|
| 1 | +## Zero-effort Typesafety |
| 2 | + |
| 3 | +Date: 2024-09-20 |
| 4 | + |
| 5 | +Status: accepted |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +[#0012](./0012-type-inference.md) lays out the foundation for typesafety in React Router. |
| 10 | +As a result you can import route-specific types from `+types.<route>.ts` and annotate types for route exports: |
| 11 | + |
| 12 | +```tsx |
| 13 | +// app/routes/product.tsx |
| 14 | +// URL path: /products/:id |
| 15 | + |
| 16 | +import * as T from "./+types.product-details.ts"; |
| 17 | + |
| 18 | +export function loader({ params }: T.loader["args"]) { |
| 19 | + // ^? { id: string } |
| 20 | + const user = getUser(params.id); |
| 21 | + return { planet: "world", user }; |
| 22 | +} |
| 23 | + |
| 24 | +export default function Component({ |
| 25 | + loaderData, |
| 26 | +}: // ^? { planet: string } |
| 27 | +T.Default["args"]): T.Default["return"] { |
| 28 | + return <h1>Hello, {loaderData.planet}!</h1>; |
| 29 | +} |
| 30 | +``` |
| 31 | + |
| 32 | +### The type inference experience |
| 33 | + |
| 34 | +These generated types are straightforward to wire up correctly, but are still boilerplate that would be present in every single route module. |
| 35 | +In fact React Router knows what the types for each route export should be, so why do you have to manually annotate these types? |
| 36 | + |
| 37 | +For other APIs you don't own — like third party libraries — you expect types to be provided for you. |
| 38 | +That way, you can stick to annotating your own APIs when necessary but let type inference do the heavy lifting everywhere else. |
| 39 | +So it feels unnatural to do so for React Router's route export API. |
| 40 | +Ideally, you should be able to author routes without type annotations for route exports and TypeScript should be able to infer what they are: |
| 41 | + |
| 42 | +```tsx |
| 43 | +// app/routes/product.tsx |
| 44 | +// URL path: /products/:id |
| 45 | + |
| 46 | +export function loader({ params }) { |
| 47 | + // ^? { id: string } |
| 48 | + const user = getUser(params.id); |
| 49 | + return { planet: "world", user }; |
| 50 | +} |
| 51 | + |
| 52 | +export default function Component({ loaderData }): { |
| 53 | + // ^? { planet: string } |
| 54 | + return <h1>Hello, {loaderData.planet}!</h1>; |
| 55 | +} |
| 56 | +``` |
| 57 | + |
| 58 | +### TypeScript limitations |
| 59 | + |
| 60 | +1. **TypeScript cannot type modules** |
| 61 | + |
| 62 | + React Router knows which files are route modules and the types for exports from those modules. |
| 63 | + It'd be great if React Router could somehow transfer this knowledge to TypeScript, maybe something like: |
| 64 | + |
| 65 | + ```tsx |
| 66 | + import * as T from "./+types.product-details.ts"; |
| 67 | + |
| 68 | + // I wish something like this worked, but there's no way to do this |
| 69 | + declare module "app/routes/product-details.tsx" satisfies { |
| 70 | + loader: (args: T.loader["args"]) => unknown |
| 71 | + default: (args: T.Default["args"]) => T.Default["return"] |
| 72 | + } |
| 73 | + ``` |
| 74 | + |
| 75 | + Importantly, we need this imaginary API to use `satisfies` so that we can still infer the actual return types for things like `loader`. |
| 76 | + |
| 77 | + Unfortunately, TypeScript does not have any mechanism for typing modules. |
| 78 | + |
| 79 | +2. **TypeScript plugin do not affect typechecking** |
| 80 | + |
| 81 | + TypeScript does have [language service plugins](https://github.com/microsoft/TypeScript/wiki/Writing-a-Language-Service-Plugin), |
| 82 | + but these plugins can only augment the experience in your editor. |
| 83 | + In other words, these plugins do not get used when `tsc` to typecheck your code. |
| 84 | + |
| 85 | + Relying solely on your editor to report type errors is insufficient as type errors can easily slip through. |
| 86 | + For example, you could edit one file and cause a type error in another file you never opened. |
| 87 | + To ensure this never happens, typechecking needs to be a standalone command runnable by CI. |
| 88 | + |
| 89 | +## Goals |
| 90 | + |
| 91 | +- Automate type annotations for route exports |
| 92 | +- Minimize noise in route modules from automatic annotations |
| 93 | +- Support in-editor diagnostics _and_ standalone typechecking in CI |
| 94 | + |
| 95 | +## Decisions |
| 96 | + |
| 97 | +### Autotype route exports |
| 98 | + |
| 99 | +Conceptually, you should be able to write route modules without annotating types for route exports. |
| 100 | +To accomplish this, React Router will implement a custom TypeScript language service that is a simple pass-through for non-route modules. |
| 101 | +For routes, the language service will inject annotations for route exports at the correct locations _before_ the typechecker sees the file contents. |
| 102 | + |
| 103 | +For example, you write this: |
| 104 | + |
| 105 | +```tsx |
| 106 | +// app/routes/product.tsx |
| 107 | +// URL path: /products/:id |
| 108 | + |
| 109 | +export function loader({ params }) { |
| 110 | + const user = getUser(params.id); |
| 111 | + return { planet: "world", user }; |
| 112 | +} |
| 113 | + |
| 114 | +export default function Component({ loaderData }): { |
| 115 | + return <h1>Hello, {loaderData.planet}!</h1>; |
| 116 | +} |
| 117 | +``` |
| 118 | + |
| 119 | +And the language service will make sure the typechecker sees this: |
| 120 | + |
| 121 | +```tsx |
| 122 | +// app/routes/product.tsx |
| 123 | +// URL path: /products/:id |
| 124 | + |
| 125 | +export function loader({ params }: import("./+types.product").loader["args"]) { |
| 126 | + const user = getUser(params.id); |
| 127 | + return { planet: "world", user }; |
| 128 | +} |
| 129 | + |
| 130 | +export default function Component({ |
| 131 | + loaderData, |
| 132 | +}: import("./+types.product").Default["args"]): import("./+types.product").Default["return"] { |
| 133 | + return <h1>Hello, {loaderData.planet}!</h1>; |
| 134 | +} |
| 135 | +``` |
| 136 | + |
| 137 | +The language service will be implemented to make minimal changes to the source file when auto-annotating route modules. |
| 138 | +It will also translate any references to line/column numbers in the auto-typed code back to the corresponding line/column numbers in the original code |
| 139 | +so that all typechecking and LSP features report warnings and errors relative to the source file. |
| 140 | + |
| 141 | +Unlike a TS plugin, a language service is easily testable in isolation via the methods in the LSP spec. |
| 142 | + |
| 143 | +### `typecheck` command |
| 144 | + |
| 145 | +If you run `tsc` to typecheck your code, `tsc` won't know about our custom language service and will complain that route export args are typed as `any` due to missing type annotations. |
| 146 | +Instead of invoking `tsc` directly as a CLI, React Router will provide a `typecheck` command that invokes the TypeScript typechecker progammatically with our custom language service. |
| 147 | + |
| 148 | +It's worth noting that `tsc` is the TypeScript _compiler_, not just the typechecker. |
| 149 | +Remix and React Router already use Vite to compile and bundle your code. |
| 150 | +So delegating typechecking to TypeScript programmatically is less of a departure from `tsc` than using Vite is. |
| 151 | + |
| 152 | +One limitation of the `typecheck` command is that it assumes that typechecking will be run for the app _independently_ of any other packages or apps in a monorepo. |
| 153 | +Consequently, this approach does not support monorepos with a shared top-level `tsconfig.json` at the root of the monorepo that uses project references to typecheck across packages in the monorepo. |
| 154 | + |
| 155 | +```txt |
| 156 | +root/ |
| 157 | + tsconfig.json 👈 if this uses `references`, that's a problem |
| 158 | + packages/ |
| 159 | + mylib1/ |
| 160 | + mylib2/ |
| 161 | + apps/ |
| 162 | + myapp1/ |
| 163 | + myapp2/ |
| 164 | +``` |
| 165 | + |
| 166 | +Instead, each package of a monorepo should have its own `tsconfig.json` and you should avoid a top-level `tsconfig.json` in monorepos. |
| 167 | +If you were relying on project references to get typechecking without needing to run builds for local dependencies, you can instead use [live types via custom `exports` conditions](https://colinhacks.com/essays/live-types-typescript-monorepo). |
| 168 | + |
| 169 | +Note that this only applies to top-level `tsconfig`s in monorepo roots. |
| 170 | +You can freely use project references _within_ the app root to split up the config for different environments like in the [Vite + React + TS template](https://vite.new/react-ts) on Stackblitz. |
| 171 | + |
| 172 | +### Fallback to manual type annotations |
| 173 | + |
| 174 | +While we think this makes the default DX much better for most projects, we need a graceful fallback to provide typesafety for edge cases. |
| 175 | + |
| 176 | +This feature builds on top of the route-specific typegen described in [#0012](./0012-type-inference.md), so |
| 177 | +those typegen files will still be there if you prefer to manually annotate your route exports. |
| 178 | + |
| 179 | +One reason you might want to do this is if your project uses tools that independently inspect types in your app. |
| 180 | +For example, [typescript-eslint](https://typescript-eslint.io/) will think that route export args are typed as `any`. |
| 181 | + |
| 182 | +## Thank you |
| 183 | + |
| 184 | +Credit to Svelte Kit for their [_Zero-effort type safety_ approach](https://svelte.dev/blog/zero-config-type-safety) that heavily influenced the design and implementation of this feature. |
| 185 | +Special thanks to [Simon H](https://twitter.com/dummdidumm_) from the Svelte team for answering our questions about it. |
0 commit comments