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

  1. Use strict mode: Enable all strict checks
  2. Avoid any: Use unknown instead when type is truly unknown
  3. Leverage inference: Let TypeScript infer types when possible
  4. Use utility types: Don't reinvent the wheel
  5. Type at boundaries: API responses, user input, etc.
  6. Document complex types: Add comments for non-obvious types
  7. 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.