-
-
Notifications
You must be signed in to change notification settings - Fork 2k
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
Generated types for pages/endpoints #647
Comments
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment was marked as off-topic.
This comment was marked as off-topic.
Originally posted in #3090, here I describe how Describe the problemIn larger projects (also in smaller projects) it would be great if the
It would be great if the Describe the proposed solutionThe problem could be solved by providing advanced TypeScript types for the These types then could be used to enhance the Here is a working example how I think this could look like:
type SplitPath<S extends string> =
S extends `/${infer Part}/${infer Rest}`
? ['/', Part, '/', ...SplitPath<Rest>]
: S extends `${infer Part}/${infer Rest}`
? [Part, '/', ...SplitPath<Rest>]
: S extends `/${infer Part}`
? ['/', Part]
: S extends ''
? []
: S extends `${infer Part}`
? [Part]
: []
type RemoveEmptyEntries<A extends Array<unknown>> =
A extends []
? []
: A extends [infer Item, ...infer Rest]
? Item extends ''
? RemoveEmptyEntries<Rest>
: [Item, ...RemoveEmptyEntries<Rest>]
: []
// alias type to get better TypeScript hints inside tooltips
type id = string
// this type is dynamic and get's generated
type Routes =
| ['/'] // index.svelte
| ['/', 'about'] // about/index.svelte
| ['/', 'products'] // products/index.svelte
| ['/', 'products', '/', 'create'] // products/index.svelte
| ['/', 'products', '/', id] // products/[id]/index.svelte
| ['/', 'products', '/', id, '/', 'edit'] // products/[id]/edit.svelte
export type IsValidRoute<R extends string> =
R extends `http${string}`
? R
: RemoveEmptyEntries<SplitPath<R>> extends Routes
? R
: 'No such Route'
const goto = <Route extends string>(href: IsValidRoute<Route>): void => {
// TODO: goto href
}
// @ts-expect-error
goto('')
goto('/')
goto('/about')
// @ts-expect-error
goto('/invalid')
// @ts-expect-error
goto('/product')
goto('/products')
// @ts-expect-error
goto('/products/')
// @ts-expect-error
goto('/products/create/')
goto('/products/123456')
// @ts-expect-error
goto('/products/123456/')
// @ts-expect-error
goto('/products/123456/add')
goto('/products/123456/edit')
// @ts-expect-error
goto('/products/123456/5678/edit')
goto('https://kit.svelte.dev')
type Methods =
| 'GET'
| 'POST'
| 'PUT'
| 'PATCH'
| 'DELETE'
// this type is dynamic and get's generated
type Endpoints = {
GET:
| ['/', 'products']
| ['/', 'products', '/', id]
POST:
| ['/', 'products']
PATCH:
| ['/', 'products', '/', id]
}
export type IsValidEndpoint<M extends Methods, R extends string> =
R extends `http${string}`
? R
: M extends keyof Endpoints
? RemoveEmptyEntries<SplitPath<R>> extends Endpoints[M]
? R
: 'No such Endpoint'
: 'No such Method'
const fetch = <Endpoint extends string, Method extends Methods = 'GET'>(endpoint: IsValidEndpoint<Method, Endpoint>, options?: { method?: Method, [key: string]: any }): void => {
// TODO: call fetch
}
fetch('/products')
// @ts-expect-error
fetch('products')
fetch('/products/12345')
fetch('/products', { method: 'POST' })
// @ts-expect-error
fetch('/products', { method: 'PATCH' })
// @ts-expect-error
fetch('/products/12345', { method: 'POST' })
fetch('/products/12345', { method: 'PATCH' })
fetch('http://example.com/articles') You can copy these examples to a |
Today I thought about how this could be implemented. I took a look how the current types are referenced and how the topics discussed above could fit in.
routing
When looking at the /// <reference types="../.svelte-kit/types" /> This file then imports some general SvelteKit type information and extends them with the generated types: /// <reference types="@sveltejs/kit" />
type IsValidRoute = /* ... */
declare module '$app/navigation' {
export function goto<Route extends string>(href: IsValidRoute<Route>, opts): Promise<any>
// ...
} This would be a breaking change since all existing SvelteKit projects would need to update the When starting with a fresh project, the loadWhen looking at the types for the ´load´ function, it would be great if the types could get injected automatically into the svelte routes. enhance functionI think this would be possible via preprocessing the files to add the type. So you could write: export async function load({ params }) {
// params has type Record<string, string>
} and the preprocessor would turn this into: /** @type {import('./[slug]').Load} */
export async function load({ params }) {
// params has type { slug: string }
} inject typeIt's probably a bad idea to look for an export named /** @type {Load} */
export async function load({ params }) { } and the preprocessor would turn it into: /** @typedef { import('./[slug]').Load} Load`,
/** @type {Load} */
export async function load({ params }) { } So you won't actually import the virtual moduleOr maybe import it from a "virtual" package like /** @type {import('@sveltekit/types').Load} */
export async function load({ params }) { } and the preprocessor turns it into: /** @type {import('./[slug]').Load} */
export async function load({ params }) { } conclusionThese are my thoughts. When thinking about the What do you think? |
There's new community package that addresses typesafety between endpoints and routes in sveltekit. I felt like it could be relevant to this discussion or atleast the folks following this thread. Tbh, I don't know much about its internals, but it uses tRPC to achieve said typesafety. |
Ah great that there exists a package for |
@kwangure I really like the idea of those helper functions. I was just about to write another comment on this thread ^^. This should be a
|
+1 for this. Right now, we have a super messy workaround to share types between standalone api endpoints and fetch helpers, but even just being able to import the RequestHandler's return type and wrap up fetch would be a lifesaver. |
Posting here from my discussion (#11042) and issue (#11101) as I was not aware that this existed. I've come up with an approach that should work with Svelte's new Essentially we want a way to call our API endpoints so that we have typing automatically generated. I came to much the same conclusion as many other people in this thread and developed a working prototype for how we could get typed API routes with a fetch wrapper // Typed response returned by `GET` `POST` or other methods
export interface TypedResponse<A> extends Response {
json(): Promise<A>;
} import { json } from "@sveltejs/kit";
// A typed json wrapper (this could probably just replace `json`
export function typed_json<A>(data: A): TypedResponse<A> {
return json(data)
} Autogenerated // autogenerated imports for the +server.ts files
import type * as AnotherAPI from "./api/another_api/+server";
import type * as ApiPostsPostParam from "./api/[post]/+server";
// Define a utility type that extracts the type a Promise resolves to
// from a function that returns a Promise.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type APIReturnType<T> = T extends (...args: any[]) => Promise<TypedResponse<infer O>> ? Promise<O> : never;
type Methods = "GET" | "POST" | "PUT" | "DELETE";
// These function overloads would be programmatically generated based on the manifest from +server.ts files
export async function api_fetch(url: "/api/another_api", method: "GET"): APIReturnType<typeof AnotherAPI.GET>
export async function api_fetch(url: "/api/[post]", method: "GET", params: { post: string }): APIReturnType<typeof ApiPostsPostParam.GET>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function api_fetch(url: string, method: Methods, params?: Record<string, string>): Promise<any> {
// parse the url
// inject the params into the [param] sections
const response = await fetch(url, { method });
return response.json();
}
import { typed_json, type TypedResponse } from "the_utils";
export async function GET({ params }): Promise<TypedResponse<{ text: string }>> /* the return type here could be auto inserted by the language server */ {
return typed_json({ text: `The post ID is ${params.post}` }) satisfies TypedResponse<{ text: string }>;
}
import { api_fetch } from "./api_stuff";
export async function load() {
const data = await api_fetch("/api/[post]", "GET", { post: "123" });
return {
text: data.text
}
} The prototype works and the IDE is able to pick up on the correct types as seen here: The Thoughts? |
@AlbertMarashi |
Just a quick thought, I think we should keep as much in the type system as possible. If something like: export async function api_fetch(url: string, method: Methods, params?: Record<string, string>): Promise<any> {
// parse the url
// inject the params into the [param] sections
const response = await fetch(url, { method });
return response.json() as never;
} is implemented then you're hiding the fetch API. If you need to add anything to the request, or do anything else with the response then this helper will make that more difficult. Projects I've made in the past which have delved into this sort of thing create types like: type TypedResponse<Status extends number = number, H extends Headers = Headers> = Omit<
Response,
'status' | 'clone' | 'text' | 'formData' | 'json' | 'blob' | 'arrayBuffer' | 'headers'
> & {
status: Status;
headers: H;
clone: () => TypedResponse<Status, H>;
};
type JSONResponse<R extends TypedResponse, Body> = Omit<R, 'clone'> & {
json: () => Promise<Body>;
clone: () => JSONResponse<R, Body>;
}; where essentially we're just modifying the return response type to type the As discussed above, the harder part is generating types in all cases which match exactly one endpoint. In the easy case where you have two endpoints:
you might generate a fetch type like: type Paths = '/api/endpoint1' | '/api/endpoint2'
type api_endpoint1_ResBody = { ...whatever }
type api_endpoint2_ResBody = { ...whatever }
type Fetch<P extends Paths> = (path: P, init?: RequestInit) => P extends '/api/endpoint1' ? JSONResponse<TypedResponse<200>, api_endpoint1_ResBody> : P extends '/api/endpoint2' ? JSONResponse<TypedResponse<200>, api_endpoint1_ResBody> : never; which can be used like const f: Fetch<Paths> = fetch;
const res1 = f("/api/endpoint1")
res1.json(); This can easily be extended for different methods obviously, you're just generating a big-ass type. Route params makes all this more challenging |
@jhwz the problem is when you introduce params the typing for paths becomes complex and typescript does not have regex-based strings. I think it makes more sense to pass in In terms of stuff like the status & headers I think that seems like a good idea/extension. The interface TypedResponse<O> extends Response {
json(): Promise<O>
}
type ApiPostsPostParamParams = {
post: string
}
type Methods = "GET" | "POST" | "UPDATE" | "DELETE"
type FetchOptions<Params = never, Method extends Methods = Methods> =
Parameters<typeof fetch>[1] & {
params: Params,
method: Method
}
export async function api_fetch(url: "/api/posts/[post]", options?: FetchOptions<ApiPostsPostParamParams, "GET">): Promise<TypedResponse<{ foo: string }>>;
export async function api_fetch(url: string, options?: Parameters<typeof fetch>[1] | undefined): Promise<any> {
}
api_fetch("/api/posts/[post]", {
method: "GET",
params: {
post: "post-id"
}
}) This has the benefit of using a very similar |
I see what you're getting at, I agree that keeping the type as the route ID and passing params is simpler. Good point! |
I currently use something like this: import { resolveRoute } from '$app/paths';
import type RouteMetadata from '../../.svelte-kit/types/route_meta_data.json';
type RouteMetadata = typeof RouteMetadata;
type Prettify<T> = { [K in keyof T]: T[K] } & {};
type ParseParam<T extends string> = T extends `...${infer Name}` ? Name : T;
type ParseParams<T extends string> = T extends `${infer A}[[${infer Param}]]${infer B}`
? ParseParams<A> & { [K in ParseParam<Param>]?: string } & ParseParams<B>
: T extends `${infer A}[${infer Param}]${infer B}`
? ParseParams<A> & { [K in ParseParam<Param>]: string } & ParseParams<B>
: {};
type RequiredKeys<T extends object> = keyof {
// eslint-disable-next-line @typescript-eslint/ban-types
[P in keyof T as {} extends Pick<T, P> ? never : P]: 1;
};
export type RouteId = keyof RouteMetadata;
export type Routes = {
[K in RouteId]: Prettify<ParseParams<K>>;
};
export function route<T extends keyof Routes>(
options: {
routeId: T;
query?: string | Record<string, string> | URLSearchParams | string[][];
hash?: string;
} & (RequiredKeys<Routes[T]> extends never ? { params?: Routes[T] } : { params: Routes[T] })
) {
const path = resolveRoute(options.routeId, options.params ?? {});
const search = options.query && new URLSearchParams(options.query).toString();
return path + (search ? `?${search}` : '') + (options.hash ? `#${options.hash}` : '');
} Usage: <script>
import { route } from '$lib/route'
route({
routeId: '/(app)/posts/[postId]/edit',
params: {
postId: 'whatever'
}
})
</script>
<a
href={route({
routeId: '/(app)/posts/[postId]/edit',
params: {
postId: 'whatever'
}
})}
>
Whatever
</a> Works pretty well! Not sure how well it scales as its parsing the params using typescript magic, but so far i did not have any issues. |
@david-plugge this might be relevant for #11406 |
@david-plugge that's awesome, I didn't think to leverage sveltekits internal route knowledge! technical question, have you considered allowing to omit style question, any particular reason for making the mandatory |
@Lootwig Do you mean this usage for the style question?
|
Thats absolutely possible: type RemoveGroups<T> = T extends `${infer A}/(${string})/${infer B}`
? `${RemoveGroups<A>}/${RemoveGroups<B>}`
: T;
export type RouteId = RemoveGroups<keyof RouteMetadata>;
That works too, the main reason i had was that its a bit more complicated. But as in most cases you dont pass in a parameter i like this better. type OptionalOptions<T extends RouteId> = {
query?: string | Record<string, string> | URLSearchParams | string[][];
hash?: string;
params?: Routes[T];
};
type RequiredOptions<T extends RouteId> = {
query?: string | Record<string, string> | URLSearchParams | string[][];
hash?: string;
params: Routes[T];
};
type RouteArgs<T extends RouteId> =
RequiredKeys<Routes[T]> extends never
? [options?: OptionalOptions<T>]
: [options: RequiredOptions<T>];
export function route<T extends RouteId>(routeId: T, ...[options]: RouteArgs<T>) {
const path = resolveRoute(routeId, options?.params ?? {});
const search = options?.query && new URLSearchParams(options.query).toString();
return path + (search ? `?${search}` : '') + (options?.hash ? `#${options.hash}` : '');
} |
Complete file: Sveltekit typesafe routes |
Would it make sense to have a rune |
This is great stuff -- one small issue I noticed is that if you're using Sveltekit param matchers (ie
Sveltekit's
|
Probably a good time to bring back this to life: #11406 |
This should be combined with the functionality described within #11108 as well |
Ways that types in SvelteKit apps could be improved:
Implicit
params
andprops
forload
functions (update: done)Similarly, with shadow endpoints, it would be good to type
body
based on component props (though this could be tricky since component props combine e.g.post
andget
bodies), and also type theprops
input toload
in cases where it's used.It might be possible to do something clever with rootDirs, or with preprocessors?
Typed
goto
andfetch
As mentioned below, it might be possible to type functions like
goto
based on the available routes. It probably gets tricky with relative routes, but that could be a bailout.Typed links
This is a little tricky for us, since we use
<a>
instead of<Link>
, but it would be neat if it was possible to typecheck links somehow.The text was updated successfully, but these errors were encountered: