Open Rich-Harris opened 3 years ago
Relatedly, it would be nice if types for request context
and load context
and session
could be declared in a single place and accounted for in generated types. (Is it confusing that request context
and load context
are both called 'context' but are different things?)
I think it is a bit confusing yeah that load context is a Svelte context and request context is a completely unrelated thing. I don't have a better name for it right now.
load context is a Svelte context
actually it isn't... it's only available to load
, not the components themselves. that's a completely different type of context
perhaps getMetadata(incoming)
and request.metadata
?
Oh whoops, yeah. Uh. Maybe they both should be called something other than 'context'? Kit having two concepts with the same name that are different from the Svelte concept with also the same name is definitely confusing.
Suggestions welcome!
export async function load({ milieu }) {
return {
milieu: {...}
};
}
load context is a Svelte context
actually it isn't... it's only available to
load
, not the components themselves. that's a completely different type of context
Now that I've read this, would it be possible to make the load
context available in the component as a Svelte Context? getContext('AppContext');
or something like that.
That could be used for a "request bound bag". See https://github.com/sveltejs/sapper/issues/917
The problem in Sapper is, there is no way to create a store that is bound to the server request.
That is no problem on the client, but on the server it gets problematic. For example if you want to use a store for user specific data. That will leak to other users, if you create a store, like 99% of all svelte users (export const store = writable({});
in store.js
).
Edit: I just experimented a bit, it is kinda possible now with SvelteKit.
<!-- src/routes/$layout.svelte -->
<script context="module">
import { createUserSpecificStore } from 'somewhere';
export const load = ({ session }) => {
const userSpecificStore = createUserSpecificStore(session);
return {
props: { userSpecificStore }, // make it available to the $layout component
context: { userSpecificStore }, // make it available to all other load function
};
};
</script>
<script>
import { setContext } from 'svelte';
export let userSpecificStore;
setContext('userSpecificStore', userSpecificStore); // make it available to all child components
</script>
Originally posted in #3090, here I describe how goto
, fetch
(and like @ebeloded mentioned invalidate
, prefetch
, prefetchRoutes
) could be improved with some type-information:
In larger projects (also in smaller projects) it would be great if the goto
and the fetch
functions could offer more typesafety. Problems with missing typesafety are:
/costumer
instead of /customer
routes
folder, all links need to be updated toofetch
with the wrong method e.g. using PUT
instead of PATCH
It would be great if the goto
and the fetch
functions could output an error when you pass in a invalid relative slug.
The problem could be solved by providing advanced TypeScript types for the goto
and the fetch
function.
Similar tho the already generated .svelte-kit/dev/generated/manifest.js
file, SvelteKit could generate a d.ts
with types depending on the .svelte
files inside the routes
folder and depending on the function inside a .js
and .ts
Endpoints file.
These types then could be used to enhance the goto
and fetch
functions.
The typesafe functions could replace the existing import from app/navigation
. I'm not sure how this could work for the fetch
function since you don't really import it from anywhere.
Or this could be an additional function you need to import from app/typesafe
or something similar.
Here is a working example how I think this could look like:
type SplitPath =
S extends /${infer Part}/${infer Rest}
? ['/', Part, '/', ...SplitPath${infer Part}/${infer Rest}
? [Part, '/', ...SplitPath/${infer Part}
? ['/', Part]
: S extends ''
? []
: S extends ${infer Part}
? [Part]
: []
type RemoveEmptyEntries<A extends Array
- routes: for the `goto` function
```ts
// 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')
fetch
function
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
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 `.ts` file and try passing some valid/invalid strings to the `goto` and `fetch` functions.
Lines annotated with `// @ts-expect-error` are invalid and will throw a TypeScript Error.
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.
I'm assuming that the "sveltekit dev process" processes the source files and then generates some
.d.ts
files somewhere inside the.svelte-kit
directory
here I'm always using
goto
but the same approach could be applied for all other functions
When looking at the goto
function, the types are coming from @sveltejs/kit
package referenced by the src/global.d.ts
file.
With this approach we can't override types defined inside @sveltejs/kit
with an "enhanced" version of goto
.
A solution to this problem could be to reference a generated file inside global.d.ts`:
/// <reference types="../.svelte-kit/types" />
This file then imports some general SvelteKit type information and extends them with the generated types:
I'm currently not aware of a way to override a type defined inside @sveltejs/kit
so in order to make this work the declaration of the goto
function inside the $app/navigation
module has to be removed from the @sveltejs/kit
types.
The module then get's declared with the .svelte-kit/types
file/directory.
/// <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 global.d.ts
file. The dev process could parse the content of that file and output an error mentioning that the reference needs to be changed.
When starting with a fresh project, the .svelte-kit
folder is not present until the dev process get's started. So maybe the template needs to contain a simple .svelte-kit/types.d.ts
file out-of-the-box.
When looking at the types for the ´load´ function, it would be great if the types could get injected automatically into the svelte routes.
I 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 }
}
It's probably a bad idea to look for an export named load
, so maybe a better idea would be to automatically inject just the Load
type. So you would write:
/** @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 Load
type directly. It will be just there for you to use.
Or maybe import it from a "virtual" package like sveltekit/types
/** @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 }) { }
These are my thoughts. When thinking about the rootDirs
solution from the first comment, the automatic injection of the Load
type in some form could improve the DX a lot. As I'm heavily using the automatic import function that VS Code provides, I can immagine that having multiple Load
types generated for each route would probably never import the type from the correct path.
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 trpc
now, I hacked it together manually in a recent project of mine.
Thanks for sharing the link @ambiguous48
@ivanhofer I actually implemented path validation for the fetch in a branch (I would love some version of this to make it into sveltekit)
It would generate something 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>]
: [];
export type IsValidEndpoint<
R extends string,
Endpoint extends Endpoints
> = R extends `http${string}`
? R
: RemoveEmptyEntries<SplitPath<R>> extends Endpoint
? R
: 'No such Endpoint';
type __base = string;
type bar = string;
type Endpoint =
| ['/', __base, '/', 'endpoint']
| ['/', 'endpoint']
| ['/', __base, '/', bar]
| ['/', bar]
| ['/', __base, '/', bar, '/', 'foo']
| ['/', bar, '/', 'foo'];
declare global {
declare function fetch<Path extends string>(
endpoint: IsValidEndpoint<Path, Endpoint>,
init?: RequestInit
): Promise<Response>;
}
in a file .svelte-kit/types/__app.d.ts
which I could just reference from the app.d.ts
The biggest problems I encountered were:
fetch<Endpoint>("/myendpoint")
but that would mean the Path generic wouldn't be inferred because typescript doesn't support partial inference..json()
return type and the headers object (need headers: { accept: "application/json" }
for shadow endpoints)It almost seems like if you want to go down this route you'd be best to generate an API client that can interact with your API. Maybe doing something like generating an OpenAPI3 schema would be best and then letting people do what they will with that. Dunno, it's a big old rabbit hole.
I'm not the best at TypeScript so it's likely I missed some obvious solutions but that's my take on this issue. Would love to see some progress on this at some point and am happy to contribute where I can.
- Need to account for the base path, this loosened up the typings making them less useful
If it would just be used inside the routes
folder, similar to the Load
type, each file could have it's own scoped Endpoint
type. This solution would also take into account upwards relative links ../about
(I'm not sure if this is a valid relative link). The simplest solution would be to just allow absolute paths.
- Couldn't prefer this fetch implementation, because of typescript declaration merging. I thought it would be nice if you could have a syntax like
fetch<Endpoint>("/myendpoint")
but that would mean the Path generic wouldn't be inferred because typescript doesn't support partial inference.
I have also struggled to make this work with the current type definitions. See my comment about "routing" here: https://github.com/sveltejs/kit/issues/647#issuecomment-1019287010
- Not actually super useful like this. In the perfect world you would have typing for the paths and methods (as @ivanhofer showed) but also on the
.json()
return type and the headers object (needheaders: { accept: "application/json" }
for shadow endpoints)
Currently you have to manually link the types between your endpoints, load functions and routes. It would be great if we could find a solution to automate this and other stuff. Also manually importing the Load
type when you have hundreds of routes or updating the import if you move your files around is a bit annoying. It would be great if this could be automated (also mentioned in my comment above in the "load" section)
Dropping this here for later (when we are sure how the load/endpoint interaction looks in the long run):
To get better interop with the endpoint and the load function, we can do this:
// @ts-ignore - necessary because we don't know if the user actually added a GET endpoint
import type {GET} from '../index.js'; // index.js relates to whatever this is generated from
export type InputProps = (typeof import('../index.js')) extends { GET: any } ? Awaited<ReturnType<typeof GET>>['body'] : never; // The main part
export type Load<
OutputProps extends Record<string, any> = InputProps // should this be Record<string, any> by default instead? Having it InputProps likely breaks more users, and whoever types both likely wants to transform the InputProps
> = GenericLoad<{}, InputProps, OutputProps>;
I made some thoughts about this topic again, because I had a bug in one of my SvelteKit
applications.
The issue was that I have used $page.params.id
in one of my +page.svelte
files. Then I renamed the route from [id]
to [productId]
and guess what: I forgot to change the id
to productId
on the $page.params
object.
This is not a problem inside load
functions because they get typed by the generated ./$types.d.ts
file.
But in regular .svelte
files you need to use the generic $page
store or manually wrap it like this:
import type { RouteParams } from './$types.js'
($page.params as RouteParams).productId
Also accessing form
, data
, route.id
on the $page
object results in having a genericly typed object.
For form
and data
this is not really an issue since those objects get passed as page props, so you can specify the generated type there.
Possible solutions:
add params
to the page props
<script>
import type { PageData, RouteParams } from './$types.js'
export let params: RouteParams
export let data: PageData
</script>
This would make it consistent to the other props that already get passed. This would probably mean a tiny runtime overhead.
generate a file that wraps the $page
object with the generated files
<script>
- import { page } from '$app/stores'
+ import page from './$page'
$page.params.projectId
</script>
This would be a solution almost entirely on the typesystem-level, with no runtime overhead (just a reference to the original $app/stores
object). This would also open up the possibility to export a goto
function with enhanced typing like described above in a similar way.
doing more magic in the language tools
Let users write the regular $page/stores
import. The language tools then check if it gets imported in a +page.svelte
file and then replaces the types on-the-fly. I have no experience with such things but I would assume it is possible to create a TypeScript
plugin that is able to replace the type per file basis.
Additional to the $app/stores
import, It would be great to have such a solution also for everything inside ./$types
. Every now and then I accidentially import the generic RequestHandler
from @sveltejs/kit
instead of the better typed version from the generated ./$types.d.ts
file. Having just a single possible RequestHandler
to import and then letting the language tools do their magic, could be an option. And also a solution to such a problem:
As soon as you start having a few routes the "Quick Fix..." dialog in VS Code
shows an endless list of possible types to import. For some reason ../$types
alwas shows up above the ./$types
that you probably want to import.
This image shows the better working variant of this panel. Sometimes another version shows up, where the list is randomly sorted and you have to hover over the name in order to see the path. It is almost impossible to import a type from the correct path. I don't know why and when this variant shows up, but I guess it is a bug. I can't reproduce right now, but it shows up every other day. I'll post it here once it shows up the next time.
This all is just complaining at a high level from my side. I'm really happy how well the TypeScript
support has improved in the last months. The points above are just minor issues I encounter using SvelteKit
day by day. I just want to share it here with you.
[update]:
here comes the ugly version of the "Quick Fix..." dialog mentioned above. This is more like a "code completion" dialog. I just found out that this gets triggered by pressing CTRL + SPACE
and the real "Quick Fix..." dialog pops up by pressing CTRL + .
(on Windows). Triggering the "code completion" dialog does not always work. But you should see that it is almost impossible to import the correct type
from there. This list is not sorted (or at least it seems so) and you need to select it via keyboard to see the actual path. In my opinion this is completely useless.
It would be great if the language-tools
could find a way to modify this list e.g. sorting entries, adding more information to it, just showing a single entry or removing all entries alltogether.
We could introduce typed helper functions for html elements & relative routes.
Braindump:
<script>
import { static, route, endpoint } from './$routes.js'; // bikeshedable
</script>
<img src={static("/favicon.jpg")}/>
<a href={route("/about")}>About</a>
<form action={endpoint("/api/v1/rsvp")>
<button name="rsvp" value="yes">RSVP</button>
</form>
In dev mode, static
and friends would use the TypeScript patterns suggested above, and possibly do some validation TypeScript can't (e.g relative routes). In build mode, they'd resolve to typed identity functions. We could preprocess them away entirely, though there may be some caveats (e.g function complicatedFunction(a) { return route(a) }
or poor performance).
On the untyped $page.data
and params @ivanhofer mentions above, as well as goto
we might follow a similar pattern. Generated files import from $app/**
and enhance with types.
<!-- /product/[productId]/+page.svelte -->
<script>
import { page } from './$stores.js';
import { goto } from './$navigation.js';
$: ({ params } = page); // `params: { productId: string; }`
goto('/prodoct/123') // error
</script>
I use ./$routes.js
because it matches the ./$types.js
but it might be considered a breaking change. I highly doubt people use ./$routes.js
in their app though. Alternatively, we could use ./+routes.js
since +
files are reserved, but we've taught SvelteKit users that ./$
stuff is generated and ./+
is stuff you write. If we went this way, it may also be a good idea to reserve ./$
.
@kwangure I really like the idea of those helper functions.
I was just about to write another comment on this thread ^^.
What I want to mention is: SvelteKit
could generate a metadata object/array of all routes, asset-paths etc. somewhere in the .sveltekit
folder. From there on anyone can build their own helper functions e.g. create a goto
function that only accepts absolute paths like mentioned a few comments above.
This would also open the possibilities for other use cases not thought of yet.
This should be a JavaScript
object (as const
to be able to infer types from it via typeof
) and not a type
because then you can do some other runtime stuff e.g. check how many parent layout.server.ts
do exist and have a load
function defined. I currently would need this in an application and without that I need to manually count and define a number on each function. It would make my life easier if I could just iterate over a metadata object to get to that number.
I haven't fully thought about this yet, but maybe I can write a utility function that uses vite's
import.meta.glob
to calculate that number.
SvelteKit
probably already has those information and it would just need to expose them for other developers to use and build stuff on top.
+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 +server.ts
and +page.ts
system.
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 api_fetch
function
// 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();
}
/api/[post]/+server.ts
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 }>;
}
+page.ts
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 api_fetch
signature could be adjusted, and even made to handle App.Error
as well as potential typing for POST
requests that provide JSON
input, so we could potentially get a typed request.json()
in the same way we get response.json()
typed in this method
Thoughts?
@AlbertMarashi I love this idea.
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 json()
function to not return Promise<unknown>
but Promise<T>
.
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 params: { ... }
and then parse those from the url template which looks like /api/posts/[post]
or etc
In terms of stuff like the status & headers I think that seems like a good idea/extension. The api_fetch
signature as I mentioned was only to demonstrate an example, but I think it could definitely use a similar API/signature as the fetch function, with possibly an added property for the params
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 fetch
syntax, and we could also extend this to type the body
property in FetchOptions
via some kind of generic on export async function POST<{ foo: string}>/** inputs **/>(event) { ... }
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.
Previously i created a vite plugin to generate the types, but then i noticed route_meta_data.json
in the generated folder.
@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 (group)
segments for convenience?
style question, any particular reason for making the mandatory routeId
a named prop, rather than just a first positional argument of route()
(and options
actually optional, then)?
@Lootwig Do you mean this usage for the style question?
route('/(app)/posts/[postId]/edit',
{
routeId: ,
params: {
postId: 'whatever'
}
}
)
@Lootwig Do you mean this usage for the style question?
route('/(app)/posts/[postId]/edit', { routeId: , params: { postId: 'whatever' } } )
In that scenario, with my approach in #11108 it makes sense to remove the (group)
since it's not technically a valid URL
@Lootwig
technical question, have you considered allowing to omit
(group)
segments for convenience?
Thats absolutely possible:
type RemoveGroups<T> = T extends `${infer A}/(${string})/${infer B}`
? `${RemoveGroups<A>}/${RemoveGroups<B>}`
: T;
export type RouteId = RemoveGroups<keyof RouteMetadata>;
style question, any particular reason for making the mandatory
routeId
a named prop, rather than just a first positional argument ofroute()
(andoptions
actually optional, then)?
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 $route
for something like this?
This is great stuff -- one small issue I noticed is that if you're using Sveltekit param matchers (ie routes/fruits/[page=fruit]
) the typing here would expect you to do
route('/fruits/[page=fruit]',
{
params: {
'page=fruit': 'whatever'
}
}
)
Sveltekit's resolveRoute
expects you to pass the param as page
rather than page=fruit
, so it won't generate the route param correctly. To fix this you can change line 9 in this file to be
type ParseParam<T extends string> = T extends `...${infer Name}=${string}`
? `...${Name}`
: T extends `...${infer Name}`
? `...${Name}`
: T extends `${infer Name}=${string}`
? Name
: T;
Probably a good time to bring back this to life: https://github.com/sveltejs/kit/pull/11406 To then kill vite-plugin-kit-routes
Probably a good time to bring back this to life: #11406 To then kill vite-plugin-kit-routes
This should be combined with the functionality described within https://github.com/sveltejs/kit/pull/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.