Type-Safe Navigation in React Navigation v6: Catching Route Errors at Compile Time
React Navigation v6 lets you define a single RootParamList type that propagates through navigate, push, deep links, and nested navigators — catching typos and missing params at compile time instead of runtime.
14 Sept 2025, 08:42 UTC

The Problem with String-Based Navigation
Every React Native developer has shipped a typo like navigate('Profle', { userId: '123' }) — only to discover the broken screen at runtime during QA or, worse, in production. React Navigation v6 solves this by letting you define a single source of truth for your route parameters, then propagating those types through navigate, push, deep links, and nested navigators. The result: the TypeScript compiler catches missing, misspelled, or mismatched parameters before the app ever runs.
Define a Root Parameter List Once
Start by creating a type that maps every route name in your root navigator to its required parameters. Routes with no params use undefined.
// navigation/types.ts
export type RootParamList = {
Home: undefined;
Profile: { userId: string };
Settings: undefined;
Chat: { threadId: string; initialMessage?: string };
};Pass this type to createStackNavigator (or createBottomTabNavigator, createDrawerNavigator) to get fully typed navigation props throughout the navigator.
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import type { RootParamList } from './types';
const Stack = createNativeStackNavigator<RootParamList>();Typed Hooks for Navigation and Route Access
Inside any screen, use the typed variants of useNavigation and useRoute. The navigation prop knows every valid destination and its param shape; the route prop narrows to the current screen's params.
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp, RouteProp } from '@react-navigation/native';
import type { RootParamList } from '../navigation/types';
export function SettingsScreen() {
// Navigation prop typed for the entire RootParamList
const navigation = useNavigation<NativeStackNavigationProp<RootParamList>>();
// Route prop typed only for 'Profile' params
const route = useRoute<RouteProp<RootParamList, 'Profile'>>();
const { userId } = route.params; // userId is string, not string | undefined
const openChat = () => {
// TypeScript errors if threadId is missing or not a string
navigation.navigate('Chat', { threadId: 'abc-123' });
};
return <Text>Profile for {userId}</Text>;
}If you write navigation.navigate('Profile', { userId: 123 }), the compiler flags the number-to-string mismatch. If you omit userId entirely, it errors on the missing required property.
Deep Linking Inherits the Same Types
The linking configuration uses your RootParamList to parse path segments into typed parameters. Define path patterns with the same param names:
import { LinkingOptions } from '@react-navigation/native';
import type { RootParamList } from './types';
const linking: LinkingOptions<RootParamList> = {
prefixes: ['myapp://', 'https://example.com'],
config: {
Profile: 'profile/:userId',
Chat: 'chat/:threadId',
},
};
// In your NavigationContainer:
// <NavigationContainer linking={linking} />When a user opens myapp://profile/42, the library parses userId as a string and passes it to the Profile screen. If you later make userId required in RootParamList but forget to update the path pattern, TypeScript surfaces the mismatch at build time.
Composing Nested Navigators Without Losing Types
Real apps nest navigators — a tab navigator inside a stack, a drawer around tabs, etc. Each nested navigator receives its own param list slice. Compose them by extending a base type and using CompositeNavigationProp to preserve cross-boundary navigation safety.
// Tab navigator param list
type TabParamList = {
Feed: undefined;
Search: { query?: string };
Profile: { userId: string };
};
// Root stack includes the tab navigator as a single route
type RootParamList = {
Tabs: { screen: keyof TabParamList; params: TabParamList[keyof TabParamList] };
Auth: undefined;
Modal: { message: string };
};
// Inside a tab screen, navigate to a root-level route:
import { CompositeNavigationProp } from '@react-navigation/native';
import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
const navigation = useNavigation<
CompositeNavigationProp<
BottomTabNavigationProp<TabParamList>,
NativeStackNavigationProp<RootParamList>
>
>();
// Now navigation.navigate('Auth') works and is typed,
// while navigation.navigate('Feed') stays typed for the tab navigator.Worked Example: Verifying the Setup
Create a minimal project to confirm the type pipeline works end-to-end:
npx create-expo-app@latest type-safe-nav --template blank-typescriptcd type-safe-nav && npm install @react-navigation/native @react-navigation/native-stack- Add
RootParamListwith one required param (as shown above). - In a screen, write
navigation.navigate('Profile', { userId: 123 }). - Run
npx tsc --noEmit— you should see:Type 'number' is not assignable to type 'string'.
Fix the call to { userId: '123' } and the error disappears. No runtime cost, no extra test suite needed.
Trade-offs and Limitations
- Dynamic route names lose safety.
navigate(screenNameVariable)falls back tostringbecause TypeScript can't narrow a variable. Prefer a discriminated union or a typed helper function that maps known keys to their param shapes. - Optional params still require runtime checks. Marking
initialMessage?: stringin the param list meansroute.params.initialMessageisstring | undefined— you must handleundefinedin the component. - Expo Router uses a different type-generation approach. Mixing file-based routing with a manual
RootParamListin the same app requires maintaining two separate type definitions. - Custom or third-party navigators must forward generics. If you wrap a navigator, pass the param list generic through to the underlying
create*Navigatorcall, otherwise the type chain breaks.
Incremental Migration Path
You don't need a big-bang rewrite. Add the RootParamList type, wrap your root navigator with the generic, then enable TypeScript strict mode ("strict": true in tsconfig.json). The compiler will surface every untyped navigate call. Fix them one file at a time — each fix eliminates a class of runtime crashes. Run tsc --noEmit in CI to keep the guardrails active.
Start with the navigator that changes most often (usually the main stack or tabs). Once that's clean, extend the pattern to nested navigators and deep linking config. The payoff: zero navigation-related type errors in production, and refactoring confidence when route signatures evolve.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.