Type-Safe Routing in React Navigation v6: Param Lists That Catch Bugs at Compile Time
React Navigation v6's TypeScript param lists turn route names and params into compile-time contracts. Here's how to set them up, compose nested navigators, and know where the safety ends.
16 Jul 2026, 13:31 UTC

Deep in a React Native codebase, someone writes navigation.navigate('Detail', { id: orderNumber }) where orderNumber is a string. The Detail screen expects a numeric id and calls id.toFixed(2) on it. Nothing fails at build time; the crash shows up in production logs three days later. This is the exact class of bug React Navigation v6's TypeScript param lists are designed to eliminate — if you set them up deliberately instead of relying on defaults.
The thesis: treat your route definitions as a typed contract, centralized in one file, and let the compiler enforce every navigation call and every route.params read across the app.
The core idea: one param list per navigator
React Navigation v6 ships with bundled TypeScript types (no separate @types package). The pattern is a param list: a type that maps each route name to the params object it accepts, or undefined if it takes none.
// src/navigation/types.ts
export type RootStackParamList = {
Home: undefined;
Detail: { id: number };
Profile: { userId: string; tab?: 'posts' | 'likes' };
};Note the optional param on Profile: tab? means callers may omit it, and the screen must handle undefined. Since v6.1, inference around optional params is stricter, so route.params.tab is typed 'posts' | 'likes' | undefined — you cannot read it without a guard.
Wiring the navigator and screens
Pass the param list as a generic when creating the navigator:
// src/navigation/RootStack.tsx
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { RootStackParamList } from './types';
const Stack = createNativeStackNavigator<RootStackParamList>();Then type each screen with NativeStackScreenProps (or compose NavigationProp/RouteProp if you prefer):
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
type Props = NativeStackScreenProps<RootStackParamList, 'Detail'>;
export function DetailScreen({ route, navigation }: Props) {
const { id } = route.params; // number — autocomplete works
// navigation.navigate('Profile', { userId: 'u1' }); // type-checked
}With this in place, navigation.navigate('Detail', { id: 'abc' }) is a compile error, and so is navigation.navigate('Detial'). Renaming a route or adding a required param turns into a list of red squiggles across every call site — which is exactly what you want during refactors.
Hooks in shared components
Screens get props for free, but shared components and custom hooks need useNavigation/useRoute. These accept the same generics:
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
function useGoToProfile() {
const nav = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
return (userId: string) => nav.navigate('Profile', { userId });
}This keeps navigation logic out of screen components without losing type checking.
Nested navigators: compose, don't merge
A common mistake is stuffing every route into one giant param list. Instead, give each navigator its own list and let the parent reference only the child navigator's entry route:
export type HomeTabsParamList = {
Feed: undefined;
Settings: { section?: string };
};
export type RootStackParamList = {
HomeTabs: undefined; // the tab navigator as a single stack route
Detail: { id: number };
};Tab screens type themselves against HomeTabsParamList; the stack only knows HomeTabs exists. This keeps contracts scoped and prevents cross-navigator coupling. If you need to navigate from a stack screen deep into a tab, use nested navigate syntax (navigate('HomeTabs', { screen: 'Settings', params: { section: 'privacy' } })) — typed via NavigatorScreenParams<HomeTabsParamList> in the parent list.
Where the safety stops
Param lists are not a panacea, and the gaps are worth knowing:
- Dynamic route names bypass checks.
navigation.navigate(someString)won't compile if typed properly, but any cast oras neverescape hatch silently defeats the system. Keep those out of the codebase or wrap them in a validating type predicate. - Deep linking config is not type-checked. Your
linking.config.screensmapping is plain strings; a typo there fails at runtime, not compile time. Verify deep links manually or with an integration test. - Optional params are
undefinedat runtime. TypeScript will correctly type them as possibly undefined, but only if you don't silence it with non-null assertions. Write the guard.
Verify it works in five minutes
In a TypeScript React Native project with @react-navigation/native 6.x installed (npm ls @react-navigation/native to confirm), set up the param list above, then deliberately write navigation.navigate('Detail', { id: 'abc' }) and run npx tsc --noEmit. You should see a type error on the id argument. Misspell the route name and confirm that fails too. If both compile cleanly, your generics aren't wired up — check that the navigator was created with <RootStackParamList>.
The actionable takeaway: create a single types.ts exporting your param lists today, thread the generics through your navigators and screens, and let tsc become your navigation regression suite. The next refactor will thank you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.