UTA DevHub

TypeScript Patterns

Type-safe component development patterns and advanced TypeScript techniques

TypeScript Patterns

Overview

TypeScript provides powerful type safety for React Native components. This guide covers essential patterns for building type-safe, maintainable components with comprehensive TypeScript support.

TypeScript Benefits

Using TypeScript for components provides:

  • Compile-time safety catching errors before runtime
  • Better IDE support with autocomplete and refactoring
  • Self-documenting code through type definitions
  • Easier refactoring with confidence

Props Interface Patterns

Basic Props Pattern

Start with well-defined interfaces for all component props:

// types.ts
import type { ViewStyle, TextStyle, StyleProp } from 'react-native';
 
// Base props for common attributes
interface BaseComponentProps {
  testID?: string;
  accessible?: boolean;
  accessibilityLabel?: string;
  accessibilityHint?: string;
  accessibilityRole?: string;
}
 
// Specific component props extending base
export interface ButtonProps extends BaseComponentProps {
  // Required props
  title: string;
  onPress: () => void;
  
  // Optional props with defaults
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
  size?: 'small' | 'medium' | 'large';
  disabled?: boolean;
  loading?: boolean;
  
  // Style overrides
  style?: StyleProp<ViewStyle>;
  textStyle?: StyleProp<TextStyle>;
  
  // Additional content
  icon?: React.ReactNode;
  iconPosition?: 'left' | 'right';
}

Props with Children

Handle different children patterns:

// Single child required
interface SingleChildProps {
  children: React.ReactElement;
}
 
// Multiple children
interface MultipleChildrenProps {
  children: React.ReactNode;
}
 
// Typed children
interface TypedChildrenProps {
  children: React.ReactElement<ChildComponentProps> | React.ReactElement<ChildComponentProps>[];
}
 
// Function as children (render prop)
interface RenderChildrenProps<T> {
  children: (data: T) => React.ReactNode;
}

Event Handler Props

Type event handlers properly:

interface InteractiveComponentProps {
  // Simple event
  onPress?: () => void;
  
  // Event with parameters
  onChange?: (value: string) => void;
  
  // Event with event object
  onLayout?: (event: LayoutChangeEvent) => void;
  
  // Async event handler
  onSubmit?: (data: FormData) => Promise<void>;
  
  // Event that can prevent default
  onClose?: () => boolean | void;
}

Generic Component Patterns

Basic Generic Component

Create reusable components that work with any data type:

// ui/List/List.tsx
interface ListProps<T> {
  data: T[];
  renderItem: (item: T, index: number) => React.ReactElement;
  keyExtractor: (item: T, index: number) => string;
  ItemSeparatorComponent?: React.ComponentType;
  ListEmptyComponent?: React.ComponentType;
  onItemPress?: (item: T) => void;
}
 
export function List<T>({
  data,
  renderItem,
  keyExtractor,
  ItemSeparatorComponent,
  ListEmptyComponent,
  onItemPress,
}: ListProps<T>) {
  if (data.length === 0 && ListEmptyComponent) {
    return <ListEmptyComponent />;
  }
 
  return (
    <FlatList
      data={data}
      renderItem={({ item, index }) => (
        <TouchableOpacity 
          onPress={() => onItemPress?.(item)}
          disabled={!onItemPress}
        >
          {renderItem(item, index)}
        </TouchableOpacity>
      )}
      keyExtractor={keyExtractor}
      ItemSeparatorComponent={ItemSeparatorComponent}
    />
  );
}
 
// Usage with type inference
<List<Product>
  data={products}
  renderItem={(product) => <ProductCard product={product} />}
  keyExtractor={(product) => product.id}
  onItemPress={(product) => navigateToProduct(product.id)}
/>

Constrained Generics

Add constraints to generic types:

// Ensure items have an id property
interface Identifiable {
  id: string;
}
 
interface SelectableListProps<T extends Identifiable> {
  items: T[];
  selectedIds: string[];
  onSelectionChange: (ids: string[]) => void;
  renderItem: (item: T, isSelected: boolean) => React.ReactElement;
}
 
export function SelectableList<T extends Identifiable>({
  items,
  selectedIds,
  onSelectionChange,
  renderItem,
}: SelectableListProps<T>) {
  const toggleSelection = (id: string) => {
    const newSelection = selectedIds.includes(id)
      ? selectedIds.filter(selectedId => selectedId !== id)
      : [...selectedIds, id];
    
    onSelectionChange(newSelection);
  };
 
  return (
    <FlatList
      data={items}
      renderItem={({ item }) => (
        <TouchableOpacity onPress={() => toggleSelection(item.id)}>
          {renderItem(item, selectedIds.includes(item.id))}
        </TouchableOpacity>
      )}
      keyExtractor={(item) => item.id}
    />
  );
}

Discriminated Union Props

Use discriminated unions for components with multiple modes:

// ui/Message/types.ts
type BaseMessageProps = {
  title: string;
  description?: string;
};
 
type MessageProps = BaseMessageProps & (
  | { type: 'success'; onDismiss?: () => void }
  | { type: 'error'; onRetry?: () => void; retryLabel?: string }
  | { type: 'warning'; action?: { label: string; onPress: () => void } }
  | { type: 'info' }
);
 
// ui/Message/Message.tsx
export const Message: React.FC<MessageProps> = (props) => {
  const renderAction = () => {
    switch (props.type) {
      case 'success':
        return props.onDismiss ? (
          <IconButton icon="close" onPress={props.onDismiss} />
        ) : null;
      
      case 'error':
        return props.onRetry ? (
          <Button 
            title={props.retryLabel || 'Retry'} 
            onPress={props.onRetry}
            size="small"
          />
        ) : null;
      
      case 'warning':
        return props.action ? (
          <Button 
            title={props.action.label} 
            onPress={props.action.onPress}
            size="small"
            variant="outline"
          />
        ) : null;
      
      case 'info':
        return null;
    }
  };
 
  return (
    <View style={[styles.container, styles[props.type]]}>
      <View style={styles.content}>
        <Text style={styles.title}>{props.title}</Text>
        {props.description && (
          <Text style={styles.description}>{props.description}</Text>
        )}
      </View>
      {renderAction()}
    </View>
  );
};

Advanced Type Patterns

Conditional Types

Use conditional types for flexible APIs:

// Props that change based on a condition
type ConditionalProps<T extends boolean> = {
  multiSelect: T;
  value: T extends true ? string[] : string;
  onChange: (value: T extends true ? string[] : string) => void;
};
 
interface SelectProps<T extends boolean = false> extends ConditionalProps<T> {
  options: Array<{ label: string; value: string }>;
  placeholder?: string;
}
 
// Usage
<Select
  multiSelect={false}
  value="option1"
  onChange={(value) => console.log(value)} // value is string
  options={options}
/>
 
<Select
  multiSelect={true}
  value={["option1", "option2"]}
  onChange={(values) => console.log(values)} // values is string[]
  options={options}
/>

Mapped Types

Create variations of existing types:

// Make all properties optional except specific ones
type PartialExcept<T, K extends keyof T> = Partial<T> & Pick<T, K>;
 
interface FormFieldProps {
  name: string;
  label: string;
  value: string;
  error?: string;
  required?: boolean;
  disabled?: boolean;
}
 
// Label and name are required, others optional
type MinimalFormFieldProps = PartialExcept<FormFieldProps, 'name' | 'label'>;
 
// Create readonly version
type ReadonlyFormFieldProps = Readonly<FormFieldProps>;
 
// Deep partial for nested objects
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

Template Literal Types

Use template literals for dynamic prop names:

type Spacing = 'small' | 'medium' | 'large';
type Side = 'top' | 'right' | 'bottom' | 'left';
 
// Generate margin/padding props
type SpacingProps = {
  [K in `margin${Capitalize<Side>}`]?: Spacing;
} & {
  [K in `padding${Capitalize<Side>}`]?: Spacing;
};
 
interface BoxProps extends SpacingProps {
  children: React.ReactNode;
}
 
// Usage provides autocomplete for all spacing props
<Box
  marginTop="small"
  paddingLeft="medium"
  paddingRight="medium"
>
  {content}
</Box>

Type Guards and Assertions

Custom Type Guards

Create type guards for runtime type checking:

// Type guard functions
function isErrorResponse(response: ApiResponse): response is ErrorResponse {
  return 'error' in response && response.error !== undefined;
}
 
function isLoadingState<T>(state: AsyncState<T>): state is LoadingState {
  return state.status === 'loading';
}
 
// Using type guards
const handleResponse = (response: ApiResponse) => {
  if (isErrorResponse(response)) {
    // TypeScript knows response.error exists
    showError(response.error.message);
  } else {
    // TypeScript knows response.data exists
    processData(response.data);
  }
};

Assertion Functions

Create assertion functions for type narrowing:

function assertDefined<T>(value: T | null | undefined, message?: string): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error(message || 'Value is not defined');
  }
}
 
function assertType<T>(value: unknown, check: (value: unknown) => value is T): asserts value is T {
  if (!check(value)) {
    throw new Error('Type assertion failed');
  }
}
 
// Usage
const processUser = (user: User | null) => {
  assertDefined(user, 'User must be logged in');
  // TypeScript now knows user is User, not null
  console.log(user.name);
};

Utility Types

Component Props Utilities

Extract and manipulate component props:

// Extract props from a component
type ButtonComponentProps = React.ComponentProps<typeof Button>;
 
// Get ref type for a component
type ButtonRef = React.ElementRef<typeof Button>;
 
// Omit specific props
type ButtonWithoutOnPress = Omit<ButtonProps, 'onPress'>;
 
// Make specific props required
type RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
type ButtonWithRequiredTestID = RequiredFields<ButtonProps, 'testID'>;
 
// Props with default values
interface PropsWithDefaults<T> {
  props: T;
  defaults: Partial<T>;
}

HOC Type Patterns

Type higher-order components correctly:

// HOC that adds props
interface WithThemeProps {
  theme: Theme;
}
 
function withTheme<P extends object>(
  Component: React.ComponentType<P & WithThemeProps>
): React.ComponentType<Omit<P, keyof WithThemeProps>> {
  return (props: Omit<P, keyof WithThemeProps>) => {
    const theme = useTheme();
    return <Component {...(props as P)} theme={theme} />;
  };
}
 
// HOC that modifies props
interface WithLoadingProps {
  loading?: boolean;
}
 
function withLoading<P extends object>(
  Component: React.ComponentType<P>
): React.ComponentType<P & WithLoadingProps> {
  return ({ loading, ...props }: P & WithLoadingProps) => {
    if (loading) {
      return <LoadingSpinner />;
    }
    return <Component {...(props as P)} />;
  };
}

Best Practices

TypeScript Best Practices

  1. Prefer interfaces over types for object shapes
  2. Use const assertions for literal types
  3. Export types alongside components
  4. Avoid any - use unknown if type is truly unknown
  5. Enable strict mode in tsconfig.json
  6. Document complex types with JSDoc comments

Type Organization

// ❌ Avoid - Types mixed with implementation
const Button = ({ title, onPress }: { title: string; onPress: () => void }) => {
  // ...
};
 
// ✅ Good - Separate type definitions
interface ButtonProps {
  title: string;
  onPress: () => void;
}
 
const Button: React.FC<ButtonProps> = ({ title, onPress }) => {
  // ...
};
 
// ✅ Better - Types in separate file
// types.ts
export interface ButtonProps {
  title: string;
  onPress: () => void;
}
 
// Button.tsx
import type { ButtonProps } from './types';
 
export const Button: React.FC<ButtonProps> = ({ title, onPress }) => {
  // ...
};

Const Assertions

Use const assertions for literal types:

// Without const assertion
const SIZES = {
  small: 8,
  medium: 16,
  large: 24,
}; // Type: { small: number; medium: number; large: number; }
 
// With const assertion
const SIZES = {
  small: 8,
  medium: 16,
  large: 24,
} as const; // Type: { readonly small: 8; readonly medium: 16; readonly large: 24; }
 
type Size = keyof typeof SIZES; // 'small' | 'medium' | 'large'
type SizeValue = typeof SIZES[Size]; // 8 | 16 | 24

Summary

TypeScript patterns for components ensure:

  • Type safety throughout the component tree
  • Better developer experience with autocomplete
  • Fewer runtime errors through compile-time checks
  • Self-documenting code through type definitions
  • Easier refactoring with confidence

Use these patterns to build robust, maintainable React Native components.

Next Steps