skip to content

In an Expo Router project, what does enabling experiments.typedRoutes check at compile time, and how do you keep it working in CI?

level: seniorimportance: nice to knowfreq 28%

answer

  1. app.json experiments flag, beta
  2. types generated from the file tree
  3. dev server writes them, gitignored
  4. dynamic routes need params
  5. CI: npx expo customize tsconfig.json

basics

~10 s

With experiments.typedRoutes, Expo CLI generates route types from src/app, so TypeScript rejects hrefs to missing routes, dynamic hrefs with wrong params and relative paths; CI must run npx expo customize tsconfig.json before type checking.

solid answer

~40 s

Typed routes is a **beta** Expo Router feature, switched on with `"experiments": { "typedRoutes": true }` in `app.json` (projects from the quick-start template already have it). When `npx expo start` runs, Expo CLI generates TypeScript declarations from the files under `src/app` into the project's `.expo/types` folder, writes a gitignored `expo-env.d.ts`, and adds both to `tsconfig.json`; it regenerates them as route files change. From then on `Href` is strict: `<Link href="/prodcut/42">` fails, a string like `"/product/[id]"` fails because a dynamic route needs an object with `params: { id }`, unknown param keys fail, and relative hrefs are not supported. `useLocalSearchParams<'/product/[id]'>()` derives param types from the route. Because the generated files are not committed, a CI job must run `npx expo customize tsconfig.json` to generate them before `tsc`.

code

json · 7 lines
json
{
  "expo": {
    "experiments": {
      "typedRoutes": true
    }
  }
}

go deeper

for a junior

Recall that experiments.typedRoutes makes TypeScript reject links to routes that do not exist, using types generated from the src/app files.

for a middle

Explain the rules: dynamic routes need params objects or matching template strings, param keys are checked, relative hrefs are rejected, and query params need a manual generic.

for a senior

Make the feature reliable at team scale: generate types in CI with npx expo customize tsconfig.json, keep casts rare, and still validate params at runtime.

for a principal

Weigh adopting a beta compile-time feature across a large codebase: link-rot prevention and safer refactors against generated-artefact tooling in every pipeline.

## What the feature is Expo Router can generate **TypeScript types from the route files** under `src/app`, so that navigation targets are checked by the compiler. The feature is labelled **beta** and is enabled through the app config: ```json { "expo": { "experiments": { "typedRoutes": true } } } ``` Projects created from the Expo Router quick start are already configured. The feature requires TypeScript in the project. ## What Expo CLI changes When typed routes is on and the development server starts (`npx expo start`), Expo CLI: - creates a **`.expo/types`** directory and writes the route declarations there; - writes an **`expo-env.d.ts`** file at the project root and adds it to **`.gitignore`**; - updates **`tsconfig.json`** so its `include` covers `expo-env.d.ts` and the hidden `.expo` directory; - **watches** the routes directory and regenerates the declarations when files are added, renamed or removed. If the flag is turned off again, the next start removes those side effects. The generated files are meant to stay uncommitted and unedited. ## What the compiler now checks Every API that takes an `Href` (the `Link` component's `href`, `router.push` and friends, `Redirect`) becomes strict: | Code | Result | Why | |---|---|---| | `href="/about"` | passes | the route exists | | `` href={`/product/${id}`} `` | passes | template strings matching a dynamic route are accepted | | `href="/prodcut/42"` | error | no such route | | `href="/product/[id]"` | error | a dynamic route written as a pattern needs an object with `params` | | `href={{ pathname: '/product/[id]', params: { id: 42 } }}` | passes | params match the route | | `href={{ pathname: '/product/[id]', params: { _id: 42 } }}` | error | invalid or unknown param key | | `href="./reviews"` | error | typed routes do not support relative paths | Reading params is typed from the tree too: `useLocalSearchParams<'/product/[id]'>()` gives `id: string`, and `useLocalSearchParams<'/docs/[...slug]'>()` gives `slug: string[]`. **Query parameters** are not in the file system, so they are typed with a second generic, as in `useLocalSearchParams<'/product/[id]', { ref?: string }>()`. ## Escape hatches and limits 1. **Casting.** A computed string can be cast with `as Href` when the compiler cannot prove it; every cast is an unchecked spot, so keep them rare. 2. **Relative links.** Since relative hrefs are rejected, build absolute ones; `useSegments()` can supply the current group so a shared component stays inside its tab. 3. **Runtime values.** Types check shapes, not contents: a deep link can still carry `/product/not-a-number`, so screens validate at runtime. ## Keeping it working in CI The declarations are **generated, gitignored and produced by the dev server**. A CI job that runs `tsc` on a fresh checkout has none of them, so every typed `href` either fails or loses its checking depending on how the project references the types. Expo's guide gives the fix: run **`npx expo customize tsconfig.json`** in CI to generate the types without starting the dev server, then type-check. ## Rolling it out on an existing app Turning the flag on in a mature codebase usually surfaces a batch of errors at once. A practical order of work: 1. Enable `experiments.typedRoutes`, start the dev server once, and commit only the `tsconfig.json` and `.gitignore` changes it makes. 2. Fix **genuine broken links** first; these are real bugs the compiler just found. 3. Convert pattern strings such as `"/product/[id]"` into href objects with `params`, or into template strings. 4. Replace **relative hrefs** with absolute ones, using `useSegments()` where a component must stay inside its current group. 5. Review each remaining `as Href` cast and keep only those that are truly dynamic. 6. Add the CI generation step before the type-check job, so the checks cannot silently disappear. ## Why it matters at scale - **Renames become compile errors.** Moving `product/[id].tsx` to `item/[id].tsx` breaks every stale link at build time instead of producing a runtime not-found screen. - **Param contracts are explicit.** Renaming `[id]` to `[productId]` flags every href still passing `id`. - **Review load drops.** Reviewers no longer eyeball URL strings for typos. The cost is a generated artefact that must exist wherever TypeScript runs: developer machines get it from the dev server, CI must generate it explicitly.

  • Why does a fresh CI checkout fail or stop checking hrefs after typed routes is enabled?
    The route declarations and `expo-env.d.ts` are generated by the dev server and gitignored, so a clean checkout has none. Running `npx expo customize tsconfig.json` in CI generates them before `tsc`, restoring the checks.
  • How do you type a query parameter such as ref on a typed product route?
    Query parameters are not in the file tree, so pass them as a second generic: `useLocalSearchParams<'/product/[id]', { ref?: string }>()`. The route part types `id`; the object types `ref`, which stays optional because any URL may omit it.
  • Does typed routes guarantee id is numeric at runtime?
    No. It checks that hrefs in your code match the route tree and param names. Deep links and typed URLs arrive from outside the compiler, and all params are strings, so screens still parse and validate `id`.

saying these in an interview costs you the question

  • Typed routes validates param values at runtime as well.
  • The generated route types should be committed to the repository.
  • href="/product/[id]" is valid once typed routes is enabled.
  • Typed routes also types every query parameter automatically.
  • CI gets the route types automatically from tsc alone.