Mobile — React Native Development
TypeScript Patterns for Large React Native Codebases
Direct answer
The TypeScript patterns that keep large React Native codebases maintainable are strict compiler settings enforced from day one, discriminated unions for every async and screen state, fully typed navigation parameters, runtime validation at API boundaries with types inferred from the schemas, and package-level import boundaries between features. The goal is making illegal states unrepresentable — most production crashes I audit trace back to a state the types happily allowed.
TypeScript in a 20-file app is decoration; in a 200,000-line React Native codebase it is the difference between confident refactoring and fear-driven development. These are the patterns I enforce in codebases I lead and look for first in the ones I audit.
Key facts, with sources
- React Native 0.76, released October 23, 2024, enabled the New Architecture by default and shipped with over 1,070 commits from 156 contributors plus a roughly 15x faster Metro resolver. (React Native official blog)
- The State of React Native 2024 survey collected 3,501 responses, up from about 2,400 the previous year, covering more than 15 areas of the ecosystem. (State of React Native survey)
- About 20% of State of React Native 2024 respondents reported apps with more than 100,000 users, up from 14% the year before. (InfoQ)
- Shopify migrated all of its mobile apps to React Native over five years and reports sub-500ms (P75) screen loads and over 99.9% crash-free sessions in production. (Shopify Engineering)
- 88% of surveyed React Native developers feel the framework is progressing positively, while better debugging remains the top request, cited by 54% of respondents. (SSOJet (State of React Native 2024 highlights))
Strictness is the foundation, not a follow-up
Every pattern below is worthless if the compiler is configured to shrug. I require strict: true from the first commit, plus noUncheckedIndexedAccess — which forces you to handle the undefined that array indexing can genuinely return — and lint rules banning explicit any and unchecked non-null assertions. Retrofitting strictness onto a mature codebase is a slow, demoralizing migration; starting strict costs almost nothing.
The cultural half matters as much as the config: as-casts and any escapes should be treated in review like TODO comments with interest accruing. In audits, I can predict a codebase's crash profile from its grep count of 'as any' with unsettling accuracy. When a cast is truly unavoidable — some third-party libraries force it — isolate it in one adapter file with a comment, so the unsafety has an address instead of being scattered through feature code.
Discriminated unions for every async state
The classic bug factory is modeling a screen with parallel booleans: isLoading, hasError, plus a nullable data field. That shape permits nonsense states — loading and errored simultaneously, data present while loading — and every consumer must remember which combinations are real. A discriminated union makes the impossible states unrepresentable and lets the compiler force exhaustive handling in every switch.
I use this shape for network data, form submission status, permission states, and sync status. The payoff compounds at scale: when a new state gets added — say, a 'stale' variant for cached data — the compiler points at every screen that must now handle it, turning a risky manual hunt into a checklist of red squiggles.
type RemoteData<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function InvoiceList({ state }: { state: RemoteData<Invoice[]> }) {
switch (state.status) {
case 'idle':
case 'loading':
return <Spinner />;
case 'error':
return <ErrorView message={state.error.message} />;
case 'success':
return <List data={state.data} />; // data only exists here
default: {
const exhaustive: never = state; // compile error if a variant is added
return exhaustive;
}
}
}Typed navigation eliminates a whole bug class
In untyped React Native codebases, navigation is a stringly-typed minefield: screens receive params that may or may not exist, renamed routes break at runtime, and refactoring a param name means grepping and praying. React Navigation's TypeScript support fixes all of it — declare a param list type per navigator, and both navigate calls and route.params become fully checked.
The discipline is keeping params minimal and serializable: pass IDs, not whole objects. Passing a full entity through params creates stale-data bugs and breaks deep linking; passing an ID and reading from your store or cache keeps one source of truth. In large apps I also centralize param list types in a shared module so feature teams cannot drift into incompatible route definitions.
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
export type RootStackParamList = {
Home: undefined;
InvoiceDetail: { invoiceId: string };
Settings: { section?: 'profile' | 'billing' };
};
type Props = NativeStackScreenProps<RootStackParamList, 'InvoiceDetail'>;
export function InvoiceDetailScreen({ route, navigation }: Props) {
const { invoiceId } = route.params; // typed as string, guaranteed present
// navigation.navigate('InvoiceDetail', {}) would be a compile error
return null;
}Validate at the boundary, trust types inside
A TypeScript type is a compile-time promise the network is under no obligation to keep. Large codebases need a hard rule: every byte entering from outside — API responses, deep links, push payloads, storage reads — passes through runtime validation at exactly one boundary layer, and the static types used everywhere else are inferred from those schemas so they can never drift apart.
I use zod for this: define the schema once, infer the type from it, parse at the API client layer, and let everything downstream trust the type completely. When the backend changes a field, the failure becomes a single loud, well-located parse error with the offending payload attached — instead of an undefined snaking through six components before crashing in a render, which is precisely the debugging session this pattern exists to abolish.
Scaling structure: shared types and import boundaries
Past a certain size, the enemy stops being individual bugs and becomes coupling. I structure large React Native codebases as feature modules with enforced import boundaries — lint rules that prevent feature A from reaching into feature B's internals — and a small set of shared packages: domain types, the API client with its schemas, the design system, and utilities. In a monorepo, the API schema package gets shared with the web app and backend tooling, so a field rename is one change reviewed once.
Two anti-patterns to reject early: the god types file where every interface in the app accumulates, and 'optional everything' types where each field is nullable because different screens need different slices. The second deserves special hostility — model each context's actual shape, using Pick and mapped types from a canonical entity, rather than making every consumer defensively null-check fields that are always present.
Patterns I ban in code review
Seniority in TypeScript shows in restraint. I ban clever conditional-type gymnastics in application code — five-level generic puzzles that save ten lines and cost every future reader an afternoon. Type machinery belongs in a handful of well-tested utility files, not sprinkled through features. I prefer string literal unions over enums in most cases: they serialize cleanly, need no imports at use sites, and interoperate better across package boundaries.
Also banned: non-null assertions to silence the compiler about genuinely nullable values, interfaces re-declared inline instead of imported from the domain package, and React component props typed as React.FC — which is unnecessary and was historically awkward around children typing. The unifying principle: types exist to let a mid-level developer confidently change code they did not write. Any pattern that undermines that — through cleverness, drift, or dishonesty about nullability — costs more than it saves.
When to hire senior help
Bring in senior React Native help when facing a New Architecture or major version migration, persistent performance regressions, or a first store launch, since these are the phases where inexperienced teams lose the most months. A short senior architecture audit early in the project is consistently cheaper than a rescue or rewrite later. If your stack includes React Native + Python + AI, a senior engineer who owns the full product beats coordinating multiple juniors.
Bottom line
Dhairya Senjaliya ships Mobile — React Native Development projects worldwide — book a scoping call to discuss your specific situation.
Common pitfalls to avoid
- ✕Staying multiple versions behind on React Native, then facing a compound upgrade to the New Architecture because popular libraries have dropped old-architecture support
- ✕Treating React Native as write-once-run-anywhere and only QA-testing on iOS, shipping Android builds with broken back-button handling, keyboard behavior, and gesture bugs
- ✕Pulling in unmaintained third-party native modules without checking TurboModule and Fabric compatibility, which later blocks the New Architecture migration
- ✕Launching without crash and performance monitoring wired in, so the team only discovers jank and crash clusters from one-star reviews instead of telemetry
Frequently asked questions
Should a React Native app use TypeScript strict mode from the start?
Yes, without exception. Enabling strict: true plus noUncheckedIndexedAccess on day one costs almost nothing, while retrofitting strictness onto a mature codebase is a long, demoralizing migration that most teams abandon halfway. Strict mode is also the prerequisite for every other high-value pattern — discriminated unions, typed navigation, and boundary validation all lose their guarantees when the compiler is permissive.
How do you type React Navigation screens and params in TypeScript?
Declare a param list type per navigator — an object mapping each route name to its params or undefined — and use NativeStackScreenProps to type each screen's route and navigation props. Both navigate calls and route.params then get compile-time checking. Keep params minimal and serializable: pass entity IDs rather than whole objects, so deep linking works and screens read fresh data from one source of truth.
Do TypeScript types validate API responses at runtime?
No — TypeScript types are erased at compile time and provide zero runtime protection, which is why untyped API drift is a top crash source in React Native apps. The fix is runtime validation with a library like zod at the API boundary: define schemas once, infer the static types from them, parse every response, and let code downstream trust the types completely.
Is React Native still a good technology bet in 2026?
Yes for teams with JavaScript or React skills; the New Architecture has been the default since version 0.76 in late 2024, and the framework is used in production by Meta, Microsoft, Shopify, and Amazon. In the latest State of React Native survey, 88% of developers said the framework is heading in a positive direction.
Can a React Native app feel as fast as a fully native app?
For most business, e-commerce, and content apps, yes; Shopify runs its entire app portfolio on React Native with sub-500ms P75 screen loads and over 99.9% crash-free sessions. Workloads like heavy 3D, AR, or real-time audio processing still warrant native modules or fully native builds.
How much code is actually shared between iOS and Android?
Production teams commonly report 85 to 95%+ shared code; published examples include Instagram at 85 to 99% and Shopify at roughly 86%. The remainder is platform-specific work such as payments, widgets, and deep OS integrations.
Bottom line: Dhairya Senjaliya ships Mobile — React Native Development projects worldwide. Book a scoping call at https://dhairyasenjaliya.com/#book-call.