Back to writing
TypeScriptApr 2026·8 min read

TypeScript Patterns Worth Knowing - Beyond the Basics

Discriminated unions, unknown vs never, generics in hooks, mapped types, and infer - the TypeScript patterns that appear in senior interviews and well-maintained codebases.

TypeScript's type system has a layer most developers never fully explore. These patterns appear in senior interviews, in well-maintained codebases, and in the type definitions of the libraries you use every day.

Discriminated Unions - State Machines Without a Library

The most underused TypeScript pattern in React apps. Instead of a boolean soup of isLoading, isError, hasData, model state as a union where each variant carries exactly the data that makes sense for it.

// The boolean soup (seen in every codebase)
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
// Can have impossible combinations: isLoading=true AND error=true?

// Discriminated union - only valid states exist
type State<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

const [state, setState] = useReducer(reducer, { status: 'idle' });

Now TypeScript knows exactly what's available in each case:

function render(state: State<User>) {
  switch (state.status) {
    case 'loading': return <Spinner />;
    case 'error':   return <Error message={state.error.message} />; // .error is safe
    case 'success': return <Profile user={state.data} />;          // .data is safe
    case 'idle':    return null;
  }
}
// TypeScript won't compile if you add a new status and forget to handle it

This is how TanStack Query models its internal state, how XState machines work, and how Redux Toolkit's createSlice patterns are built.


unknown vs any vs never - The Trinity

These three types represent completely different things and confusing them leads to either unsafe code or unmaintainable types.

// any: opt out of the type system entirely
const x: any = getResponse();
x.nonExistent.deeply.nested; // TypeScript says: fine! (lie)
// any is contagious - touching any often produces any

// unknown: I have a value but I don't know its type yet
const x: unknown = getResponse();
x.nonExistent; // Error: Object is of type 'unknown'
if (typeof x === 'string') x.toUpperCase(); // narrowed - now safe

// never: this code path cannot be reached
function assertNever(x: never): never {
  throw new Error('Unhandled case: ' + x);
}

The never pattern is the key to exhaustive switch statements:

type Direction = 'north' | 'south' | 'east';

function move(d: Direction) {
  switch (d) {
    case 'north': return goNorth();
    case 'south': return goSouth();
    // Forgot 'east'! TypeScript will error at assertNever:
    default: assertNever(d); // Argument of type 'string' is not assignable to 'never'
  }
}

Add 'west' to the union later and TypeScript tells you everywhere you forgot to handle it.


Utility Types - The Ones Worth Knowing

These are built into TypeScript and should replace manual type declarations in most cases:

type User = { id: string; name: string; email: string; role: 'admin' | 'user' };

// Building from existing types:
type UserPreview = Pick<User, 'id' | 'name'>;    // { id, name }
type UserWithoutRole = Omit<User, 'role'>;        // { id, name, email }
type PartialUser = Partial<User>;                 // all optional
type ReadonlyUser = Readonly<User>;               // all readonly
type UserMap = Record<string, User>;              // { [key: string]: User }

The ones that show up in React specifically:

import { ComponentProps, ComponentPropsWithoutRef } from 'react';

// Extract a component's props without importing the type manually
type ButtonProps = ComponentProps<typeof Button>;

// Extend native element props
interface InputProps extends ComponentPropsWithoutRef<'input'> {
  label: string;
  error?: string;
}
// Now Input accepts all native input attributes plus label and error

// Infer the return type of a function
type FetchResult = Awaited<ReturnType<typeof fetchUser>>;
// Unwraps the Promise automatically

Generics in Custom Hooks

Generics let you write hooks once and have TypeScript infer the types automatically at the call site.

// Without generics: forces any, loses type safety
function useLocalStorage(key: string, init: any): [any, (v: any) => void]

// With generics: fully typed based on the initial value
function useLocalStorage<T>(key: string, init: T): [T, (v: T) => void] {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : init;
  });

  const set = useCallback((v: T) => {
    setValue(v);
    localStorage.setItem(key, JSON.stringify(v));
  }, [key]);

  return [value, set];
}

// TypeScript infers T = { theme: string; lang: string } automatically:
const [prefs, setPrefs] = useLocalStorage('prefs', { theme: 'dark', lang: 'en' });
setPrefs({ theme: 'light', lang: 'pl' }); // typed correctly
setPrefs({ theme: 42 }); // Error: Type 'number' is not assignable to type 'string'

interface vs type - The Actual Difference

This is a common interview question and most answers are incomplete.

// Interface: can be extended and merged
interface User { name: string; }
interface User { age: number; } // Declaration merging - both now part of User

interface Admin extends User { role: 'admin'; }

// Type: can model unions, intersections, and computed types
type ID = string | number;                    // union
type Admin = User & { role: 'admin' };        // intersection
type Nullable<T> = T | null;                  // generic utility
type Keys = keyof User;                       // computed
type NameType = User['name'];                 // indexed access

The practical guidance:

  • Use interface for object shapes that represent domain entities (User, Product, Config)
  • Use type for unions, intersections, computed/derived types, and function types
  • In React component files, interface for props, type for unions and state shapes

Template Literal Types and Mapped Types

These unlock type-level string manipulation - useful for typed event systems, CSS-in-JS, and API route patterns.

// Template literal types
type Direction = 'north' | 'south' | 'east' | 'west';
type MoveEvent = `on${Capitalize<Direction>}`;
// 'onNorth' | 'onSouth' | 'onEast' | 'onWest'

type CSSProperty = 'margin' | 'padding';
type Sides = 'Top' | 'Right' | 'Bottom' | 'Left';
type SpacingProperty = `${CSSProperty}${Sides}`;
// 'marginTop' | 'marginRight' | 'paddingTop' | ... (8 combinations auto-generated)

// Mapped types - transform every key in a type
type Optional<T> = { [K in keyof T]?: T[K] }; // same as Partial<T>
type Nullable<T> = { [K in keyof T]: T[K] | null };
type Readonly<T> = { readonly [K in keyof T]: T[K] };

// Filtering keys by value type
type StringKeys<T> = {
  [K in keyof T]: T[K] extends string ? K : never;
}[keyof T];

type User = { id: string; name: string; age: number; active: boolean };
type StringFields = StringKeys<User>; // 'id' | 'name'

The infer Keyword - Reading Types From Within Types

infer lets you extract a type from a pattern match. It's how the built-in utility types are implemented.

// Unwrap a Promise to get the resolved type
type Awaited<T> = T extends Promise<infer U> ? U : T;
type R = Awaited<Promise<string>>; // string
type R2 = Awaited<string>;         // string (not a Promise, returns as-is)

// Extract the first element's type from a tuple
type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;
type F = First<[string, number, boolean]>; // string

// Extract parameter types
type Params<T extends (...args: any[]) => any> =
  T extends (...args: infer P) => any ? P : never;
type P = Params<(a: string, b: number) => void>; // [string, number]

You'll see infer used in library types (React's ComponentPropsWithRef, TanStack Query's QueryObserverResult) and in any codebase that builds generic utilities over async functions or tuples.


The Interview Pattern That Combines Everything

// Build a type-safe event emitter
type Events = {
  login: { userId: string; timestamp: number };
  logout: { userId: string };
  error: { message: string; code: number };
};

type EventName = keyof Events;
type EventPayload<E extends EventName> = Events[E];

function emit<E extends EventName>(event: E, payload: EventPayload<E>): void {
  // dispatch the event
}

function on<E extends EventName>(event: E, handler: (payload: EventPayload<E>) => void): void {
  // subscribe
}

// TypeScript enforces correct payloads at the call site:
emit('login', { userId: '123', timestamp: Date.now() }); // correct
emit('login', { message: 'oops' }); // Error: Property 'userId' is missing

on('error', payload => {
  console.log(payload.code); // typed as number
});

This pattern - generic functions constrained by a mapping type - appears in event systems, API clients, Redux action creators, and any system where the payload type depends on a string key.