Module Overview
Estimated Time: 3 hours | Difficulty: Intermediate | Prerequisites: Basic TypeScript knowledge
- TypeScript configuration for React Native
- Typing components, props, and state
- Navigation type safety
- API response typing
- Generic components
- Advanced TypeScript patterns
TypeScript Setup
New Project with TypeScript
Copy
# Expo (TypeScript by default)
npx create-expo-app@latest my-app
# React Native CLI with TypeScript
npx react-native@latest init MyApp --template react-native-template-typescript
Adding TypeScript to Existing Project
Copy
# Install TypeScript and types
npm install -D typescript @types/react @types/react-native
# Create tsconfig.json
npx tsc --init
TypeScript Configuration
Copy
// tsconfig.json
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
// Strict mode (recommended)
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
// Module resolution
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
// JSX
"jsx": "react-native",
// Output
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
// Path aliases
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
Typing Components
Function Components
Copy
import { View, Text, StyleSheet } from 'react-native';
// Basic component with no props
function WelcomeMessage(): JSX.Element {
return (
<View style={styles.container}>
<Text>Welcome!</Text>
</View>
);
}
// Component with props
interface GreetingProps {
name: string;
age?: number; // Optional prop
}
function Greeting({ name, age }: GreetingProps): JSX.Element {
return (
<View>
<Text>Hello, {name}!</Text>
{age && <Text>You are {age} years old</Text>}
</View>
);
}
// Using React.FC (less common now, but still valid)
const GreetingFC: React.FC<GreetingProps> = ({ name, age }) => {
return (
<View>
<Text>Hello, {name}!</Text>
</View>
);
};
const styles = StyleSheet.create({
container: {
padding: 16,
},
});
Props with Children
Copy
import { View, ViewProps } from 'react-native';
import { ReactNode } from 'react';
// Method 1: Explicit children type
interface CardProps {
children: ReactNode;
title?: string;
}
function Card({ children, title }: CardProps) {
return (
<View style={styles.card}>
{title && <Text style={styles.title}>{title}</Text>}
{children}
</View>
);
}
// Method 2: PropsWithChildren utility
import { PropsWithChildren } from 'react';
interface CardBaseProps {
title?: string;
variant?: 'default' | 'outlined';
}
type CardProps = PropsWithChildren<CardBaseProps>;
function Card({ children, title, variant = 'default' }: CardProps) {
return (
<View style={[styles.card, variant === 'outlined' && styles.outlined]}>
{title && <Text>{title}</Text>}
{children}
</View>
);
}
// Method 3: Extending native component props
interface ContainerProps extends ViewProps {
centered?: boolean;
}
function Container({ centered, style, children, ...props }: ContainerProps) {
return (
<View
style={[styles.container, centered && styles.centered, style]}
{...props}
>
{children}
</View>
);
}
Event Handler Props
Copy
import { Pressable, Text, GestureResponderEvent } from 'react-native';
interface ButtonProps {
title: string;
onPress: (event: GestureResponderEvent) => void;
onLongPress?: (event: GestureResponderEvent) => void;
disabled?: boolean;
}
function Button({ title, onPress, onLongPress, disabled }: ButtonProps) {
return (
<Pressable
onPress={onPress}
onLongPress={onLongPress}
disabled={disabled}
style={({ pressed }) => [
styles.button,
pressed && styles.pressed,
disabled && styles.disabled,
]}
>
<Text style={styles.text}>{title}</Text>
</Pressable>
);
}
// Usage
<Button
title="Submit"
onPress={(e) => console.log('Pressed', e.nativeEvent)}
onLongPress={(e) => console.log('Long pressed')}
/>
Typing State
useState
Copy
import { useState } from 'react';
// Type inference (preferred when possible)
const [count, setCount] = useState(0); // number
const [name, setName] = useState(''); // string
const [isLoading, setIsLoading] = useState(false); // boolean
// Explicit typing (when needed)
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<string[]>([]);
const [data, setData] = useState<ApiResponse | undefined>(undefined);
// Complex state
interface FormState {
email: string;
password: string;
rememberMe: boolean;
}
const [form, setForm] = useState<FormState>({
email: '',
password: '',
rememberMe: false,
});
// Update partial state
setForm(prev => ({ ...prev, email: 'new@email.com' }));
useReducer
Copy
import { useReducer } from 'react';
// State type
interface CounterState {
count: number;
step: number;
}
// Action types
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
| { type: 'reset' }
| { type: 'setStep'; payload: number };
// Reducer function
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case 'increment':
return { ...state, count: state.count + state.step };
case 'decrement':
return { ...state, count: state.count - state.step };
case 'reset':
return { ...state, count: 0 };
case 'setStep':
return { ...state, step: action.payload };
default:
return state;
}
}
// Usage
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0, step: 1 });
return (
<View>
<Text>Count: {state.count}</Text>
<Button title="+" onPress={() => dispatch({ type: 'increment' })} />
<Button title="-" onPress={() => dispatch({ type: 'decrement' })} />
<Button title="Reset" onPress={() => dispatch({ type: 'reset' })} />
<Button
title="Set Step to 5"
onPress={() => dispatch({ type: 'setStep', payload: 5 })}
/>
</View>
);
}
Typing Refs
Copy
import { useRef, forwardRef, useImperativeHandle } from 'react';
import { View, TextInput, Animated } from 'react-native';
// Ref to native component
function SearchInput() {
const inputRef = useRef<TextInput>(null);
const focusInput = () => {
inputRef.current?.focus();
};
return (
<View>
<TextInput ref={inputRef} placeholder="Search..." />
<Button title="Focus" onPress={focusInput} />
</View>
);
}
// Ref to Animated.Value
function FadeView() {
const opacity = useRef(new Animated.Value(0)).current;
const fadeIn = () => {
Animated.timing(opacity, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();
};
return (
<Animated.View style={{ opacity }}>
<Text>Fading content</Text>
</Animated.View>
);
}
// forwardRef with TypeScript
interface CustomInputProps {
label: string;
error?: string;
}
interface CustomInputRef {
focus: () => void;
blur: () => void;
clear: () => void;
}
const CustomInput = forwardRef<CustomInputRef, CustomInputProps>(
({ label, error }, ref) => {
const inputRef = useRef<TextInput>(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
clear: () => inputRef.current?.clear(),
}));
return (
<View>
<Text>{label}</Text>
<TextInput ref={inputRef} />
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
);
// Usage
function Form() {
const inputRef = useRef<CustomInputRef>(null);
return (
<View>
<CustomInput ref={inputRef} label="Email" />
<Button title="Focus" onPress={() => inputRef.current?.focus()} />
</View>
);
}
Typing Context
Copy
import { createContext, useContext, useState, ReactNode } from 'react';
// Define types
interface User {
id: string;
email: string;
name: string;
}
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (email: string, password: string, name: string) => Promise<void>;
}
// Create context with default value
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Provider component
interface AuthProviderProps {
children: ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
const login = async (email: string, password: string) => {
setIsLoading(true);
try {
// API call
const response = await authService.login(email, password);
setUser(response.user);
} finally {
setIsLoading(false);
}
};
const logout = async () => {
await authService.logout();
setUser(null);
};
const register = async (email: string, password: string, name: string) => {
setIsLoading(true);
try {
const response = await authService.register(email, password, name);
setUser(response.user);
} finally {
setIsLoading(false);
}
};
const value: AuthContextType = {
user,
isAuthenticated: !!user,
isLoading,
login,
logout,
register,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// Custom hook with type safety
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
// Usage
function ProfileScreen() {
const { user, logout, isLoading } = useAuth();
if (!user) return null;
return (
<View>
<Text>Welcome, {user.name}</Text>
<Button title="Logout" onPress={logout} disabled={isLoading} />
</View>
);
}
Typing Navigation
React Navigation Types
Copy
// src/shared/types/navigation.types.ts
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
import { CompositeScreenProps, NavigatorScreenParams } from '@react-navigation/native';
// Root Stack
export type RootStackParamList = {
Auth: NavigatorScreenParams<AuthStackParamList>;
Main: NavigatorScreenParams<MainTabParamList>;
Modal: { title: string; content: string };
ProductDetail: { productId: string };
};
// Auth Stack
export type AuthStackParamList = {
Login: undefined;
Register: undefined;
ForgotPassword: { email?: string };
};
// Main Tabs
export type MainTabParamList = {
Home: undefined;
Search: { query?: string };
Cart: undefined;
Profile: undefined;
};
// Screen props types
export type RootStackScreenProps<T extends keyof RootStackParamList> =
NativeStackScreenProps<RootStackParamList, T>;
export type AuthStackScreenProps<T extends keyof AuthStackParamList> =
CompositeScreenProps<
NativeStackScreenProps<AuthStackParamList, T>,
RootStackScreenProps<keyof RootStackParamList>
>;
export type MainTabScreenProps<T extends keyof MainTabParamList> =
CompositeScreenProps<
BottomTabScreenProps<MainTabParamList, T>,
RootStackScreenProps<keyof RootStackParamList>
>;
// Declare global types for useNavigation
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}
Using Navigation Types
Copy
// screens/LoginScreen.tsx
import { View, Text, Button } from 'react-native';
import type { AuthStackScreenProps } from '@/shared/types/navigation.types';
type Props = AuthStackScreenProps<'Login'>;
export function LoginScreen({ navigation, route }: Props) {
const handleLogin = () => {
// Navigate to main app
navigation.replace('Main', { screen: 'Home' });
};
const handleForgotPassword = () => {
// Navigate with params
navigation.navigate('ForgotPassword', { email: 'user@example.com' });
};
return (
<View>
<Text>Login Screen</Text>
<Button title="Login" onPress={handleLogin} />
<Button title="Forgot Password" onPress={handleForgotPassword} />
</View>
);
}
// screens/ProductDetailScreen.tsx
import type { RootStackScreenProps } from '@/shared/types/navigation.types';
type Props = RootStackScreenProps<'ProductDetail'>;
export function ProductDetailScreen({ navigation, route }: Props) {
const { productId } = route.params; // Type-safe params
return (
<View>
<Text>Product ID: {productId}</Text>
<Button title="Go Back" onPress={() => navigation.goBack()} />
</View>
);
}
useNavigation and useRoute Hooks
Copy
import { useNavigation, useRoute } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RouteProp } from '@react-navigation/native';
import type { RootStackParamList } from '@/shared/types/navigation.types';
// In a component
function ProductCard({ productId }: { productId: string }) {
// Type the navigation hook
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const handlePress = () => {
navigation.navigate('ProductDetail', { productId });
};
return (
<Pressable onPress={handlePress}>
<Text>View Product</Text>
</Pressable>
);
}
// Getting route params
function ProductDetailScreen() {
const route = useRoute<RouteProp<RootStackParamList, 'ProductDetail'>>();
const { productId } = route.params;
return <Text>Product: {productId}</Text>;
}
Typing API Responses
Copy
// src/shared/types/api.types.ts
// Generic API response wrapper
interface ApiResponse<T> {
data: T;
message: string;
success: boolean;
}
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
interface ApiError {
message: string;
code: string;
details?: Record<string, string[]>;
}
// Domain types
interface User {
id: string;
email: string;
name: string;
avatar?: string;
createdAt: string;
}
interface Product {
id: string;
name: string;
description: string;
price: number;
images: string[];
category: Category;
inStock: boolean;
}
interface Category {
id: string;
name: string;
slug: string;
}
// API service with types
const apiClient = {
async get<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
},
async post<T, D>(url: string, data: D): Promise<T> {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
},
};
// Usage
async function fetchProducts(): Promise<PaginatedResponse<Product>> {
return apiClient.get<PaginatedResponse<Product>>('/api/products');
}
async function createUser(data: { email: string; password: string; name: string }): Promise<ApiResponse<User>> {
return apiClient.post<ApiResponse<User>, typeof data>('/api/users', data);
}
Typing with React Query
Copy
import { useQuery, useMutation, UseQueryResult } from '@tanstack/react-query';
interface Product {
id: string;
name: string;
price: number;
}
interface CreateProductInput {
name: string;
price: number;
description: string;
}
// Typed query hook
function useProducts(): UseQueryResult<Product[], Error> {
return useQuery({
queryKey: ['products'],
queryFn: async () => {
const response = await fetch('/api/products');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
},
});
}
// Typed mutation hook
function useCreateProduct() {
return useMutation<Product, Error, CreateProductInput>({
mutationFn: async (input) => {
const response = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) throw new Error('Failed to create');
return response.json();
},
});
}
// Usage in component
function ProductList() {
const { data: products, isLoading, error } = useProducts();
const createProduct = useCreateProduct();
if (isLoading) return <Text>Loading...</Text>;
if (error) return <Text>Error: {error.message}</Text>;
return (
<View>
{products?.map((product) => (
<Text key={product.id}>{product.name}</Text>
))}
<Button
title="Add Product"
onPress={() => createProduct.mutate({ name: 'New', price: 99, description: 'Desc' })}
/>
</View>
);
}
Generic Components
Copy
import { FlatList, FlatListProps, Text, View } from 'react-native';
// Generic list component
interface GenericListProps<T> extends Omit<FlatListProps<T>, 'renderItem'> {
data: T[];
renderItem: (item: T, index: number) => JSX.Element;
keyExtractor: (item: T) => string;
emptyMessage?: string;
}
function GenericList<T>({
data,
renderItem,
keyExtractor,
emptyMessage = 'No items',
...props
}: GenericListProps<T>) {
if (data.length === 0) {
return (
<View style={styles.empty}>
<Text>{emptyMessage}</Text>
</View>
);
}
return (
<FlatList
data={data}
renderItem={({ item, index }) => renderItem(item, index)}
keyExtractor={keyExtractor}
{...props}
/>
);
}
// Usage
interface User {
id: string;
name: string;
}
function UserList() {
const users: User[] = [
{ id: '1', name: 'John' },
{ id: '2', name: 'Jane' },
];
return (
<GenericList<User>
data={users}
renderItem={(user) => <Text>{user.name}</Text>}
keyExtractor={(user) => user.id}
emptyMessage="No users found"
/>
);
}
Generic Select Component
Copy
interface SelectOption<T> {
label: string;
value: T;
}
interface SelectProps<T> {
options: SelectOption<T>[];
value: T | null;
onChange: (value: T) => void;
placeholder?: string;
}
function Select<T>({ options, value, onChange, placeholder }: SelectProps<T>) {
const selectedOption = options.find((opt) => opt.value === value);
return (
<View style={styles.select}>
<Text>{selectedOption?.label ?? placeholder ?? 'Select...'}</Text>
{options.map((option, index) => (
<Pressable
key={index}
onPress={() => onChange(option.value)}
style={styles.option}
>
<Text>{option.label}</Text>
</Pressable>
))}
</View>
);
}
// Usage with different types
type Status = 'pending' | 'active' | 'completed';
function StatusSelect() {
const [status, setStatus] = useState<Status | null>(null);
const options: SelectOption<Status>[] = [
{ label: 'Pending', value: 'pending' },
{ label: 'Active', value: 'active' },
{ label: 'Completed', value: 'completed' },
];
return (
<Select<Status>
options={options}
value={status}
onChange={setStatus}
placeholder="Select status"
/>
);
}
Utility Types
Copy
// Common utility types for React Native
// Make all properties optional
type PartialUser = Partial<User>;
// Make all properties required
type RequiredUser = Required<User>;
// Pick specific properties
type UserPreview = Pick<User, 'id' | 'name' | 'avatar'>;
// Omit specific properties
type UserWithoutPassword = Omit<User, 'password'>;
// Extract props from component
type ButtonProps = React.ComponentProps<typeof Button>;
// Extract style props
type ViewStyle = React.ComponentProps<typeof View>['style'];
// Readonly
type ReadonlyUser = Readonly<User>;
// Record type
type UserMap = Record<string, User>;
// Custom utility types
type Nullable<T> = T | null;
type Optional<T> = T | undefined;
type AsyncFunction<T> = () => Promise<T>;
// Discriminated unions
type LoadingState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function useAsyncData<T>(): LoadingState<T> {
// Implementation
}
// Usage
function DataDisplay() {
const state = useAsyncData<User[]>();
switch (state.status) {
case 'idle':
return <Text>Ready to load</Text>;
case 'loading':
return <ActivityIndicator />;
case 'success':
return <UserList users={state.data} />;
case 'error':
return <Text>Error: {state.error.message}</Text>;
}
}
Type Guards
Copy
// Type guards for runtime type checking
interface User {
type: 'user';
id: string;
name: string;
}
interface Admin {
type: 'admin';
id: string;
name: string;
permissions: string[];
}
type Account = User | Admin;
// Type guard function
function isAdmin(account: Account): account is Admin {
return account.type === 'admin';
}
// Usage
function AccountInfo({ account }: { account: Account }) {
if (isAdmin(account)) {
// TypeScript knows account is Admin here
return (
<View>
<Text>{account.name} (Admin)</Text>
<Text>Permissions: {account.permissions.join(', ')}</Text>
</View>
);
}
// TypeScript knows account is User here
return (
<View>
<Text>{account.name}</Text>
</View>
);
}
// Type guard for API responses
interface SuccessResponse<T> {
success: true;
data: T;
}
interface ErrorResponse {
success: false;
error: string;
}
type ApiResult<T> = SuccessResponse<T> | ErrorResponse;
function isSuccess<T>(result: ApiResult<T>): result is SuccessResponse<T> {
return result.success === true;
}
// Usage
async function fetchUser(id: string) {
const result = await api.get<ApiResult<User>>(`/users/${id}`);
if (isSuccess(result)) {
return result.data; // Type: User
} else {
throw new Error(result.error);
}
}
Best Practices
Enable Strict Mode
Always use
"strict": true in tsconfig.json for maximum type safetyAvoid 'any'
Use
unknown instead of any when type is truly unknownType Inference
Let TypeScript infer types when possible; explicit types when needed
Discriminated Unions
Use discriminated unions for state that can be in different shapes
Common Mistakes to Avoid
Copy
// ❌ Bad: Using 'any'
const handleData = (data: any) => { };
// ✅ Good: Use proper types or 'unknown'
const handleData = (data: unknown) => {
if (typeof data === 'string') {
// Now TypeScript knows data is string
}
};
// ❌ Bad: Non-null assertion without checking
const user = users.find(u => u.id === id)!;
// ✅ Good: Handle the undefined case
const user = users.find(u => u.id === id);
if (!user) {
throw new Error('User not found');
}
// ❌ Bad: Type assertion without validation
const data = response as User;
// ✅ Good: Validate the data
function isUser(data: unknown): data is User {
return (
typeof data === 'object' &&
data !== null &&
'id' in data &&
'name' in data
);
}
if (isUser(response)) {
// Now TypeScript knows response is User
}
Next Steps
Module 5: Core Components Deep Dive
Master React Native’s fundamental building blocks with TypeScript