me.at.coding
me.at.coding

Reputation: 17724

TypeScript safe route names?

Consider the following code:

const router = useRouter()

await router.push({
  name: 'NonExistingRoute', // no typescript error ):
}) 

A non existing route name was given, but no TypeScript error happens. Instead the issue will only be noticed on runtime. Any way to get a compile-time error on this?

Upvotes: 3

Views: 1333

Answers (5)

Norfeldt
Norfeldt

Reputation: 9688

Coming from react and just fiddling around with Vue3 to understand it. I wanted to make routes more safe in regards to params and came across this old question. Just wanted to share my current take (just fiddling around as said):

showing how ts errors out on invalid route name

import { createRouter, createWebHistory, type RouteLocationRaw } from 'vue-router'
import ChatRoomsView from '@/views/ChatRoomsView.vue'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      name: 'chat-rooms',
      component: ChatRoomsView,
    },
    {
      path: '/login',
      name: 'login',
      component: () => import('@/views/LoginView.vue')
    },
    {
      path: '/chat-room/:id/',
      name: 'chat-room',
      component: () => import('@/views/ChatView.vue'),
    },
    {
      // with hash mode, the part of the URL after the # symbol represents the "virtual" path of your SPA, and any changes to it won't trigger a server request. This makes it ideal for SPAs without server-side configurations
      path: '/#/error/:message', 
      name: 'error',
      component: () => import('@/views/ErrorView.vue')
    }
  ]
})

const linking = {
  login: {},
  'chat-rooms': {},
  'chat-room': { id: '' },
  error: { message: '' }
};

// DEMO
router.push({ name: 'NonExistingRoute' } satisfies  Record<'name', keyof Linking>)

function validateRoutes(routes: ReturnType<typeof router.getRoutes>, links: typeof linking) {
  for (const route of routes) {
    const expectedParams = links[route.name as keyof typeof linking];
    const routeParams = (route.path.match(/:\w+/g) || []).map(p => p.slice(1));

    const missingParams = Object.keys(expectedParams).filter(param => !routeParams.includes(param));
    const undeclaredParams = routeParams.filter(param => !Object.keys(expectedParams).includes(param));

    if (missingParams.length > 0 || undeclaredParams.length > 0) {
      let errorMessage = `Route ${String(route.name)} has a mismatch in parameters.\nPath: ${route.path}\n`;

      if (missingParams.length > 0) {
        errorMessage += `Missing parameters: [${missingParams.join(', ')}]\n`;
      }

      if (undeclaredParams.length > 0) {
        errorMessage += `Undeclared types for parameters: [${undeclaredParams.join(', ')}]`;
      }

      router.push({ name: 'error', params: { message: errorMessage.trim() } });
    }
  }
}

export const onMountValidateRoutes = () => validateRoutes(router.getRoutes(), linking);

export default router

export type RouteParamsExtractor<T, K extends keyof T> = T[K]
export type Linking = typeof linking

App.vue

<script setup lang="ts">
import { RouterView } from 'vue-router'
import { onMountValidateRoutes } from './router'

onMountValidateRoutes()
</script>

<template>
  <RouterView />
</template>

<style scoped></style>

ErrorView.vue

<script setup lang="ts">
import { useRoute } from 'vue-router'

const route = useRoute()
const message = (route.params.message as string)
  .split('\n')
  .map((line) => line.trim())
  .join('\n')
  .trim()
</script>

<template>
  <main>
    <h1>Error</h1>
    <pre>{{ message }}</pre>
  </main>
</template>

<style scoped>
pre {
  background-color: #1b202c;
  color: var(--color-text);
  padding: 10px;
  border-radius: 5px;
  font-size: 14px;
}
</style>

if I then haven typed (via the linking) the params I have declared in my routes I get to a screen that looks like:

error screen for undeclared param type

Upvotes: 0

stackoverfloweth
stackoverfloweth

Reputation: 6917

my solution to this dilemma

define your routes in routes.ts

import { RouteLocationRaw } from 'vue-router'

type RouteFunction = (...args: any[]) => RouteLocationRaw

export const routes = {
  login: () => ({ name: 'Login' }) as const,
  viewProfile: (userId: string) => ({ name: 'ViewProfile', params: { userId } }) as const,
  ...
} satisfies Readonly<Record<string, RouteFunction>>

export type Routes = typeof routes
export type NamedRoute = ReturnType<Routes[keyof Routes]>['name']

Note that each route needs to be as const, otherwise typescript will widen the ['name'] prop to any string.

Then the actual router gets defined as

import { RouteRecordRaw, createRouter, createWebHistory } from 'vue-router'
import { NamedRoute } from '@/router/routes'

type NamedRouteRecordParent = { name?: NamedRoute, children: NamedRouteRecord[] }
type NamedRouteRecordChild = { name: NamedRoute }
type NamedRouteRecord = Omit<RouteRecordRaw, 'name' | 'children'> & NamedRouteRecordParent | NamedRouteRecordChild

const routes: NamedRouteRecord[] = [
  { name: 'Login', path: '/login' },
  { name: 'ViewProfile', path: '/view-profile/:userId' },
]

export const router = createRouter({
  history: createWebHistory(),
  routes: routes as RouteRecordRaw[],
})

which forces developers to only name routes that match records found in routes.ts. As written, "parent" records do not have to be named.

Upvotes: 1

user2573339
user2573339

Reputation: 138

Perhaps you could wrap this in a utility function that only accepts typed route strings

const router = useRouter()
export type NamedRoute = "login" | "logout" | "user-profile";

export async function goToNamedRoute(name: NamedRoute): Promise<void> {
    return router.push({name});
}

Upvotes: 1

Julio Pereira
Julio Pereira

Reputation: 100

If you want to rely on Typescript for detecting wrong routes you might just use enums or closed types maybe?, although that will surely require some composition. Probably one way to go could be:

enum Cities {
   NY, 
   London
}

function routeCreator(city: Cities, restOfPath?: string){
    //combine paths somehow, e.g.
    if(!restOfPath) return `/${Cities[city]}/`;
    return `/${Cities[city]}/${restOfPath}`
}

Upvotes: 0

Fraser
Fraser

Reputation: 17094

In short no.

For a compile error to exist there would need to be something explicitly wrong with the code, referencing an non-existent file, syntax error, etc.

It does sound like you are trying to solve some other issue here...i.e. why do you have the names of non-existing routes in your app?

In any case, perhaps you can avoid your errors programmatically, e.g.

 let r = router.resolve({name: 'NonExistingRoute'});
 if (r.resolved.matched.length > 0){
    // exists
 } else {
    // doesn't exist
 }

Upvotes: 0

Related Questions