Skip to main content
TypeScript in React Native

Module Overview

Estimated Time: 3 hours | Difficulty: Intermediate | Prerequisites: Basic TypeScript knowledge
TypeScript is essential for building maintainable React Native applications. This module covers TypeScript patterns specific to React Native, from basic component typing to advanced patterns used in production apps. What You’ll Learn:
  • 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

# 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

# Install TypeScript and types
npm install -D typescript @types/react @types/react-native

# Create tsconfig.json
npx tsc --init

TypeScript Configuration

// 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

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

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

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

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

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

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

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

// 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

// 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

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

// 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

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

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

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

// 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

// 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 safety

Avoid 'any'

Use unknown instead of any when type is truly unknown

Type 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

// ❌ 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