Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 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
bb882fa
merge main
Rich-Harris Jul 23, 2025
8895442
empty
Rich-Harris Jul 23, 2025
66c209d
add an example
Rich-Harris Jul 23, 2025
f80faf1
silence error
Rich-Harris Jul 23, 2025
a73f34a
fix typo
Rich-Harris Jul 23, 2025
7f5b752
link from deprecation notices
Rich-Harris Jul 24, 2025
de26113
since
Rich-Harris Jul 24, 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>`
91 changes: 91 additions & 0 deletions documentation/docs/98-reference/20-$app-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
title: $app/types
---

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

<blockquote class="since note">
<p>Available since 2.26</p>
</blockquote>

```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 = `${'' | `/${string}`}/` | `${'' | `/${string}`}/my-route` | `${'' | `/${string}`}/my-other-route/${string}` | {};
```

</div>

## RouteParams

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

```ts
// @errors: 2552
type BlogParams = RouteParams<'/blog/[slug]'>; // { slug: string }
```

<div class="ts-block">

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

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

Copy link
Member Author

Choose a reason for hiding this comment

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

ah nice. I don't have time to review that PR right now but I left a comment over there to remind myself to come back and clean this up later

},
"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,7 +12,8 @@
"baseUrl": ".",
"paths": {
"@sveltejs/kit": ["../kit/types/index"]
}
},
"skipLibCheck": true
},
"include": ["*.js", "files/**/*.js", "internal.d.ts", "test/**/*.js"]
}
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:

Copy link
Member Author

Choose a reason for hiding this comment

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

// @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
80 changes: 76 additions & 4 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,15 @@ 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';

const remove_relative_parent_traversals = (/** @type {string} */ path) =>
path.replace(/\.\.\//g, '');
const replace_optional_params = (/** @type {string} */ id) =>
id.replace(/\/\[\[[^\]]+\]\]/g, '${string}');
const replace_required_params = (/** @type {string} */ id) =>
id.replace(/\/\[[^\]]+\]/g, '/${string}');
const is_whitespace = (/** @type {string} */ char) => /\s/.test(char);

/**
* @typedef {{
Expand Down Expand Up @@ -35,7 +44,9 @@ export function write_all_types(config, manifest_data) {
const types_dir = `${config.kit.outDir}/types`;

// empty out files that no longer need to exist
const routes_dir = posixify(path.relative('.', config.kit.files.routes)).replace(/\.\.\//g, '');
const routes_dir = remove_relative_parent_traversals(
posixify(path.relative('.', config.kit.files.routes))
);
const expected_directories = new Set(
manifest_data.routes.map((route) => path.join(routes_dir, route.id))
);
Expand All @@ -49,6 +60,65 @@ 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(`\`${replace_required_params(replace_optional_params(route.id))}\` & {}`);
} 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 Expand Up @@ -174,7 +244,9 @@ function create_routes_map(manifest_data) {
* @param {Set<string>} [to_delete]
*/
function update_types(config, routes, route, to_delete = new Set()) {
const routes_dir = posixify(path.relative('.', config.kit.files.routes)).replace(/\.\.\//g, '');
const routes_dir = remove_relative_parent_traversals(
posixify(path.relative('.', config.kit.files.routes))
);
const outdir = path.join(config.kit.outDir, 'types', routes_dir, route.id);

// now generate new types
Expand Down Expand Up @@ -733,7 +805,7 @@ export function tweak_types(content, is_server) {
if (declaration.type) {
let a = declaration.type.pos;
const b = declaration.type.end;
while (/\s/.test(content[a])) a += 1;
while (is_whitespace(content[a])) a += 1;

const type = content.slice(a, b);
code.remove(declaration.name.end, declaration.type.end);
Expand Down Expand Up @@ -805,7 +877,7 @@ export function tweak_types(content, is_server) {
if (declaration.type) {
let a = declaration.type.pos;
const b = declaration.type.end;
while (/\s/.test(content[a])) a += 1;
while (is_whitespace(content[a])) a += 1;

const type = content.slice(a, b);
code.remove(declaration.name.end, declaration.type.end);
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
30 changes: 30 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,30 @@
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';

// read `id` otherwise it is treated as unused
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';

// read `pathname` otherwise it is treated as unused
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