# Typed routes ## Principles - Should path-params and search-params be merged? - PROS merged: simplified mental model - It is not possible to augment the JSX, this will have to rely on the `route` function. Question is where does the route function come from? - single global route function generated by the Qwik-City: - Easy way to remember where to do the import from. - User must repeat the `path` string, which TS validates / completes - route function per route written by the developer: - CONS: this may create issues with bundlers - CONS: not immediately obvious where it is pointing as the function name may not be consistent. - Should system provide validation of args? Probably a strong yes, as URL is exposed and people could hack it. - [name=Ken: Agreed. Types and de/serialization are still a massive help without validation - worth rolling out without validation built in. But it would then be another big win offering validation on top] - Should system provide a way to do custom serialization / deserialization? => Probably yes, but should it be on per route or global level? - Global: simpler to maintain and more consistant with global `route` function. - [name=Ken: Similar to my comment about validation. Big win, not critical. This one is more of a "power user" feature. Global for sure. Maybe global + per-route overrides?] ### Misko's general thoughts - Each route should declare `RouteParams` data type. - A well known exported type. - I am concerned that putting the helper function into the route will create bundling issues. I don't think bundlers will be able to disentangle it, and I am concerned that referring to the route will bring the route file in. - I am leaning towards that the route exports type only - Referring to the route is done through string that way no bundling surprises. - [name=Ken: I'm very much on the same page with all of this. Single route() function that you import is both simple and easy] - We should have a top level `url-encoding.ts` file which developer can optionally create that can override the default encoding/decoding of the `RouteParams` - [name=Ken: Beautiful. Top level config makes perfect sense here] - ## Constraints - REQ-RTTI: must have runtime types so that it can verify that the inputs are correct. (URLs can be edited and hence we can't rely on design time validation only.) - [name=Ken: I view the need for validation not as a constraint but as an additional feature to provide. Validation is more than just about type (string is at least X chars, number is greater than some number, etc). Also forgive me if this is what you meant; I'm not sure what RTTI stands for] - REQ-INHERIT: Given `/[foo]/` and `/[foo]/[bar]` system must ensure that `foo` is of same type and serialization in both routes. - [name=Ken: This provides another case for path params just being typed as strings] ## Prototypes - Misko: [defineRoute](https://stackblitz.com/edit/qwik-starter-7my1vk) - CONCERN: because `defineRoute` is in the route file worried that the bundler will not know how to break up the files correctly. - CON: same import `~/code-gen` will have a different output depending where it is imported from. - CON: `RouteParams` must be exported so that child routes can correctly maintain type - OPEN ISSUE: How will the child routes work with middleware so that the same information can be on many levels. - Dustin: [link](https://stackblitz.com/edit/qwik-starter-m5qpt2) ## Exporting Route `Params` ```typescript! export interface RouteParams { contactId: string; predicate: { name: string age: number }; } ``` ### Comments Wout: - I don't understand `predicate` here. - I suppose Qwik-City will be auto-generating the routes interface? E.g. at `/routes/routes.d.ts`? - How would Qwik-City know what the type of param should be? That can then also be checked at runtime. > [name=Miško Hevery] In the design which I presented in the meeting it would not know. Since then I have come to the conclusion that we must have types at runtime as well. - when desired, you can add to the interface via a separate .d.ts file, for example when making a complex `[...rest]` route - Check out [io-ts](https://github.com/gcanti/io-ts/blob/master/index.md), it enables runtime type checking + encoding (:thinking_face: might be good for making tsurl2) - How to type `[...rest]` route? > [name=Miško Hevery] it should just be normal type as in `interface RouteParams { rest: string }` - How about also typing the query? > [name=Miško Hevery] any param which is not in the path would end up in the Query. So the same `RouteParams` can be used to type both. ## Creating URLs in TypeSafe manner Options 1: `toUrl` function: ```typescript! export const Link: { (params: __ALL_ROUTES): ReturnType<typeof UntypedLink>; /** * No `RouteParams` provided */ toUrl( route: "/contacts/", params?: {} // NOT DECLARED ASSUMED NO PARAMS ): string; /** * {@link 〳contacts〳ᐸcontactIDᑀ〳ːRouteParams} */ toUrl( route: "/contacts/[contactID]/", params: 〳contacts〳ᐸcontactIDᑀ〳ːRouteParams ): string; /** * {@link 〳contacts〳ᐸcontactIDᑀ〳delete〳ːRouteParams} */ toUrl( route: "/contacts/[contactID]/delete/", params: 〳contacts〳ᐸcontactIDᑀ〳delete〳ːRouteParams ): string; } = UntypedLink as any; Link.toUrl = encodeParamsToUrl; ``` Option 2: Factory function ```typescript! import {ROUTE_PATH} export const contactRoute = defineRoute<RouteParams>(ROUTE_PATH); ``` > Ken: I like the toUrl approach (could be in Link but also in useNavigate for further extensibility). The typegen I think would end up pretty simple. A file that ends up looking something like: > [name=Miško Hevery] I like that approach too. It seams less magic than [defineRoute](https://stackblitz.com/edit/qwik-starter-7my1vk) but does not have a good solution for runtime type information and validation. ```typescript! //This is an automatically generated file. There is no need to update it manually. Manual updates will be overridden import type { RouteParams as params0 } from "./routes/profile/[contact]" import type { RouteParams as params1 } from "./routes/product/[productId]" export type ParamsByRoute = { "/": null; "/flower": null; "/profile/[contact]": params0; "/product/[productId]": params1; "/article/[articleId]": string; } ``` Where the `null` are those without route params at all, the `string` are where there are params given the structure of the directory, but no RouteParams interface was exported, and the rest are the imported RouteParams; Then the toUrl function can be typed like ```typescript! function toUrl<Route extends keyof ParamsByRoute>( ...args: ParamsByRoute[Route] extends null ? [route: Route] : [route: Route, params: ParamsByRoute[Route]] ) { ``` ### Type system in action: ![](https://i.imgur.com/tFztWBu.gif) The above is with the following RouteParams exported under "/profile/[contact]": ```typescript! export interface RouteParams { id: number; name: string; tags: string[]; } ``` and the following was the RouteParams for "/product/[productId]": ```typescript! export interface RouteParams { id: string; } ``` ## Types other than strings When using encoding like jsurl2, you can encode the type in the param. Params `"hello"`, `123`, `true`, `"123true"` would be encoded as `/route/hello/123/_T/*123true`. So then the RouteParams interface can include the type of the param, but of course it needs runtime verification. > [name=Misko] I like this approach, but we need to solve few more things: > - REQ-RTTI: we need to ensure that we have the type information at runtime so that we can correctly verify types of URLs > - REQ-INHERIT: We need to enuser that child routes don't change the types. --- # HIGH-LEVEL DESIGN DOCUMENT --- NOTE: This is a high-level design, many parts are left unanswered. ## Goals: - Routes should be type-safe. A change in route should throw a TS error. - IMPLICATION: Some function needs to exist to verify that the route is correct. (Not possible to augment the `<a href>` type.) The resulting syntax will be `<a href={someFn(...)}/>`. - Route params should by type-safe as well. - IMPLICATION: The developer needs to declare the types in tho route file. When crating a route, the TS should verify that the types match. `<a href={someFn(..., {paramA: "valueA", ...})}/>` - Route serialization/deserialization should be configurable. - IMPLICATION: A reasonable default should be provided but the developer should be able to override the serializer/deserializer. - Route params should be verifiable, because the URL is easily hackable by the user and we can't rely on design-type types alone. ## File Layout ``` src +- components +- routes +- config +- routes-types.d.ts // AUTO-GENERATED +- route.ts // PRE-GENERATED by npm create +- route-link.ts // but developer can change the +- url-decode.ts // implementation ``` ### `routes-types.d.ts` This file will be generated by the QwikCity build system. Notice that the file is `.d.ts` which means it can contain type information only. This is intentional as for now I don't think we should go to the business of code-generation. A pseudo implementation of the file: ```typescript! import type { RouteParams as 〳contacts〳ᐸcontactIDᑀ〳ːRouteParams } from "./contacts/[contactID]"; import type { RouteParams as 〳contacts〳ᐸcontactIDᑀ〳delete〳ːRouteParams } from "./contacts/[contactID]/delete"; export type 〳contacts〳 = Route< "/contacts/", never, {} // NOT DECLARED ASSUME NO PARAMS >; export type 〳contacts〳ᐸcontactIDᑀ〳 = Route< "/contacts/[contactID]/", "contactID", 〳contacts〳ᐸcontactIDᑀ〳ːRouteParams, SlugParams<〳contacts〳> >; export type 〳contacts〳ᐸcontactIDᑀ〳delete〳 = Route< "/contacts/[contactID]/delete/", "contactID", // DO YOU SEE TYPESCRIPT ERROR ON ABOVE LINE? // If so, then the issue is at: FILE:line:col 〳contacts〳ᐸcontactIDᑀ〳delete〳ːRouteParams, // DO YOU SEE TYPESCRIPT ERROR ON ABOVE LINE? // If so, then the issue is at: FILE:line:col SlugParams<〳contacts〳ᐸcontactIDᑀ〳> >; export Routes = | LinkFromRoute<〳contacts〳> | LinkFromRoute<〳contacts〳ᐸcontactIDᑀ〳> | LinkFromRoute<〳contacts〳ᐸcontactIDᑀ〳delete〳>; ``` ### `routes.ts` This file will be created by the Qwik starter and will contain configuration for the URL serializer/deserializer etc.. A default file will be created but it is up to developer to customize it. The file will have well known exports which the rest of the system can use. A pseudo implementation of the file: ```typescript! import { Link, URLDeserializer, decodeParamsFromUrl, encodeParamsToUrl, } from '@builder.io/qwik-city'; import {Routes} from './routes-types'; // Well known export which is used by the QwikCity to automatically deserialize requests export const urlDeserializer: URLDeserializer = (request) => { return decodeParamsFromUrl(request.path, request.search); }; // Well known export which is used developer to build Routes. export const RouteLink: (params: Routes): ReturnType<typeof DefaultLink> = Link; export const route: (path: Path<Routes>, params: Params<Routes>): string = encodeParamsToUrl; ``` ### `/contacts/[contactID]/` An example route: ```typescript! // Well known export to be used by `routes-types.d.ts` export interface RouteParams { contactID: string; predicate?: { age?: number } } export const onGet: RequestHandler<RouteParams, EndpointResponse> = ({ params, }) => { // 1. params are parsed by the `urlDeserializer` function in `routes.ts` // 2. params here are of `RouteParams` type console.log(params); }; ``` ### Usage ```typescript! import {RouteLink, route} from './routes'; return ( <> <a href="http://you-can-always-do-the-unsafe-thing">link</a> <a href={route('/contacts/[contactID]', { '[contactID]': 'abc', predicate: {age: 30} })}>link</a> <RouteLink href={route('/contacts/[contactID]', { '[contactID]': 'abc', predicate: {age: 30} })}>link</RouteLink> <RouteLink href="/contacts/[contactID]" contactID='abc' predicate={{age:30}}> text </RouteLink> <RouteLink href="/contacts/[contactID]" contactID='abc' predicate={{age:30}}> text (kind of broken) </RouteLink> <RouteLink link:href="/contacts/[contactID]" contactID='abc' predicate={{age:30}}> text </RouteLink> <RouteLink href="/contacts/[contactID]" params={{ '[contactID]': 'abc', predicate: {age: 30} }}> text </RouteLink> </> ); ``` ## Verification of Params ```typescript! export interface RouteParams { contactID: string; predicate?: { age?: number } } // Well known export export onGetRouteParamsAssertion: TypeAssertion<RouteParams>: { contactID: { required: true, type: String, assert: (v) => { if (v.length < 3) throw new AssertError('key to short'); } }, predicate: { required: false, properties: { age: { defaultValue: () => 21; type: Number; assert: (v) => { if (v < 21) throw new Error('Underaged') } } } } } export const onGetCatch: CatchHandler<...> = ...; export const onGet: RequestHandler<RouteParams, EndpointResponse> = ({ params, }) => { // If `routeParamsAssertion` is present then it will be called automatically // Equivalent to the call below: // assertObjectShape(params, routeParamsAssertion); console.log(params); }; export default component$<RouteParams>((params) => { // assertObjectShape(params, routeParamsAssertion); }); ```