Skip to content

Commit 2d5e406

Browse files
committed
decision doc
1 parent 79ffc2f commit 2d5e406

File tree

1 file changed

+185
-0
lines changed

1 file changed

+185
-0
lines changed
+185
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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

Comments
 (0)