Skip to content

Commit

Permalink
Generated types (#4120)
Browse files Browse the repository at this point in the history
* generated tsconfig.json

* eval user config

* generate types for pages and endpoints

* factor out type generation into separate function

* add type

* tighten up include/exclude

* check test apps individually

* docs

* lint

* i think this will work

* handle weird configs

* ok try this

* tweak docs

* make it a bit more obvious

* changeset

* regenerate tsconfig when routes change

* update configs

* fix

* Revert "regenerate tsconfig when routes change"

This reverts commit 536634c.

* posixify generated paths

* fix windows path handling

Co-authored-by: Ignatius Bagus <ignatius.mbs@gmail.com>
  • Loading branch information
Rich-Harris and ignatiusmb committed Mar 2, 2022
1 parent 273a082 commit 8e68f8a
Show file tree
Hide file tree
Showing 38 changed files with 419 additions and 165 deletions.
5 changes: 5 additions & 0 deletions .changeset/honest-cows-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

Generate types for each page/endpoint
26 changes: 21 additions & 5 deletions documentation/docs/01-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,15 @@ declare module '$lib/database' {
export const get: (id: string) => Promise<Item>;
}

// @filename: [id].d.ts
import type { RequestHandler as GenericRequestHandler } from '@sveltejs/kit';
export type RequestHandler<Body = any> = GenericRequestHandler<{ id: string }, Body>;

// @filename: index.js
// ---cut---
import db from '$lib/database';

/** @type {import('@sveltejs/kit').RequestHandler} */
/** @type {import('./[id]').RequestHandler} */
export async function get({ params }) {
// `params.id` comes from [id].js
const item = await db.get(params.id);
Expand All @@ -74,11 +78,13 @@ export async function get({ params }) {
return {
status: 404
};
};
}
```

> All server-side code, including endpoints, has access to `fetch` in case you need to request data from external APIs. Don't worry about the `$lib` import, we'll get to that [later](/docs/modules#$lib).
The type of the `get` function above comes from `./[id].d.ts`, which is a file generated by SvelteKit (in a hidden directory, using the [`rootDirs`](https://www.typescriptlang.org/tsconfig#rootDirs) option) that provides type safety when accessing `params`. See the section on [generated types](/docs/types#generated-types) for more detail.

The job of a [request handler](/docs/types#sveltejs-kit-requesthandler) is to return a `{ status, headers, body }` object representing the response, where `status` is an [HTTP status code](https://httpstatusdogs.com):

- `2xx` — successful response (default is `200`)
Expand Down Expand Up @@ -148,11 +154,15 @@ declare module '$lib/database' {
export const create: (request: Request) => Promise<[Record<string, ValidationError>, Item]>;
}

// @filename: items.d.ts
import type { RequestHandler as GenericRequestHandler } from '@sveltejs/kit';
export type RequestHandler<Body = any> = GenericRequestHandler<{}, Body>;

// @filename: index.js
// ---cut---
import * as db from '$lib/database';

/** @type {import('@sveltejs/kit').RequestHandler} */
/** @type {import('./items').RequestHandler} */
export async function get() {
const items = await db.list();

Expand All @@ -161,7 +171,7 @@ export async function get() {
};
}

/** @type {import('@sveltejs/kit').RequestHandler} */
/** @type {import('./items').RequestHandler} */
export async function post({ request }) {
const [errors, item] = await db.create(request);

Expand Down Expand Up @@ -358,9 +368,15 @@ Higher priority routes can _fall through_ to lower priority routes by returning

```js
/// file: src/routes/[a].js

// @filename: [a].d.ts
import type { RequestHandler as GenericRequestHandler } from '@sveltejs/kit';
export type RequestHandler<Body = any> = GenericRequestHandler<{ a: string }, Body>;

// @filename: index.js
// @errors: 2366
/** @type {import('@sveltejs/kit').RequestHandler} */
// ---cut---
/** @type {import('./[a]').RequestHandler} */
export function get({ params }) {
if (params.a === 'foo-def') {
return { fallthrough: true };
Expand Down
4 changes: 3 additions & 1 deletion documentation/docs/03-loading.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ If the data for a page comes from its endpoint, you may not need a `load` functi
```html
/// file: src/routes/blog/[slug].svelte
<script context="module">
/** @type {import('@sveltejs/kit').Load} */
/** @type {import('./[slug]').Load} */
export async function load({ params, fetch, session, stuff }) {
const url = `https://cms.example.com/article/${params.slug}.json`;
const response = await fetch(url);
Expand All @@ -26,6 +26,8 @@ If the data for a page comes from its endpoint, you may not need a `load` functi

> Note the `<script context="module">` — this is necessary because `load` runs before the component is rendered. Code that is per-component instance should go into a second `<script>` tag.
As with [endpoints](/docs/routing#endpoints), pages can import [generated types](/docs/types#generated) — the `./[slug]` in the example above — to ensure that `params` are correctly typed.

`load` is similar to `getStaticProps` or `getServerSideProps` in Next.js, except that `load` runs on both the server and the client. In the example above, if a user clicks on a link to this page the data will be fetched from `cms.example.com` without going via our server.

If `load` returns `{fallthrough: true}`, SvelteKit will [fall through](/docs/routing#advanced-routing-fallthrough-routes) to other routes until something responds, or will respond with a generic 404.
Expand Down
71 changes: 71 additions & 0 deletions documentation/docs/14-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,74 @@ title: Types
---

**TYPES**

### Generated types

The [`RequestHandler`](#sveltejs-kit-requesthandler) and [`Load`](#sveltejs-kit-load) types both accept a `Params` argument allowing you to type the `params` object. For example this endpoint expects `foo`, `bar` and `baz` params:

```js
/// file: src/routes/[foo]/[bar]/[baz].js
// @errors: 2355
/** @type {import('@sveltejs/kit').RequestHandler<{
* foo: string;
* bar: string;
* baz: string
* }>} */
export async function get({ params }) {
// ...
}
```

Needless to say, this is cumbersome to write out, and less portable (if you were to rename the `[foo]` directory to `[qux]`, the type would no longer reflect reality).

To solve this problem, SvelteKit generates `.d.ts` files for each of your endpoints and pages:

```ts
/// file: .svelte-kit/types/src/routes/[foo]/[bar]/[baz].d.ts
/// link: false
import type { RequestHandler as GenericRequestHandler, Load as GenericLoad } from '@sveltejs/kit';

export type RequestHandler<Body = any> = GenericRequestHandler<
{ foo: string; bar: string; baz: string },
Body
>;

export type Load<Props = Record<string, any>> = GenericLoad<
{ foo: string; bar: string; baz: string },
Props
>;
```

These files can be imported into your endpoints and pages as siblings, thanks to the [`rootDirs`](https://www.typescriptlang.org/tsconfig#rootDirs) option in your TypeScript configuration:

```js
/// file: src/routes/[foo]/[bar]/[baz].js
// @filename: [baz].d.ts
import type { RequestHandler as GenericRequestHandler, Load as GenericLoad } from '@sveltejs/kit';

export type RequestHandler<Body = any> = GenericRequestHandler<
{ foo: string, bar: string, baz: string },
Body
>;

// @filename: index.js
// @errors: 2355
// ---cut---
/** @type {import('./[baz]').RequestHandler} */
export async function get({ params }) {
// ...
}
```

```svelte
<script context="module">
/** @type {import('./[baz]').Load} */
export async function load({ params, fetch, session, stuff }) {
// ...
}
</script>
```

> For this to work, your own `tsconfig.json` or `jsconfig.json` should extend from the generated `.svelte-kit/tsconfig.json`:
>
> { "extends": ".svelte-kit/tsconfig.json" }
29 changes: 1 addition & 28 deletions packages/create-svelte/templates/default/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,30 +1,3 @@
{
"compilerOptions": {
"moduleResolution": "node",
"module": "es2020",
"lib": ["es2020"],
"target": "es2020",
/**
svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript
to enforce using \`import type\` instead of \`import\` for Types.
*/
"importsNotUsedAsValues": "error",
"isolatedModules": true,
"resolveJsonModule": true,
/**
To have warnings/errors of the Svelte compiler at the correct position,
enable source maps by default.
*/
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"allowJs": true,
"checkJs": true,
"paths": {
"$lib/*": ["src/lib/*"]
}
},
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"]
"extends": "./.svelte-kit/tsconfig.json"
}
10 changes: 9 additions & 1 deletion packages/kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,15 @@
"build": "rollup -c && node scripts/cp.js src/runtime/components assets/components && npm run types",
"dev": "rollup -cw",
"lint": "eslint --ignore-path .gitignore --ignore-pattern \"src/packaging/test/**\" \"{src,test}/**/*.{ts,mjs,js,svelte}\" && npm run check-format",
"check": "tsc && svelte-check --ignore test/prerendering,src/packaging/test",
"check": "tsc && npm run check:integration && npm run check:prerendering",
"check:integration": "npm run check:integration:amp && npm run check:integration:basics && npm run check:integration:options && npm run check:integration:options-2",
"check:integration:amp": "cd test/apps/amp && pnpm check",
"check:integration:basics": "cd test/apps/basics && pnpm check",
"check:integration:options": "cd test/apps/options && pnpm check",
"check:integration:options-2": "cd test/apps/options-2 && pnpm check",
"check:prerendering": "npm run check:prerendering:basics && npm run check:prerendering:options",
"check:prerendering:basics": "cd test/prerendering/basics && pnpm check",
"check:prerendering:options": "cd test/prerendering/options && pnpm check",
"format": "npm run check-format -- --write",
"check-format": "prettier --check . --config ../../.prettierrc --ignore-path .gitignore",
"prepublishOnly": "npm run build",
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/core/build/build_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ export async function build_client({
process.env.VITE_SVELTEKIT_APP_VERSION_POLL_INTERVAL = `${config.kit.version.pollInterval}`;

create_app({
config,
manifest_data,
output: `${SVELTE_KIT}/generated`,
cwd
});

Expand Down
77 changes: 75 additions & 2 deletions packages/kit/src/core/create_app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { s } from '../../utils/misc.js';
import { mkdirp } from '../../utils/filesystem.js';
import { SVELTE_KIT } from '../constants.js';

/** @type {Map<string, string>} */
const previous_contents = new Map();
Expand All @@ -22,16 +23,19 @@ export function write_if_changed(file, code) {

/**
* @param {{
* config: import('types').ValidatedConfig;
* manifest_data: ManifestData;
* output: string;
* cwd: string;
* }} options
*/
export function create_app({ manifest_data, output, cwd = process.cwd() }) {
export function create_app({ config, manifest_data, cwd = process.cwd() }) {
const output = `${SVELTE_KIT}/generated`;
const base = path.relative(cwd, output);

write_if_changed(`${output}/manifest.js`, generate_client_manifest(manifest_data, base));
write_if_changed(`${output}/root.svelte`, generate_app(manifest_data));

create_types(config, manifest_data);
}

/**
Expand Down Expand Up @@ -189,3 +193,72 @@ function generate_app(manifest_data) {
{/if}
`);
}

/**
* @param {import('types').ValidatedConfig} config
* @param {ManifestData} manifest_data
*/
function create_types(config, manifest_data) {
/** @type {Map<string, { params: string[], type: 'page' | 'endpoint' | 'both' }>} */
const shadow_types = new Map();

/** @param {string} key */
function extract_params(key) {
/** @type {string[]} */
const params = [];

const pattern = /\[([^\]]+)\]/g;
let match;

while ((match = pattern.exec(key))) {
params.push(match[1]);
}

return params;
}

manifest_data.routes.forEach((route) => {
if (route.type === 'endpoint') {
const key = route.file.slice(0, -path.extname(route.file).length);
shadow_types.set(key, { params: extract_params(key), type: 'endpoint' });
} else if (route.shadow) {
const key = route.shadow.slice(0, -path.extname(route.shadow).length);
shadow_types.set(key, { params: extract_params(key), type: 'both' });
}
});

manifest_data.components.forEach((component) => {
if (component.startsWith('.')) return; // exclude fallback components

const ext = /** @type {string} */ (config.extensions.find((ext) => component.endsWith(ext)));
const key = component.slice(0, -ext.length);

if (!shadow_types.has(key)) {
shadow_types.set(key, { params: extract_params(key), type: 'page' });
}
});

shadow_types.forEach(({ params, type }, key) => {
const arg = `{ ${params.map((param) => `${param}: string`).join('; ')} }`;

const imports = [
type !== 'page' && 'RequestHandler as GenericRequestHandler',
type !== 'endpoint' && 'Load as GenericLoad'
]
.filter(Boolean)
.join(', ');

const file = `${SVELTE_KIT}/types/${key || 'index'}.d.ts`;
const content = [
'// this file is auto-generated',
`import type { ${imports} } from '@sveltejs/kit';`,
type !== 'page' && `export type RequestHandler = GenericRequestHandler<${arg}>;`,
type !== 'endpoint' &&
`export type Load<Props = Record<string, any>> = GenericLoad<${arg}, Props>;`
]
.filter(Boolean)
.join('\n');

write_if_changed(file, content);
});
}
3 changes: 1 addition & 2 deletions packages/kit/src/core/dev/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export async function create_plugin(config, cwd) {
function update_manifest() {
const manifest_data = create_manifest_data({ config, cwd });

create_app({ manifest_data, output: `${SVELTE_KIT}/generated`, cwd });
create_app({ config, manifest_data, cwd });

manifest = {
appDir: config.kit.appDir,
Expand Down Expand Up @@ -200,7 +200,6 @@ export async function create_plugin(config, cwd) {

/** @type {import('types').Hooks} */
const hooks = {
// @ts-expect-error this picks up types that belong to the tests
getSession: user_hooks.getSession || (() => ({})),
handle: amp ? sequence(amp, handle) : handle,
handleError:
Expand Down
Loading

0 comments on commit 8e68f8a

Please sign in to comment.