sveltejs / kit

web development, streamlined
https://svelte.dev/docs/kit
MIT License
18.74k stars 1.95k forks source link

Generated types for pages/endpoints #647

Open Rich-Harris opened 3 years ago

Rich-Harris commented 3 years ago

Ways that types in SvelteKit apps could be improved:

Implicit params and props for load functions (update: done)

<script context="module">
  /** @type {import('@sveltejs/kit').Load */
  export async function load({ params, fetch }) {
    // `params` automatically typed from filename (e.g. `src/routes/blog/[slug]`)
    const res = await fetch(`/blog/${params.slug}.json`);
    const { post } = await res.json();

    return {
      props: {
        // `post` type automatically inferred from component props
        post
      }
    };
  }
</script>

<script>
  /** @type {BlogPost} */
  export let post;
</script>

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 and get bodies), and also type the props input to load in cases where it's used.

It might be possible to do something clever with rootDirs, or with preprocessors?

Typed goto and fetch

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.

Rich-Harris commented 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?)

Conduitry commented 3 years ago

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.

Rich-Harris commented 3 years ago

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

Rich-Harris commented 3 years ago

perhaps getMetadata(incoming) and request.metadata?

Conduitry commented 3 years ago

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.

Rich-Harris commented 3 years ago

Suggestions welcome!

Rich-Harris commented 3 years ago
export async function load({ milieu }) {
  return {
    milieu: {...}
  };
}

image

PatrickG commented 3 years ago

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>
ivanhofer commented 2 years ago

Originally posted in #3090, here I describe how goto, fetch (and like @ebeloded mentioned invalidate, prefetch, prefetchRoutes) could be improved with some type-information:

Describe the problem

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:

It would be great if the goto and the fetch functions could output an error when you pass in a invalid relative slug.

Describe the proposed solution

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] : S extends ${infer Part}/${infer Rest} ? [Part, '/', ...SplitPath] : S extends /${infer Part} ? ['/', Part] : S extends '' ? [] : S extends ${infer Part} ? [Part] : []

type RemoveEmptyEntries<A extends Array> = A extends [] ? [] : A extends [infer Item, ...infer Rest] ? Item extends '' ? RemoveEmptyEntries : [Item, ...RemoveEmptyEntries] : []

- 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')
  • endpoints: for the 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> 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 `.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.
ivanhofer commented 2 years ago

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

routing

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.

load

When looking at the types for the ´load´ function, it would be great if the types could get injected automatically into the svelte routes.

enhance function

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 }
}

inject type

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.

virtual module

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 }) { }

conclusion

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?

thenbe commented 2 years ago

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.

https://github.com/icflorescu/trpc-sveltekit

ivanhofer commented 2 years ago

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

jhwz commented 2 years ago

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

  • Need to account for the base path, this loosened up the typings making them less useful
  • 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.
  • 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 (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.

ivanhofer commented 2 years ago
  • 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 (need headers: { 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)

dummdidumm commented 2 years ago

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>;
ivanhofer commented 1 year ago

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:

  1. 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.

  2. 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.

  3. 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: image 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.

image

kwangure commented 1 year ago

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 ./$.

ivanhofer commented 1 year ago

@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.

iandoesallthethings commented 1 year ago

+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.

AlbertMarashi commented 12 months ago

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

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?

Lenard-0 commented 12 months ago

@AlbertMarashi I love this idea.

jhwz commented 12 months ago

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:

  • GET /api/endpoint1
  • GET /api/endpoint2

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

AlbertMarashi commented 12 months ago

@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"
    }
})

TS Playground

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) { ... }

jhwz commented 12 months ago

I see what you're getting at, I agree that keeping the type as the route ID and passing params is simpler. Good point!

david-plugge commented 5 months ago

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.

PatrickG commented 5 months ago

@david-plugge this might be relevant for #11406

Lootwig commented 5 months ago

@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)?

jhwheeler commented 5 months ago

@Lootwig Do you mean this usage for the style question?

route('/(app)/posts/[postId]/edit',
  {
    routeId: ,
    params: {
      postId: 'whatever'
    }
  }
)
AlbertMarashi commented 5 months ago

@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

david-plugge commented 5 months ago

@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 of route() (and options 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}` : '');
}
david-plugge commented 5 months ago

Complete file: Sveltekit typesafe routes

FilipLjubic commented 4 months ago

Would it make sense to have a rune $route for something like this?

ajhaupt7 commented 3 months ago

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;
jycouet commented 1 month ago

Probably a good time to bring back this to life: https://github.com/sveltejs/kit/pull/11406 To then kill vite-plugin-kit-routes

AlbertMarashi commented 1 month ago

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