Expo Router can generate static route types from your app/ file tree, giving <Link>, router.push, and param hooks compile-time checks. Route params still cross a trust boundary - they originate from URLs, notifications, and OS intents - so pair generated types with runtime validation.
// app/user/[id].tsximport { Link, router, useLocalSearchParams, type Href } from "expo-router";import { Text, View } from "react-native";import { z } from "zod";const Params = z.object({ id: z.string().regex(/^\d+$/),});export default function UserScreen() { const raw = useLocalSearchParams<"/user/[id]">(); const { id } = Params.parse(raw); // runtime gate - params are strings return ( <View> <Text>User {id}</Text> <Link href={{ pathname: "/user/[id]", params: { id: "42" } }}>User 42</Link> </View> );}// Imperative navigation - Href catches typosexport function openUser(userId: string) { const href = { pathname: "/user/[id]", params: { id: userId } } satisfies Href; router.push(href);}
When to reach for this: You use Expo Router file-based routes and want autocomplete on href, typed path segments in screens, and validation before trusting deep-link params.
String path for dynamic routes - <Link href="/places/[placeId]" /> may type-check in loose setups but omits required params. Fix: Use { pathname, params } objects for every [param] segment.
Trusting typed params as numbers - useLocalSearchParams<"/product/[id]">() still returns id: string. Fix: Parse with z.coerce.number() or Number() behind validation.
Stale types after moving files - Renaming routes without restarting Metro leaves outdated Href unions. Fix: Restart npx expo start or run the customize command on CI before tsc.
Query params assumed auto-typed - Only file-system segments appear in generated types. Fix: Add a second generic to useLocalSearchParams or a Zod schema for ?tab= and ?ref= keys.
Catch-all type surprises - [...terms] may be string when one segment and string[] when several. Fix: Normalize with Array.isArray(v) ? v : [v] before use.
Relative href strings - ./settings is intentionally excluded from Href. Fix: Prefix with useSegments() or hard-code absolute paths.
Optional params in push - Passing params: { id: undefined } can stringify "undefined" in the URL. Fix: Omit keys or filter before navigation.
Set experiments.typedRoutes to true in app.json, run npx expo customize tsconfig.json, then start the dev server with npx expo start. Generated types update when route files change.
Where are generated route types stored?
Expo CLI writes into .expo/types/ and references them from a git-ignored expo-env.d.ts at the project root. Do not commit or hand-edit these files.
Why must dynamic routes use href objects?
Dynamic segments need a params map alongside pathname. The object form ensures every [placeId] or [id] token receives a value and matches the generated Href union.
Or validate with Zod for enums, defaults, and coercion.
What is the difference between useLocalSearchParams and useGlobalSearchParams?
useLocalSearchParams returns params for the current screen's route pattern. useGlobalSearchParams returns the merged params for the entire navigation tree - useful for catch-all routes and nested layouts, but broader in scope.
Are route params typed as strings?
Yes. URL segments and query values are strings at the native boundary. TypeScript generics name the keys; Zod (or similar) proves value shapes at runtime.
How do I navigate imperatively with types?
Import router from expo-router or call useRouter(). Both accept Href paths: router.push("/places") for static routes and router.push({ pathname: "/places/[placeId]", params: { placeId } }) for dynamic ones.
Can I use typed routes without starting Metro?
Run npx expo customize tsconfig.json on CI before tsc so types exist without a dev server. For fresh route files, still regenerate when the app/ tree changes.
Gotcha: Why does autocomplete not show my new route?
Types generate on dev server start. If you added a file while Metro was idle, restart npx expo start. Confirm experiments.typedRoutes is true and tsconfig.json includes expo-env.d.ts.
How do catch-all routes type their params?
A file search/[...terms].tsx maps to useLocalSearchParams<"/search/[...terms]">() with terms as string | string[]. Normalize to an array before joining or displaying.
Should I validate params if TypeScript already checks them?
Yes. Typed routes prove route shape, not user input. Deep links, QR codes, and OS share intents can supply malformed values - validate at the screen boundary.
How do route groups affect types?
Groups like (tabs) are omitted from the URL but appear in file paths. Generated types include group segments where applicable - use the exact string literal TypeScript suggests when passing generics to hooks.
How do I build tab-relative links without relative href?
Read the current segments with useSegments() and construct an absolute path:
const [group] = useSegments();<Link href={`/${group}/profile` as Href}>Profile</Link>
Can Zod share schemas between API and route params?
Yes. Reuse primitives (z.string().uuid()) across API response schemas and route param schemas. Keep screen-specific objects separate to avoid coupling fetch shapes to URL shapes.
What if I migrate from React Navigation param lists?
Map each screen to a file route, enable typed routes, then replace manual RootStackParamList entries incrementally. See Gradual Typing in Brownfield Apps for migration pacing.