Skip to content

feat: typed route ids #13864

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 31 commits into
base: main
Choose a base branch
from
Open

feat: typed route ids #13864

wants to merge 31 commits into from

Conversation

Rich-Harris
Copy link
Member

@Rich-Harris Rich-Harris commented Jun 7, 2025

resolves #11386

This is long overdue, but @jycouet goaded me into working on it with the 1.0 release of vite-plugin-kit-routes 🎉

Everyone should have type safety when dealing with routes!

This PR adds a new generated $app/types module which exports three types:

  • RouteId is a union of all the routes in your app ('/' | '/about' | '/blog' | '/blog/[slug]' etc)
  • RouteParams<T extends RouteId> is a utility that gives you the type of the params for a given route
  • LayoutParams<T extends RouteId> is the same, but also gives you the type of any children of T (for example, if you're in the /blog layout, the route could be either /blog or /blog/[slug], so slug is an optional param

Most of the time you won't use these types directly, but via things like page.route.id and resolveRoute:

Screenshot 2025-06-07 at 3 49 11 PM

Screenshot 2025-06-07 at 3 50 53 PM

Screenshot 2025-06-07 at 3 49 32 PM

Screenshot 2025-06-07 at 3 49 51 PM

I couldn't figure out a sensible way to add a test for this. Maybe someone else can, if not then I'm not too worried about it.


Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests

  • Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpm changeset and following the prompts. Changesets that add features should be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.

Edits

  • Please ensure that 'Allow edits from maintainers' is checked. PRs without this option may be closed.

Copy link

changeset-bot bot commented Jun 7, 2025

🦋 Changeset detected

Latest commit: 22b26dc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@sveltejs/kit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@svelte-docs-bot
Copy link

@jycouet
Copy link
Contributor

jycouet commented Jun 7, 2025

Haha noice!
Let's link to: #11386

Would you consider two more things ?

  • 1/ being able to specify searchParams in like +page.ts
export const params = {
  limit: 11,
  search: ''
}
  • 2/ How to add things in the list of resolveRoute ? To manage external links ?

@Rich-Harris
Copy link
Member Author

Typing search params would be great but I think that's a separate issue and it shouldn't hold up this PR. As for resolveRoute, I'm not sure what you mean — it's only meant to be used with the routes of your app, why would you need to do stuff with external links?

@Rich-Harris
Copy link
Member Author

had to bump the test timeout quite a bit, might be worth running unit tests in a separate workflow

@jycouet
Copy link
Contributor

jycouet commented Jun 7, 2025

Typing search params would be great but I think that's a separate issue and it shouldn't hold up this PR. As for resolveRoute, I'm not sure what you mean — it's only meant to be used with the routes of your app, why would you need to do stuff with external links?

Perfect to have a follow-up PR for searchParams, yeah, let's do this step by step ;)


I like to have a common way to manage ALL links (internal & external).

<!-- 🤞 before, hardcoded string, error prone -->
<a href="https://bsky.app/profile/jyc.dev">Bluesky</a>

<!-- ✅ after, typechecked route, no more errors -->
<a href={route('bluesky', { handle: 'jyc.dev' })}>Bluesky</a>

Today, I just need to feed the config like this: (similar path structure to "create" params)

plugins: [
    // ...
    kitRoutes({
      LINKS: {
        bluesky: 'https://bsky.app/profile/[handle]',
      }
    }),
],

Of course, it can be a separate issue to think a bit how to feed this info. But in general, what's your feeling ?


vite-plugin-kit-routes DEPRECATED s00n 🎉

@jycouet
Copy link
Contributor

jycouet commented Jun 7, 2025

had to bump the test timeout quite a bit, might be worth running unit tests in a separate workflow

Not tonight, but let me know where/how I can contribute to this feature.

@eltigerchino eltigerchino added the feature / enhancement New feature or request label Jun 9, 2025
@@ -7,7 +7,8 @@
"target": "es2022",
"module": "node16",
"moduleResolution": "node16",
"baseUrl": "."
"baseUrl": ".",
"skipLibCheck": true
Copy link
Member

@eltigerchino eltigerchino Jun 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need skipLibCheck? I've tried running pnpm check without it and didn't bump into any issues for the adapters.

EDIT: Alright, I'm seeing the errors now.

Copy link
Member

@eltigerchino eltigerchino Jun 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking of looking into dts-buddy to see if we can get it to keep the // @ts-ignore comments in the generated types so that we don't need skipLibCheck here. It seems like the best way to tell TypeScript "this type doesn't exist yet but will be generated" until something like microsoft/TypeScript#31894 comes along. Do you think this is worth looking into?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a shot I guess, though dts-buddy is operating on emitted declaration files and I'm pretty sure the comments are lost by that point - might be tricky to correctly recover them from the source

Copy link
Member

@eltigerchino eltigerchino Jun 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I managed to get it working in Rich-Harris/dts-buddy#110 . This should remove the need for the hack in generate-dts.js to preserve the @ts-ignore comment.

The ts-ignore is only preserved if it meets these two rules:

  1. It has to be a multi-line comment /** @ts-ignore ... */ so that it gets recognised as jsdoc
  2. It has to be above the start of a statement instead of in the middle of it such as in line 26 here https://github.com/sveltejs/kit/pull/13864/files#diff-a174e22e6d675073a963705b97687d42e219c2e05eb1cd6d5811c2581d416a8e

@Rich-Harris
Copy link
Member Author

For external links: I could imagine a future addition like this, which should be all that's needed from a configuration perspective:

// svelte.config.js
export default {
  kit: {
    paths: {
      external: {
        bluesky: 'https://bsky.app/profile/[handle]'
      }
    }
  }
};

@Rich-Harris
Copy link
Member Author

Just realised that resolveRoute('/foo') errors because of a missing second argument, which is the opposite of what's supposed to happen. Investigating

@Rich-Harris
Copy link
Member Author

Rich-Harris commented Jun 9, 2025

Another thought: #13840 proposes a resolve(...) function that behaves similarly to resolveRoute(...) but without the parameter interpolation. But do we need both? When would you want to resolve(...) something that isn't a route?

You might of course want to do this...

resolve(`/blog/${slug}`)

...instead of this:

resolveRoute('/blog/[slug]', { slug })

It would just be nice if there was a way to avoid having two very similar APIs. Especially since for kit-routes users, resolveRoute(...) feels like a bit of a downgrade from route(...).

One possibility is for resolve to accept a RouteId or a Pathname. I think that gives us the desired behaviour in basically all cases:

image

Is that too loosey-goosey, or does it feel good to people? Personally I like it. Would be great to solve this and deal with #13840 in one go.

@Rich-Harris
Copy link
Member Author

The diff that makes that work, FWIW (obviously we would need to keep resolveRoute in place, but we could deprecate it):

diff --git a/packages/kit/src/runtime/app/paths/types.d.ts b/packages/kit/src/runtime/app/paths/types.d.ts
index a9fdfb1f5..f783a4950 100644
--- a/packages/kit/src/runtime/app/paths/types.d.ts
+++ b/packages/kit/src/runtime/app/paths/types.d.ts
@@ -1,5 +1,5 @@
 // @ts-ignore
-import { RouteId, RouteParams } from '$app/types';
+import { RouteId, RouteParams, Pathname } from '$app/types';
 
 /**
  * A string that matches [`config.kit.paths.base`](https://svelte.dev/docs/kit/configuration#paths).
@@ -15,8 +15,11 @@ export let base: '' | `/${string}`;
  */
 export let assets: '' | `https://${string}` | `http://${string}` | '/_svelte_kit_assets';
 
-type ResolveRouteArgs<T extends RouteId> =
-       RouteParams<T> extends Record<string, never> ? [route: T] : [route: T, params: RouteParams<T>];
+type ResolveRouteArgs<T extends RouteId | Pathname> = T extends RouteId
+       ? RouteParams<T> extends Record<string, never>
+               ? [route: T]
+               : [route: T, params: RouteParams<T>]
+       : [route: T];
 
 /**
  * Populate a route ID with params to resolve a pathname.
@@ -33,4 +36,4 @@ type ResolveRouteArgs<T extends RouteId> =
  * ); // `/blog/hello-world/something/else`
  * ```
  */
-export function resolveRoute<T extends RouteId>(...args: ResolveRouteArgs<T>): string;
+export function resolve<T extends RouteId | Pathname>(...args: ResolveRouteArgs<T>): string;

@jycouet
Copy link
Contributor

jycouet commented Jun 9, 2025

I would like to check a few points from kit-routes to see if we have a plan for everything.


  • format - no need
    I was supporting various format
// format: route(path)        -> default <-
route("/site/[id]", { id: 7, tab: 'info' })

// format: route(symbol)
route("site_id", { id: 7, tab: 'info' })

// format: `variables` (best for code splitting & privacy)
PAGE_site_id({ id: 7, tab: 'info' })

// format: object[path]
PAGES["/site/[id]"]({ id: 7, tab: 'info' })

// format: object[symbol]
PAGES.site_id({ id: 7, tab: 'info' })

But I belive that it's not needed, route() is probably the main usage. 👍
I think that resolve would be a greate replacement. (removing resolveRoute to not have 2 ways of doing the same thing)

  • base path
    If config.kit.paths.base is set, all resolve() should return the url with the base prefix

  • removes optional params

route('/[[lang]]/one')          // ❌ Doesn't exist.
route('/one', { lang: 'fr' })   // ✅ valid path (lang is optional)
  • removes groups
route('/(gp)/two')   // ❌ Doesn't exist.
route('/two')        // ✅ valid path
  • param config
    In vite.config.ts I was able to customize a few path & params
'/site/[id]': {
  params: {
    id: { type: 'string', default: 'Vienna' },
    lang: { type: "'fr' | 'hu' | undefined", default: 'fr' },
  },
},

So with type and default
If type is not specified, it's going to string | number

  • param config with matcher
retour('/match/[id=int]', { id: 2 })
                         // id: ExtractParamType<typeof import('../params/int.ts').match

The support is not so good.
params/ab.ts is ok, but more is harder in terms of types.

  • search Params - In a future PR
    we could have in +page.ts something like:
export const searchParams = {
  limit: 11,
  search: ''
}
  • external links - In a future PR
// svelte.config.js
export default {
  kit: {
    paths: {
      external: {
        bluesky: 'https://bsky.app/profile/[handle]'
      }
    }
  }
};
  • anchors - In a future PR
'/anchors': {
  hash: {
    type: '"section0" | "section1" | "section2" | "section3"',
    required: true,
    default: 'section0',
  },
},

Could maybe be a settings in +page.ts

  • [...rest] param
<a href={route('/a/[...rest]/z', { rest: ['SWAGER', 'GRAPHIQL'] })}>Rest SWAGER GRAPHIQL</a>
  • encoding is supported
<a href={route('/[x+2e]well-known')}>Well Known</a>
<a href={route('/[u+d83e][u+dd2a]')}>🤪</a>
<a href={route('/[u+d83e][u+dd2a]/[emoji]/[u+2b50]', { emoji: '🚀' })}>🤪🚀⭐</a>
  • SERVER path
'GET /site': (params?: { lang?: 'fr' | 'en' | 'hu' | 'at' | string }) => {
  return `${params?.['lang'] ? `/${params?.['lang']}` : ''}/site`
},
  • ACTIONS path
'default /contract/[id]': (params: {
  id: string | number
  lang?: 'fr' | 'en' | 'hu' | 'at' | string
  limit?: number
  }) => {
    return `${params?.['lang'] ? `/${params?.['lang']}` : ''}/contract/${params['id']}${appendSp({ limit: params['limit'] })}`
},
'create /site': (params?: {
  lang?: 'fr' | 'en' | 'hu' | 'at' | string
  redirectTo?: 'list' | 'new' | 'detail'
  }) => {
    return `${params?.['lang'] ? `/${params?.['lang']}` : ''}/site?/create${appendSp({ redirectTo: params?.['redirectTo'] }, '&')}`
},
  • interpolation
    Managing the 2 styles would be awesome
resolve('/blog/[slug]', { slug })
resolve(`/blog/${slug}`)

We don't HAVE TO support everything from the start... But it's maybe a good checklist to see what we want right now, later and never.
Is this PR packaged somewhere to be able to try it in a project ?

Tomorrow, I could add some tests around all this? (in a PR targeting this PR)

@Rich-Harris
Copy link
Member Author

Is this PR packaged somewhere to be able to try it in a project ?

Yes, you can do this (substitute whichever package manager you're using for pnpm):

pnpm i -D https://pkg.pr.new/@sveltejs/kit@13864

@Rich-Harris
Copy link
Member Author

Annoying discovery: TypeScript collapses e.g. '/blog/[slug]' | `/blog/${string}` into `/blog/${string}`, which means you don't get autocomplete for dynamic route IDs if resolve accepts a RouteId | Pathname. If you have a catchall route (/[...catchall]) that means you won't get autocompletion at all.

You do still get type safety, but it definitely feels less magical than getting autocomplete for all your route IDs. I can't decide what the best trade-offs are here.

@Rich-Harris
Copy link
Member Author

Ah I figured it out!

image

@Rich-Harris
Copy link
Member Author

Added the asset(...) function proposed in #13840 and made it type-safe:

image

Also deprecated base and assets alongside resolveRoute, since we really want people to start using the new stuff or things will break in an async world. Whether we remove them in 3.0 or just keep them deprecated is TBD.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, few nits

<div class="ts-block">

```dts
type RouteParams<T extends RouteId> = { /* generated */ } | Record<string, never>;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a way this could be more useful?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

such as?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/* generated */ doesn't really help me understand what's actually going on here

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps an example with a concrete type param passed would be helpful? Same for the one below


// this is hacky as all hell but it gets the tests passing. might be a bug in dts-buddy?
// prettier-ignore
writeFileSync('./types/index.d.ts', types.replace("declare module '$app/server' {", `declare module '$app/server' {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:wat:


dynamic_routes.push(route_type);

pathnames.push(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as a general rule, could we pull out regexes like this into util functions that say what they do? Like... I'm pretty sure this is removing all square brackets but it is really hard to read, and there are a bunch of similar-but-different ones in the file

@benmccann
Copy link
Member

#13881 is a PR against this branch that adds additional tests

import { s } from '../../../utils/misc.js';

const remove_relative_parent_traversals = (/** @type {string} */ path) =>
path.replace(/\.\.\//g, '');

Check failure

Code scanning / CodeQL

Incomplete multi-character sanitization High

This string may still contain
../
, which may cause a path injection vulnerability.

Copilot Autofix

AI 5 days ago

To fix the issue, the regular expression replacement should be applied repeatedly until no more instances of ../ remain in the string. This ensures complete sanitization of the input. The best approach is to use a do...while loop to repeatedly apply the replacement until the input string stabilizes (i.e., no further replacements occur). This fix is localized to the remove_relative_parent_traversals function and does not alter its intended functionality.


Suggested changeset 1
packages/kit/src/core/sync/write_types/index.js

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/kit/src/core/sync/write_types/index.js b/packages/kit/src/core/sync/write_types/index.js
--- a/packages/kit/src/core/sync/write_types/index.js
+++ b/packages/kit/src/core/sync/write_types/index.js
@@ -9,4 +9,10 @@
 
-const remove_relative_parent_traversals = (/** @type {string} */ path) =>
-	path.replace(/\.\.\//g, '');
+const remove_relative_parent_traversals = (/** @type {string} */ path) => {
+    let previous;
+    do {
+        previous = path;
+        path = path.replace(/\.\.\//g, '');
+    } while (path !== previous);
+    return path;
+};
 const replace_optional_params = (/** @type {string} */ id) =>
EOF
@@ -9,4 +9,10 @@

const remove_relative_parent_traversals = (/** @type {string} */ path) =>
path.replace(/\.\.\//g, '');
const remove_relative_parent_traversals = (/** @type {string} */ path) => {
let previous;
do {
previous = path;
path = path.replace(/\.\.\//g, '');
} while (path !== previous);
return path;
};
const replace_optional_params = (/** @type {string} */ id) =>
Copilot is powered by AI and may make mistakes. Always verify output.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature / enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

resolveRoute improvements
5 participants