Back to Posts
TypeScript Patterns for React Native
By Lumina Software•
typescriptreact-nativemobile-developmentpatterns
TypeScript Patterns for React Native
TypeScript brings type safety to React Native, catching errors before runtime and improving developer experience. Here are the patterns and practices that make TypeScript truly powerful in React Native development.
Type-Safe Component Props
Basic Props Interface
interface ButtonProps {
title: string;
onPress: () => void;
disabled?: boolean;
variant?: 'primary' | 'secondary';
}
function Button({ title, onPress, disabled = false, variant = 'primary' }: ButtonProps) {
return (
<TouchableOpacity onPress={onPress} disabled={disabled}>
<Text style={styles[variant]}>{title}</Text>
</TouchableOpacity>
);
}
Extending Native Components
import { TextInput, TextInputProps } from 'react-native';
interface CustomTextInputProps extends TextInputProps {
label?: string;
error?: string;
}
function CustomTextInput({ label, error, ...textInputProps }: CustomTextInputProps) {
return (
<View>
{label && <Text>{label}</Text>}
<TextInput {...textInputProps} />
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
Advanced Type Patterns
Discriminated Unions
Perfect for component variants:
type LoadingState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User[] }
| { status: 'error'; error: string };
function UserList({ state }: { state: LoadingState }) {
switch (state.status) {
case 'idle':
return <EmptyState />;
case 'loading':
return <LoadingSpinner />;
case 'success':
return <UserList data={state.data} />; // TypeScript knows data exists
case 'error':
return <ErrorMessage error={state.error} />; // TypeScript knows error exists
}
}
Generic Components
interface ListProps<T> {
data: T[];
renderItem: (item: T) => React.ReactNode;
keyExtractor: (item: T) => string;
}
function List<T>({ data, renderItem, keyExtractor }: ListProps<T>) {
return (
<FlatList
data={data}
renderItem={({ item }) => renderItem(item)}
keyExtractor={keyExtractor}
/>
);
}
// Usage with type inference
<List
data={users}
renderItem={(user) => <UserCard user={user} />} // user is typed as User
keyExtractor={(user) => user.id}
/>
Conditional Types
type NonNullable<T> = T extends null | undefined ? never : T;
type Optional<T> = {
[K in keyof T]?: T[K];
};
type Required<T> = {
[K in keyof T]-?: T[K];
};
// Usage
type PartialUser = Optional<User>;
type RequiredUser = Required<User>;
Navigation Types
Expo Router Type Safety
// app/(tabs)/profile/[id].tsx
import { useLocalSearchParams } from 'expo-router';
export default function ProfileScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
// id is typed as string
return <Text>Profile {id}</Text>;
}
// Type-safe navigation
import { router } from 'expo-router';
router.push({
pathname: '/profile/[id]',
params: { id: '123' }, // TypeScript validates params
});
React Navigation Types
import { StackScreenProps } from '@react-navigation/stack';
type RootStackParamList = {
Home: undefined;
Profile: { userId: string };
Settings: undefined;
};
type ProfileScreenProps = StackScreenProps<RootStackParamList, 'Profile'>;
function ProfileScreen({ route, navigation }: ProfileScreenProps) {
const { userId } = route.params; // Typed as string
navigation.navigate('Home'); // Type-safe navigation
}
State Management Types
Zustand with TypeScript
import create from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface AppState {
user: User | null;
theme: 'light' | 'dark';
setUser: (user: User | null) => void;
setTheme: (theme: 'light' | 'dark') => void;
}
export const useAppStore = create<AppState>()(
devtools(
persist(
(set) => ({
user: null,
theme: 'light',
setUser: (user) => set({ user }),
setTheme: (theme) => set({ theme }),
}),
{ name: 'app-storage' }
)
)
);
// Usage with type inference
function Profile() {
const user = useAppStore((state) => state.user); // Typed as User | null
const setUser = useAppStore((state) => state.setUser); // Typed correctly
}
TanStack Query Types
interface User {
id: string;
name: string;
email: string;
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
function useUser(userId: string) {
return useQuery<User, Error>({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useUser(userId);
// user is typed as User | undefined
// error is typed as Error | null
}
API Type Safety
Fetch with Types
interface ApiResponse<T> {
data: T;
error?: string;
}
async function apiRequest<T>(
url: string,
options?: RequestInit
): Promise<ApiResponse<T>> {
const response = await fetch(url, options);
const data = await response.json();
return { data };
}
// Usage
const { data: users } = await apiRequest<User[]>('/api/users');
// users is typed as User[]
API Client Pattern
class ApiClient {
async get<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`);
return response.json();
}
async post<T>(endpoint: string, data: unknown): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
body: JSON.stringify(data),
});
return response.json();
}
}
// Typed endpoints
interface Endpoints {
'/users': User[];
'/users/:id': User;
'/posts': Post[];
}
const api = new ApiClient();
// Type-safe API calls
const users = await api.get<Endpoints['/users']>('/users');
Form Types
React Hook Form Types
import { useForm, Controller } from 'react-hook-form';
interface FormData {
email: string;
password: string;
rememberMe: boolean;
}
function LoginForm() {
const { control, handleSubmit } = useForm<FormData>();
const onSubmit = (data: FormData) => {
// data is fully typed
console.log(data.email, data.password);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="email"
control={control}
render={({ field }) => (
<TextInput {...field} keyboardType="email-address" />
)}
/>
</form>
);
}
Utility Types
Common Helpers
// Extract type from array
type ArrayElement<T> = T extends (infer U)[] ? U : never;
// Extract return type
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
// Extract promise type
type Awaited<T> = T extends Promise<infer U> ? U : T;
// Make specific keys optional
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Usage
type UserWithoutEmail = PartialBy<User, 'email'>;
Type Guards
Runtime Type Checking
function isUser(obj: unknown): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
'name' in obj &&
'email' in obj
);
}
function processData(data: unknown) {
if (isUser(data)) {
// TypeScript knows data is User here
console.log(data.email);
}
}
Zod for Runtime Validation
import { z } from 'zod';
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
function validateUser(data: unknown): User {
return UserSchema.parse(data); // Throws if invalid
}
Strict TypeScript Configuration
Recommended tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true
}
}
Common Patterns
Type-Safe Event Handlers
type ButtonPressEvent = {
type: 'press';
timestamp: number;
};
type ButtonLongPressEvent = {
type: 'longPress';
duration: number;
};
type ButtonEvent = ButtonPressEvent | ButtonLongPressEvent;
function handleButtonEvent(event: ButtonEvent) {
if (event.type === 'press') {
console.log('Pressed at', event.timestamp);
} else {
console.log('Long pressed for', event.duration);
}
}
Branded Types
type UserId = string & { readonly brand: unique symbol };
type PostId = string & { readonly brand: unique symbol };
function createUserId(id: string): UserId {
return id as UserId;
}
function getUser(id: UserId) {
// Can't accidentally pass PostId
}
Best Practices
- Use strict mode: Enable all strict checks
- Avoid
any: Useunknowninstead when type is truly unknown - Leverage inference: Let TypeScript infer types when possible
- Use utility types: Don't reinvent the wheel
- Type at boundaries: API responses, user input, etc.
- Document complex types: Add comments for non-obvious types
- Use type guards: For runtime validation
Conclusion
TypeScript in React Native provides:
- Type safety: Catch errors at compile time
- Better DX: Autocomplete and IntelliSense
- Self-documenting code: Types serve as documentation
- Refactoring confidence: Safe to rename and restructure
Master these patterns, and you'll write more robust, maintainable React Native code. TypeScript isn't just about types—it's about building better software.
