No history yet

Advanced TypeScript Patterns

Eliminate Impossible States

In large applications, managing asynchronous data flows often leads to state objects that permit invalid combinations. For example, a component might fetch data and need to track loading, success, and error states. A naive approach might look like this:

interface DataState {
  isLoading: boolean;
  data?: User[];
  error?: Error;
}

This interface allows for nonsensical states, such as isLoading: true while also having data populated, or having both data and an error at the same time. These are impossible states that your UI logic must defensively check against, leading to bloated and error-prone code.

A more robust pattern is the discriminated union. By using a literal type as a discriminant (commonly named status or type), you can create a set of mutually exclusive states. This ensures that the data associated with each state is only available when that state is active.

type LoadingState = { status: 'loading' };
type SuccessState<T> = { status: 'success'; data: T };
type ErrorState = { status: 'error'; error: Error };

type DataState<T> = LoadingState | SuccessState<T> | ErrorState;

function handleState(state: DataState<User[]>) {
  switch (state.status) {
    case 'loading':
      // state has no 'data' or 'error' property here
      console.log('Loading...');
      break;
    case 'success':
      // state.data is guaranteed to be User[]
      console.log(state.data);
      break;
    case 'error':
      // state.error is guaranteed to be an Error
      console.error(state.error.message);
      break;
    default:
      // TypeScript's exhaustiveness check ensures all cases are handled
      const _exhaustiveCheck: never = state;
      return _exhaustiveCheck;
  }
}

Using a discriminated union makes invalid states unrepresentable in the type system, which is a core tenet of writing safe and maintainable TypeScript.

Dynamic Event Keys

When building systems that rely on events, such as a Redux-style state management library or a pub/sub model, you often need to define action types. A common pattern is to create string constants, but this can be brittle. Template literal types allow for the creation of precise, dynamic, and safe event keys.

Imagine you have different domains in your application (user, product, order) and common actions (create, update, delete). You can combine these programmatically to generate a union of all possible event types.

type Domain = 'user' | 'product' | 'order';
type Action = 'create' | 'update' | 'delete';

type EventType = `${Domain}:${Action}`;
// EventType is now 'user:create' | 'user:update' | 'user:delete' | 'product:create' | ...

function dispatch(event: EventType) {
  // ...
}

dispatch('user:create'); // OK
dispatch('product:delete'); // OK
// dispatch('user:fetch'); // Error: Argument of type '"user:fetch"' is not assignable to parameter of type 'EventType'.

This technique provides strong compile-time guarantees, preventing typos and ensuring that only valid event types are used throughout your application. It couples the structure of your domain logic directly to the type system.

Advanced Generics and Configuration

For creating highly reusable and type-safe abstractions, like a state machine or a data fetching hook, advanced generics are indispensable. By using generic constraints, you can build functions that operate on a variety of data structures while enforcing a specific shape.

Here’s a simplified state machine creator. Notice the use of extends to constrain the generic types S and E, ensuring any configuration passed to the function adheres to a specific structure.

type StateMachineConfig<S extends string, E extends string> = {
  initial: S;
  states: {
    [key in S]: {
      on?: {
        [key in E]?: S;
      };
    };
  };
};

function createMachine<S extends string, E extends string>(
  config: StateMachineConfig<S, E>
) {
  // ... implementation details
  return { /* machine instance */ };
}

While this works, TypeScript often widens the type of the configuration object passed to createMachine, losing the specific string literals. For example, initial: 'idle' would be inferred as initial: string. This prevents us from doing more advanced type-level operations, like validating that the initial state actually exists in the states object.

The satisfies operator solves this. It validates that an expression matches a type without changing the expression's own type. This allows for both strong type-checking and precise type inference.

const config = {
  initial: 'idle',
  states: {
    idle: {
      on: { FETCH: 'loading' },
    },
    loading: {
      on: {
        SUCCESS: 'success',
        FAILURE: 'error',
      },
    },
    success: {},
    error: {
      on: { RETRY: 'loading' },
    },
  },
} satisfies StateMachineConfig<string, string>;

// TypeScript knows config.initial is 'idle', not just string.
const machine = createMachine(config);

Using TypeScript with React provides several advantages in application-building, including the option of simpler React components and better JavaScript XML (JSX) support for static type validation.

Lastly, ensure immutability in your state updates. Utility types like Readonly<T> and Readonly<Record<K, V>> are essential. For nested objects, you might need a deep readonly type.

type DeepReadonly<T> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>;
};

interface State {
  user: {
    id: number;
    name: string;
  };
  posts: string[];
}

const initialState: DeepReadonly<State> = {
  user: { id: 1, name: 'Alice' },
  posts: ['First post'],
};

// initialState.user.name = 'Bob'; // Error: Cannot assign to 'name' because it is a read-only property.

These advanced patterns form the backbone of robust, enterprise-scale React applications, leveraging the compiler to prevent entire classes of bugs.