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
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
74c0fd7
add `RouteId` to `$app/types`
Rich-Harris Jun 7, 2025
c95a69f
more
Rich-Harris Jun 7, 2025
4fe5f0b
create directory
Rich-Harris Jun 7, 2025
5669c42
hmm
Rich-Harris Jun 7, 2025
3e6b274
fix
Rich-Harris Jun 7, 2025
df2293d
changeset
Rich-Harris Jun 7, 2025
039fa0f
regenerate
Rich-Harris Jun 7, 2025
e2afa93
some basic docs
Rich-Harris Jun 7, 2025
42ea4f8
oh wow that seemed to work
Rich-Harris Jun 7, 2025
26beba7
test
Rich-Harris Jun 7, 2025
5865e34
ouch
Rich-Harris Jun 7, 2025
e155d92
oh actually lets do this instead
Rich-Harris Jun 8, 2025
a483961
fix
Rich-Harris Jun 8, 2025
7987509
couple more, though still doesnt quite fix everything
Rich-Harris Jun 8, 2025
7cf2cfc
forgive me
Rich-Harris Jun 8, 2025
8c8c037
fix
Rich-Harris Jun 8, 2025
c6c3355
fix
Rich-Harris Jun 8, 2025
cfd16ed
make url.pathname type safe as well
Rich-Harris Jun 8, 2025
c000bb4
oops didnt mean to commit that
Rich-Harris Jun 8, 2025
945f9cc
fix
Rich-Harris Jun 9, 2025
9bd9a67
page.url.pathname needs to account for base path
Rich-Harris Jun 9, 2025
82d6695
resolveRoute -> resolve
Rich-Harris Jun 9, 2025
6cd32d4
add `asset(...)` function, deprecate `base` and `assets`
Rich-Harris Jun 9, 2025
eb8b876
fix
Rich-Harris Jun 9, 2025
f6c4b35
changesets
Rich-Harris Jun 9, 2025
c1987e0
regenerate
Rich-Harris Jun 9, 2025
96de94b
Update packages/kit/src/core/sync/write_types/test/app-types/+page.ts
elliott-with-the-longest-name-on-github Jun 12, 2025
5a30a83
Update packages/kit/src/core/sync/write_types/test/app-types/+page.ts
elliott-with-the-longest-name-on-github Jun 12, 2025
eac2a0e
chore: extract regexes into named functions
elliott-with-the-longest-name-on-github Jun 16, 2025
5445a93
Update documentation/docs/98-reference/20-$app-types.md
Rich-Harris Jun 17, 2025
22b26dc
Update documentation/docs/98-reference/20-$app-types.md
eltigerchino Jun 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/proud-rules-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: better type-safety for `page.route.id`, `page.params`, page.url.pathname` and various other places
5 changes: 5 additions & 0 deletions .changeset/slow-weeks-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: `resolve(...)` and `asset(...)` helpers for resolving paths
5 changes: 5 additions & 0 deletions .changeset/wicked-bananas-yawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: Add `$app/types` module with `Asset`, `RouteId`, `Pathname`, `ResolvedPathname` `RouteParams<T>` and `LayoutParams<T>`
82 changes: 82 additions & 0 deletions documentation/docs/98-reference/20-$app-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
title: $app/types
---

This module contains generated types for the routes in your app.

```js
// @noErrors
import type { RouteId, RouteParams, LayoutParams } from '$app/types';
```

## Asset

A union of all the filenames of assets contained in your `static` directory.

<div class="ts-block">

```dts
type Asset = '/favicon.png' | '/robots.txt';
```

</div>

## RouteId

A union of all the route IDs in your app. Used for `page.route.id` and `event.route.id`.

<div class="ts-block">

```dts
type RouteId = '/' | '/my-route' | '/my-other-route/[param]';
```

</div>

## Pathname

A union of all valid pathnames in your app.

<div class="ts-block">

```dts
type Pathname = '/' | '/my-route' | `/my-other-route/${string}`;
```

</div>

## ResolvedPathname

`Pathname`, but possibly prefixed with a [base path](https://svelte.dev/docs/kit/configuration#paths). Used for `page.url.pathname`.

<div class="ts-block">

```dts
type Pathname = '/' | '/my-route' | `/my-other-route/${string}`;
```

</div>

## RouteParams

A utility for getting the parameters associated with a given route.

<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

```

</div>

## LayoutParams

A utility for getting the parameters associated with a given layout, which is similar to `RouteParams` but also includes optional parameters for any child route.

<div class="ts-block">

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

</div>
3 changes: 2 additions & 1 deletion packages/adapter-auto/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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

},
"include": ["**/*.js"]
}
3 changes: 2 additions & 1 deletion packages/adapter-node/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"baseUrl": ".",
"paths": {
"@sveltejs/kit": ["../kit/types/index"]
}
},
"skipLibCheck": true
},
"include": ["index.js", "src/**/*.js", "tests/**/*.js", "internal.d.ts", "utils.js"],
"exclude": ["tests/smoke.spec_disabled.js"]
Expand Down
3 changes: 2 additions & 1 deletion packages/adapter-static/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"baseUrl": ".",
"paths": {
"@sveltejs/kit": ["../kit/types/index"]
}
},
"skipLibCheck": true
},
"include": ["index.js", "test/utils.js"]
}
3 changes: 2 additions & 1 deletion packages/adapter-vercel/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"baseUrl": ".",
"paths": {
"@sveltejs/kit": ["../kit/types/index"]
}
},
"skipLibCheck": true
}
}
8 changes: 7 additions & 1 deletion packages/kit/scripts/generate-dts.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createBundle } from 'dts-buddy';
import { readFileSync } from 'node:fs';
import { readFileSync, writeFileSync } from 'node:fs';

await createBundle({
output: 'types/index.d.ts',
Expand Down Expand Up @@ -28,3 +28,9 @@ if (types.includes('__sveltekit/')) {
types
);
}

// 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:

// @ts-ignore
import { LayoutParams as AppLayoutParams, RouteId as AppRouteId } from '$app/types'`));
5 changes: 4 additions & 1 deletion packages/kit/src/core/sync/write_tsconfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,10 @@ export function get_tsconfig(kit) {
const config = {
compilerOptions: {
// generated options
paths: get_tsconfig_paths(kit),
paths: {
...get_tsconfig_paths(kit),
'$app/types': ['./types/index.d.ts']
},
rootDirs: [config_relative('.'), './types'],

// essential options
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/sync/write_tsconfig.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test('Creates tsconfig path aliases from kit.alias', () => {
// $lib isn't part of the outcome because there's a "path exists"
// check in the implementation
expect(compilerOptions.paths).toEqual({
'$app/types': ['./types/index.d.ts'],
simpleKey: ['../simple/value'],
'simpleKey/*': ['../simple/value/*'],
key: ['../value'],
Expand Down
62 changes: 62 additions & 0 deletions packages/kit/src/core/sync/write_types/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import MagicString from 'magic-string';
import { posixify, rimraf, walk } from '../../../utils/filesystem.js';
import { compact } from '../../../utils/array.js';
import { ts } from '../ts.js';
import { s } from '../../../utils/misc.js';

/**
* @typedef {{
Expand Down Expand Up @@ -49,6 +50,67 @@ export function write_all_types(config, manifest_data) {
}
}

/** @type {string[]} */
const pathnames = [];

/** @type {string[]} */
const dynamic_routes = [];

/** @type {string[]} */
const layouts = [];

for (const route of manifest_data.routes) {
if (route.params.length > 0) {
const params = route.params.map((p) => `${p.name}${p.optional ? '?:' : ':'} string`);
const route_type = `${s(route.id)}: { ${params.join('; ')} }`;

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

`\`${route.id.replace(/\/\[\[[^\]]+\]\]/g, '${string}').replace(/\/\[[^\]]+\]/g, '/${string}')}\` & {}`
);
} else {
pathnames.push(s(route.id));
}

/** @type {Map<string, boolean>} */
const child_params = new Map(route.params.map((p) => [p.name, p.optional]));

for (const child of manifest_data.routes.filter((r) => r.id.startsWith(route.id))) {
for (const p of child.params) {
if (!child_params.has(p.name)) {
child_params.set(p.name, true); // always optional
}
}
}

const layout_params = Array.from(child_params)
.map(([name, optional]) => `${name}${optional ? '?:' : ':'} string`)
.join('; ');

const layout_type = `${s(route.id)}: ${layout_params.length > 0 ? `{ ${layout_params} }` : 'undefined'}`;
layouts.push(layout_type);
}

try {
fs.mkdirSync(types_dir, { recursive: true });
} catch {}

fs.writeFileSync(
`${types_dir}/index.d.ts`,
[
`type DynamicRoutes = {\n\t${dynamic_routes.join(';\n\t')}\n};`,
`type Layouts = {\n\t${layouts.join(';\n\t')}\n};`,
// we enumerate these rather than doing `keyof Routes` so that the list is visible on hover
`export type RouteId = ${manifest_data.routes.map((r) => s(r.id)).join(' | ')};`,
'export type RouteParams<T extends RouteId> = T extends keyof DynamicRoutes ? DynamicRoutes[T] : Record<string, never>;',
'export type LayoutParams<T extends RouteId> = Layouts[T] | Record<string, never>;',
`export type Pathname = ${pathnames.join(' | ')};`,
'export type ResolvedPathname = `${"" | `/${string}`}${Pathname}`;',
`export type Asset = ${manifest_data.assets.map((asset) => s('/' + asset.file)).join(' | ') || 'never'};`
].join('\n\n')
);

// Read/write meta data on each invocation, not once per node process,
// it could be invoked by another process in the meantime.
const meta_data_file = `${types_dir}/route_meta_data.json`;
Expand Down
18 changes: 9 additions & 9 deletions packages/kit/src/core/sync/write_types/index.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { assert, expect, test } from 'vitest';
Expand Down Expand Up @@ -33,15 +34,14 @@ test('Creates correct $types', { timeout: 6000 }, () => {
// To save us from creating a real SvelteKit project for each of the tests,
// we first run the type generation directly for each test case, and then
// call `tsc` to check that the generated types are valid.
run_test('actions');
run_test('simple-page-shared-only');
run_test('simple-page-server-only');
run_test('simple-page-server-and-shared');
run_test('layout');
run_test('layout-advanced');
run_test('slugs');
run_test('slugs-layout-not-all-pages-have-load');
run_test('param-type-inference');
const directories = fs
.readdirSync(cwd)
.filter((dir) => fs.statSync(`${cwd}/${dir}`).isDirectory());

for (const dir of directories) {
run_test(dir);
}

try {
execSync('pnpm testtypes', { cwd });
} catch (e) {
Expand Down
28 changes: 28 additions & 0 deletions packages/kit/src/core/sync/write_types/test/app-types/+page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { RouteId, RouteParams, Pathname } from './.svelte-kit/types/index.d.ts';

declare let id: RouteId;

// okay
id = '/';
id = '/foo/[bar]/[baz]';

// @ts-expect-error
id = '/nope';

id;

declare let params: RouteParams<'/foo/[bar]/[baz]'>;

// @ts-expect-error
params.foo; // not okay
params.bar; // okay
params.baz; // okay

declare let pathname: Pathname;

// @ts-expect-error
pathname = '/nope';
pathname = '/foo';
pathname = '/foo/1/2';

pathname;
2 changes: 1 addition & 1 deletion packages/kit/src/core/sync/write_types/test/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
"types": ["../../../../types/internal"]
}
},
"include": ["./**/*.js"],
"include": ["./**/*.js", "./**/*.ts"],
"exclude": ["./**/.svelte-kit/**"]
}
Loading
Loading