Skip to content

Typesafety improvements #12019

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 32 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
7ce143e
typescript language service plugin setup
pcattori Sep 17, 2024
ff7631d
simplify rollup config with `output.exports: "auto"`
pcattori Sep 17, 2024
fbcc3ad
vite node context refactor
pcattori Sep 17, 2024
c5aa105
typegen watch
pcattori Sep 18, 2024
eed704c
params typegen
pcattori Sep 19, 2024
7d1a623
update playground for typegen
pcattori Sep 19, 2024
ab5e307
refactor
pcattori Sep 19, 2024
6440d23
typegen exports
pcattori Sep 19, 2024
6245f09
type tests
pcattori Sep 19, 2024
624093f
decision doc: type inference
pcattori Sep 21, 2024
76ef8a1
pr feedback
pcattori Sep 24, 2024
60adb8c
arg-centric typegen
pcattori Sep 25, 2024
7de6935
use types from typegen in compiler template
pcattori Sep 25, 2024
a447ee0
typegen command
pcattori Sep 25, 2024
55fd1bf
update playgrounds to fix typechecking
pcattori Sep 25, 2024
3bba333
update cli snapshots
pcattori Sep 25, 2024
72a20c3
Fix vite-node resolution within TS plugin
markdalgleish Sep 26, 2024
722db5c
Merge pull request #12039 from remix-run/markdalgleish/fix-ts-plugin-…
pcattori Sep 26, 2024
50ba6c5
rename type utils to avoid autoimporting them within route modules
pcattori Sep 26, 2024
bdfefbe
typegen file comments
pcattori Sep 26, 2024
12b2606
only typegen if route file exists
pcattori Sep 26, 2024
07f9b5e
Merge branch 'dev' into pedro/typesafety-phase-1
pcattori Sep 28, 2024
197c225
clientLoader.hydrate types
pcattori Sep 28, 2024
38a2037
typegen as `.d.ts` instead of `.ts`
pcattori Sep 28, 2024
23c16a6
typegen for root route
pcattori Sep 30, 2024
1dd6884
inline ctx for ts plugin
pcattori Sep 30, 2024
dce0795
no more hardcoded app dir
pcattori Sep 30, 2024
f042b8c
decision doc rejected solutions section
pcattori Sep 30, 2024
92d4a70
fix HydrateFallback detection
pcattori Sep 30, 2024
42a5853
rename `DefaultProps` to `ComponentProps`
pcattori Oct 1, 2024
6eaf2d6
pr feedback
pcattori Oct 1, 2024
7e87c5d
changeset
pcattori Oct 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Date: 2022-07-11

Status: accepted
Status: Superceded by [#0012](./0012-type-inference.md)

## Context

Expand Down
270 changes: 270 additions & 0 deletions decisions/0012-type-inference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
# Type inference

Date: 2024-09-20

Status: accepted

Supercedes [#0003](./0003-infer-types-for-useloaderdata-and-useactiondata-from-loader-and-action-via-generics.md)

## Context

Now that Remix is being merged upstream into React Router, we have an opportunity to revisit our approach to typesafety.

### Type inference

There are three major aspects to typesafety in a framework like React Router:

1. **Type inference from the route config**

Some types are defined in the route config (`routes.ts`) but need to be inferred within a route module.

For example, let's look at URL path parameters.
Remix had no mechanism for inferring path parameters as that information is not present _within_ a route module.
If a route's URL path was `/products/:id`, you'd have to manually specify `"id"` as a valid path parameter within that route module:

```ts
const params = useParams<"id">();
params.id;
```

This generic was nothing more than a convenient way to do a type cast.
You could completely alter the URL path for a route module, typechecking would pass, but then you would get runtime errors.

2. **Type inference within a route**

Some types are defined within a route module but need to be inferred across route exports.

For example, loader data is defined by the return type of `loader` but needs to be accessed within the `default` component export:

```ts
export function loader() {
// define here 👇
return { planet: "world" };
}

export default function Component() {
// access here 👇
const data = useLoaderData<typeof loader>();
}
```

Unlike the `useParams` generic, this isn't just a type cast.
The `useLoaderData` generic ensures that types account for serialization across the network.
However, it still requires you to add `typeof loader` every time.

Not only that, but complex routes get very tricky to type correctly.

> What if the route also has a `clientLoader`?
> Did you remember to change the generic to `typeof clientLoader`?
> But in the initial SSR render, the `clientLoader` doesn't run, so really you'd need `typeof loader | typeof clientLoader`.
> Unless you also set `clientLoader.hydrate = true` _AND_ provided a `HydrateFallback`. Then `clientLoader` always runs so you'd want just `typeof clientLoader`

The generic for `useLoaderData` starts to feel a lot like doing your taxes: there's only one right answer, Remix knows what it is, but you're going to get quizzed on it anyway.

3. **Type inference across routes**

Some types are defined in one route module but need to be inferred in another route module.
This is common when wanting to access loader data of matched routes like when using `useMatches` or `useRouteLoaderData`.

```ts
import type { loader as otherLoader } from "../other-route.ts";
// hope the other route is also matched 👇 otherwise this will error at runtime
const otherData = useRouteLoaderData<typeof otherLoader>();
```

Again, its up to you to wire up the generics with correct types.
In this case you need to know both types defined in the route config (to know which routes are matched) and types defined in other route modules (to know the loader data for those routes).

In practice, Remix's generics work fine most of the time.
But they are mostly boilerplate and can become error-prone as the app scales.
An ideal solution would infer types correctly on your behalf, doing away with tedious generics.

## Goals

- Type inference from the route config (`routes.ts`)
- Type inference within a route
- Type inference across routes
- Same code path for type inference whether using programmatic routing or file-based routing
- Compatibility with standard tooling for treeshaking, HMR, etc.
- Minimal impact on runtime API design

## Decisions

### Route exports API

Keep the route module export API as is.
Route modules should continue to export separate values for `loader`, `clientLoader`, `action`, `ErrorBoundary`, `default` component, etc.
That way standard transforms like treeshaking and React Fast Refresh (HMR) work out-of-the-box.

Additionally, this approach introduces no breaking changes allowing Remix users to upgrade to React Router v7 more easily.

### Pass path params, loader data, and action data as props

Hooks like `useParams`, `useLoaderData`, and `useActionData` are defined once in `react-router` and are meant to be used in _any_ route.
Without any coupling to a specific route, inferring route-specific types becomes impossible and would necessitate user-supplied generics.

Instead, each route export should be provided route-specific args:

```ts
// Imagine that we *somehow* had route-specific types for:
// - LoaderArgs
// - ClientLoaderArgs
// - DefaultProps

export function loader({ params }: LoaderArgs) {}

export function clientLoader({ params, serverLoader }: ClientLoaderArgs) {}

export default function Component({
params,
loaderData,
actionData,
}: DefaultProps) {
// ...
}
```

We'll keep those hooks around for backwards compatibility, but eventually the aim is to deprecate and remove them.
We can design new, typesafe alternatives for any edge cases.

### Typegen

While React Router will default to programmatic routing, it can easily be configured for file-based routing.
That means that sometimes route URLs will only be represented as file paths.
Unfortunately, TypeScript cannot use the filesystem as part of its type inference nor type checking.
The only tenable way to infer types based on file paths is through code generation.

We _could_ have typegen just for file-based routing, but then we'd need to maintain a separate code path for type inference in programmatic routing.
To keep things simple, React Router treats any value returned by `routes.ts` the same; it will not make assumptions about _how_ those routes were constructed and will run typegen in all cases.

To that end, React Router will generate types for each route module into a special, gitignored `.react-router` directory.
For example:

```txt
- .react-router/
- types/
- app/
- routes/
- +types.product.ts
- app/
- routes/
- product.tsx
```

The path within `.react-router/types` purposefully mirrors the path to the correspond route module.
By setting things up like this, we can use `tsconfig.json`'s [rootDirs](https://www.typescriptlang.org/tsconfig/#rootDirs) option to let you conveniently import from the typegen file as if it was a sibling:

```ts
// app/routes/product.tsx
import { LoaderArgs, DefaultProps } from "./+types.product";
```

TypeScript will even give you import autocompletion for the typegen file and the `+` prefix helps to distinguish it as a special file.
Big thanks to Svelte Kit for showing us that [`rootDirs` trick](https://svelte.dev/blog/zero-config-type-safety#virtual-files)!

### TypeScript plugin

Typegen solutions often receive criticism due to typegen'd files becoming out of sync during development.
This happens because many typegen solutions require you to then rerun a script to update the typegen'd files.

Instead, our typegen will automatically run within a TypeScript plugin.
That means you should never need to manually run a typegen command during development.
It also means that you don't need to run our dev server for typegen to take effect.
The only requirement is that your editor is open.

Additionally, TypeScript plugins work with any LSP-compatible editor.
That means that this single plugin will work in VS Code, Neovim, or any other popular editor.

Even more exciting is that a TS plugin sets the stage for tons of other DX goodies:

- jsdoc and links to official documentation when you hover a route export
- Snippet-like autocomplete for route exports
- In-editor warnings when you forget to name your React components, which would cause HMR to fail
- ...and more...

## Rejected solutions

### `defineRoute`

Early on, we considered changing the route module API from many exports to a single `defineRoute` export:

```tsx
export default defineRoute({
loader() {
return { planet: "world" };
},
Component({ loaderData }) {
return <h1>Hello, {loaderData.planet}!</h1>;
},
});
```

That way `defineRoute` could do some TypeScript magic to infer `loaderData` based on `loader` (type inference within a route).
With some more work, we envisioned that `defineRoute` could return utilities like a typesafe `useRouteLoaderData` (type inference across routes).

However, there were still many drawbacks with this design:

1. Type inference across function arguments depends on the ordering of those arguments.
That means that if you put `Component` before `loader` type inference is busted and you'll get gnarly type errors.

2. Any mechanism expressible solely as code in a route module cannot infer types from the route config (`routes.ts`).
That means no type inference for things like path params nor for `<Link to="..." />`.

3. Transforms that expect to operate on module exports can no longer access parts of the route.
For example, bundlers would only see one big export so they would bail out of treeshaking route modules.
Similarly, React-based HMR via React Fast Refresh looks for React components as exports of a module.
It would be possible to augment React component detection for HMR to look within a function call like `defineRoute`, but it significantly ups the complexity.

### `defineLoader` and friends

Instead of a single `defineRoute` function as described above, we could have a `define*` function for each route export:

```tsx
import { defineLoader } from "./+types.product";

export const loader = defineLoader(() => {
return { planet: "world" };
});
```

That would address the most of the drawbacks of the `defineRoute` approach.
However, this adds significant noise to the code.
It also means we're introducing a runtime API that only exists for typesafety.

Additionally, utilities like `defineLoader` are implemented with an `extends` generic that [does not pin point incorrect return statements](https://tsplay.dev/WJP7ZN):

```ts
const defineLoader = <T extends Loader>(loader: T): T => loader;

export const loader = defineLoader(() => {
// ^^^^^^^
// Argument of type '() => "string" | 1' is not assignable to parameter of type 'Loader'.
// Type 'string | number' is not assignable to type 'number'.
// Type 'string' is not assignable to type 'number'.(2345)

if (Math.random() > 0.5) return "string"; // 👈 don't you wish the error was here instead?
return 1;
});
```

### Zero-effort typesafety

Svelte Kit has a ["zero-effort" type safety approach](https://svelte.dev/blog/zero-config-type-safety) that uses a TypeScript language service plugin to automatically inject types for framework-specific exports.
Initially, this seemed like a good fit for React Router too, but we ran into a couple drawbacks:

1. Tools like `typescript-eslint` that need to statically inspect the types of your TS files without running a language server would not be aware of the injected types.
There's an open issue for [`typescript-eslint` interop with Svelte Kit](https://github.com/sveltejs/language-tools/issues/2073)

2. Running `tsc` would perform typechecking without any knowledge of our custom language service.
To fix this, we would need to wrap `tsc` in our own CLI that programmatically calls the TS typechecker.
For Svelte Kit, this isn't as big of an issue since they already need their own typecheck command for the Svelte language: `svelte-check`.
But since React Router is pure TypeScript, it would be more natural to invoke `tsc` directly in your `package.json` scripts.

## Summary

By leaning into automated typegen within a TypeScript plugin, we radically simplify React Router's runtime APIs while providing strong type inference across the entire framework.
We can continue to support programmatic routing _and_ file-based routing in `routes.ts` while providing typesafety with the same approach and same code path.
We can design our runtime APIs without introducing bespoke ways to inform TypeScript of the route hierarchy.

The initial implementation will be focused on typesafety for path params, loader data, and action data.
That said, this foundation lets us add type inference for things like `<Link to="..." />` and search params in the future.
6 changes: 5 additions & 1 deletion packages/react-router-dev/__tests__/cli-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,11 @@ describe("remix CLI", () => {
$ react-router reveal entry.server
$ react-router reveal entry.client --no-typescript
$ react-router reveal entry.server --no-typescript
$ react-router reveal entry.server --config vite.react-router.config.ts"
$ react-router reveal entry.server --config vite.react-router.config.ts

Generate types for route modules:

$ react-router typegen"
`);
});
});
Expand Down
10 changes: 10 additions & 0 deletions packages/react-router-dev/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { RoutesFormat } from "../config/format";
import { loadPluginContext } from "../vite/plugin";
import { transpile as convertFileToJS } from "./useJavascript";
import * as profiler from "../vite/profiler";
import * as Typegen from "../typescript/typegen";

export async function routes(
reactRouterRoot?: string,
Expand Down Expand Up @@ -190,3 +191,12 @@ async function createClientEntry(
let contents = await fse.readFile(inputFile, "utf-8");
return contents;
}

export async function typegen(root: string) {
let ctx = await loadPluginContext({ root });
await Typegen.writeAll({
rootDirectory: root,
appDirectory: ctx.reactRouterConfig.appDirectory,
routes: ctx.reactRouterConfig.routes,
});
}
7 changes: 7 additions & 0 deletions packages/react-router-dev/cli/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ ${colors.logoBlue("react-router")}
$ react-router reveal entry.client --no-typescript
$ react-router reveal entry.server --no-typescript
$ react-router reveal entry.server --config vite.react-router.config.ts

${colors.heading("Generate types for route modules")}:

$ react-router typegen
`;

/**
Expand Down Expand Up @@ -170,6 +174,9 @@ export async function run(argv: string[] = process.argv.slice(2)) {
case "dev":
await commands.dev(input[1], flags);
break;
case "typegen":
await commands.typegen(input[1]);
break;
default:
// `react-router ./my-project` is shorthand for `react-router dev ./my-project`
await commands.dev(input[0], flags);
Expand Down
7 changes: 5 additions & 2 deletions packages/react-router-dev/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"directory": "packages/react-router-dev"
},
"license": "MIT",
"main": "./dist/typescript/plugin.ts",
"exports": {
"./routes": {
"types": "./dist/routes.d.ts",
Expand Down Expand Up @@ -47,21 +48,23 @@
"arg": "^5.0.1",
"babel-dead-code-elimination": "^1.0.6",
"chalk": "^4.1.2",
"chokidar": "^4.0.0",
"dedent": "^1.5.3",
"es-module-lexer": "^1.3.1",
"exit-hook": "2.2.1",
"fs-extra": "^10.0.0",
"gunzip-maybe": "^1.4.2",
"jsesc": "3.0.2",
"lodash": "^4.17.21",
"pathe": "^1.1.2",
"picocolors": "^1.0.0",
"picomatch": "^2.3.1",
"prettier": "^2.7.1",
"react-refresh": "^0.14.0",
"semver": "^7.3.7",
"set-cookie-parser": "^2.6.0",
"vite-node": "^1.6.0",
"valibot": "^0.41.0"
"valibot": "^0.41.0",
"vite-node": "^1.6.0"
},
"devDependencies": {
"@react-router/serve": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion packages/react-router-dev/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ module.exports = function rollup() {
`${SOURCE_DIR}/routes.ts`,
`${SOURCE_DIR}/vite.ts`,
`${SOURCE_DIR}/vite/cloudflare.ts`,
`${SOURCE_DIR}/typescript/plugin.ts`,
],
output: {
banner: createBanner("@react-router/dev", version),
dir: OUTPUT_DIR,
format: "cjs",
preserveModules: true,
exports: "named",
exports: "auto",
},
plugins: [
babel({
Expand Down
Loading
Loading