TypeScript Typing Deep Dive

Introduction

This article is a collection of typing patterns to solve real problems. Some of this is theoretical but grounded in practical application. All of it is aimed at making you able to express your data structures precisely in types, so the compiler catches errors before you deploy them.

Generics: The Foundation of Reusable Typing

Generics are how you write types that work with multiple types while preserving type information. Most developers understand the basics — Array<T>, Promise<T> — but miss the deeper patterns that make generics powerful.

The basic generic: type parameters

A generic is a placeholder for a type. You create one by adding a type parameter in angle brackets:

// A simple generic function
function identity<T>(value: T): T {
  return value;
}

const num = identity(42); // T is inferred as number
const str = identity('hello'); // T is inferred as string

// A generic interface
interface Container<T> {
  value: T;
  getValue(): T;
}

const numContainer: Container<number> = {
  value: 42,
  getValue() {
    return this.value;
  },
};

When you call identity(42), TypeScript infers that T is number. When you write Container<string>, you explicitly say T is string. This is type parameter inference and explicit type arguments — the two ways you use generics.

Constrained generics: limiting what types can be passed

Sometimes you need a generic that only works with certain types. Use extends:

// T must be an object with a 'length' property
function getLength<T extends { length: number }>(value: T): number {
  return value.length;
}

getLength('hello'); // Works — string has length
getLength([1, 2, 3]); // Works — array has length
getLength(42); // Error — number has no length property

// T must be an element in an array
function getArrayItem<T, K extends keyof T[]>(arr: T[], index: K): T {
  return arr[index as number];
}

// More practically — T must be a record-like object
interface Entity {
  id: number;
  name: string;
}

function saveEntity<T extends Entity>(entity: T): void {
  console.log(`Saving ${entity.name} with ID ${entity.id}`);
}

saveEntity({ id: 1, name: 'Alice', role: 'admin' }); // Works — has id and name
saveEntity({ id: 1 }); // Error — missing name

Constraints are how you express “this generic function works with any type as long as it has these properties” or “this works with any subtype of this base type”.

Multiple type parameters and their relationships

When you need multiple generics, you can make them work together:

// A function that transforms one type to another
function transform<TInput, TOutput>(
  input: TInput,
  transformer: (value: TInput) => TOutput
): TOutput {
  return transformer(input);
}

const result = transform(42, (n) => n * 2); // TInput = number, TOutput = number
const result2 = transform('hello', (s) => s.length); // TInput = string, TOutput = number

// A generic map that preserves key-value type relationships
function mapValues<T, U>(
  obj: Record<string, T>,
  mapper: (value: T) => U
): Record<string, U> {
  const result: Record<string, U> = {};
  for (const key in obj) {
    result[key] = mapper(obj[key]);
  }
  return result;
}

const numbers = { a: 1, b: 2, c: 3 };
const doubled = mapValues(numbers, (n) => n * 2); // doubled: Record<string, number>

The consistency between input and output is the key. If the transformer takes TInput, the result is TOutput. The relationship is explicit.

Generic defaults and variance

Type parameters can have defaults — if not specified, they fall back to a type:

// apiCall has a generic response type, defaults to unknown if not specified
async function apiCall<T = unknown>(
  url: string,
  options?: RequestInit
): Promise<T> {
  const response = await fetch(url, options);
  return response.json();
}

const data = await apiCall('/api/users'); // T defaults to unknown
const users = await apiCall<User[]>('/api/users'); // T explicitly set to User[]

This is useful for library code where most callers don’t care about specificity but some do.

The keyof constraint: type-safe property access

One of the most useful constraints is keyof, which ensures type safety when accessing object properties:

// Without keyof — whoops, typo possible
function getProperty(obj: { name: string; age: number }, key: string): unknown {
  return obj[key]; // Could be anything, key could be wrong
}

// With keyof — only valid keys allowed
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const person = { name: 'Alice', age: 30 };
const name = getProperty(person, 'name'); // name is string (T[K] where K is 'name')
const age = getProperty(person, 'age'); // age is number (T[K] where K is 'age')
const email = getProperty(person, 'email'); // Error — 'email' is not a key of person

This pattern is crucial for any function that takes a key and returns the value at that key with the correct type.

Utility Types: The TypeScript Standard Library

TypeScript comes with utility types that transform types. They’re not magic — they’re built from generics and the language features you’re learning — but they’re worth understanding deeply.

Pick and Omit: selecting or excluding properties

interface User {
  id: number;
  email: string;
  name: string;
  password: string;
  createdAt: Date;
  isAdmin: boolean;
}

// Pick selects specific properties
type PublicUser = Pick<User, 'id' | 'email' | 'name'>; // { id, email, name }

// Omit excludes specific properties — the inverse
type UserWithoutPassword = Omit<User, 'password'>; // everything except password

// Practical use — response DTOs
type UserResponse = Pick<User, 'id' | 'email' | 'name' | 'createdAt'>;

// API handlers have the right type without repeating fields
function getCurrentUser(): UserResponse {
  // TypeScript ensures you only return these fields
}
  • Pick is useful when you want to be explicit about what’s included.
  • Omit is useful when you want to exclude one or two fields from an existing type. They’re opposites.

Partial: making all properties optional

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
}

// For update endpoints — any property can be provided, all are optional
type UpdateProduct = Partial<Product>;

// Equivalent to:
type UpdateProduct = {
  id?: number;
  name?: string;
  price?: number;
  description?: string;
};

function updateProduct(id: number, updates: Partial<Product>): void {
  // You can update just the price, or just the name, or all of them
}

updateProduct(1, { price: 29.99 }); // Valid
updateProduct(1, { name: 'New Name', price: 39.99 }); // Valid

Partial is the most common utility type because API update endpoints rarely require all fields.

Required: the opposite of Partial

interface Config {
  host?: string;
  port?: number;
  auth?: { username: string; password: string };
}

// For validation — all must be present
type ValidatedConfig = Required<Config>;

function validateConfig(config: Config): asserts config is ValidatedConfig {
  if (!config.host) throw new Error('host is required');
  if (!config.port) throw new Error('port is required');
  if (!config.auth) throw new Error('auth is required');
}

const config: Config = {
  host: 'localhost',
  port: 3000,
  auth: { username: 'admin', password: 'secret' },
};

validateConfig(config);
// After this line, TypeScript knows config is ValidatedConfig — no optionals

The asserts config is ValidatedConfig syntax is a type predicate. After the validation function, TypeScript narrows the type.

Record: creating object types with specific keys

// Create an object with specific keys, all with the same value type
type Permissions = 'read' | 'write' | 'delete';
type RolePermissions = Record<Permissions, boolean>;

// Equivalent to:
type RolePermissions = {
  read: boolean;
  write: boolean;
  delete: boolean;
};

const adminRole: RolePermissions = {
  read: true,
  write: true,
  delete: true,
};

// More complex — enum-like behavior with values
const STATUS_MESSAGES: Record<'pending' | 'success' | 'error', string> = {
  pending: 'Please wait...',
  success: 'Done!',
  error: 'Something went wrong.',
};

// With union of string literals — ensure all keys are covered
type Color = 'red' | 'green' | 'blue';
const colorCodes: Record<Color, string> = {
  red: '#FF0000',
  green: '#00FF00',
  blue: '#0000FF',
  // If you forget one, TypeScript errors
};

Record is invaluable for creating enums with complex values or ensuring all cases of a union are handled.

Exclude and Extract: filtering union types

type Status = 'pending' | 'success' | 'error' | 'loading';

// Exclude removes types from a union
type TerminalStatus = Exclude<Status, 'loading' | 'pending'>;
// Equivalent to: 'success' | 'error'

// Extract keeps only matching types
type LoadingStatus = Extract<Status, 'loading' | 'pending'>;
// Equivalent to: 'loading' | 'pending'

// Practical — handle failures only, not successes
type ErrorStatus = Exclude<Status, 'success'>;

function handleError(status: ErrorStatus): void {
  // status is 'pending' | 'error' | 'loading'
  // Not 'success'
}

These are less common than Pick/Omit but critical when you’re filtering unions.

ReturnType and Parameters: extracting function signatures

function createUser(name: string, email: string): { id: number; name: string } {
  return { id: 1, name };
}

// Extract the return type of a function
type CreateUserReturn = ReturnType<typeof createUser>;
// Equivalent to: { id: number; name: string }

// Extract the parameter types
type CreateUserParams = Parameters<typeof createUser>;
// Equivalent to: [name: string, email: string]

// Practical — creating a mock that has the same signature
const mockCreateUser: typeof createUser = (name, email) => {
  return { id: Math.random(), name };
};

// Also works with function types
type QueryFn = (query: string, limit: number) => Promise<Result[]>;
type QueryReturn = ReturnType<QueryFn>; // Promise<Result[]>

This is useful in testing and in creating wrapper functions that maintain the original function’s signature without repeating it.

Discriminated Unions: Type-Safe Pattern Matching

A discriminated union (also called a tagged union or sum type) is when you have multiple possible shape of an object, distinguished by a common property. It’s one of the most powerful patterns in TypeScript.

// Without discrimination — ambiguous
type Response = {
  status: string;
  data?: unknown;
  error?: string;
};

// With discrimination — clear and type-safe
type SuccessResponse = {
  status: 'success';
  data: { id: number; name: string };
};

type ErrorResponse = {
  status: 'error';
  error: string;
};

type LoadingResponse = {
  status: 'loading';
};

type Response = SuccessResponse | ErrorResponse | LoadingResponse;

// TypeScript narrows the type based on the status field
function handleResponse(response: Response): void {
  if (response.status === 'success') {
    console.log(response.data.id); // response.data is known to exist
  } else if (response.status === 'error') {
    console.log(response.error); // response.error is known to exist
  } else if (response.status === 'loading') {
    // response.status is 'loading'
    // response.data and response.error don't exist
  }
}

The status field is the discriminant. TypeScript sees you narrowing on it and refines the type of the whole object.

More complex discriminated unions

// Events in a system — each type of event has different payload
type UserEvent =
  | { type: 'user.created'; userId: number; email: string }
  | { type: 'user.updated'; userId: number; changes: Partial<User> }
  | { type: 'user.deleted'; userId: number }
  | { type: 'user.verified'; userId: number };

function handleUserEvent(event: UserEvent): void {
  switch (event.type) {
    case 'user.created': {
      const { userId, email } = event; // type is narrowed
      sendWelcomeEmail(email);
      break;
    }
    case 'user.updated': {
      const { userId, changes } = event;
      logUpdateEvent(userId, changes);
      break;
    }
    case 'user.deleted': {
      const { userId } = event;
      cleanupUser(userId);
      break;
    }
    case 'user.verified': {
      const { userId } = event;
      unlockPremiumFeatures(userId);
      break;
    }
    // If you miss a case, TypeScript errors — exhaustiveness checking
  }
}

The switch statement with discriminated unions gives you exhaustiveness checking. Miss a case, get a compile error.

Exhaustiveness checking with never

// A function that asserts all cases are handled
function assertNever(x: never): never {
  throw new Error(`Unhandled case: ${x}`);
}

function handleEvent(event: UserEvent): void {
  switch (event.type) {
    case 'user.created':
      // ... handle
      break;
    case 'user.updated':
      // ... handle
      break;
    case 'user.deleted':
      // ... handle
      break;
    // Missing 'user.verified'!
    default:
      assertNever(event); // Error: Argument of type 'UserEvent' is not assignable to 'never'
  }
}

This pattern catches missing cases. If event.type could be 'user.verified', it’s not never and the call to assertNever fails.

Mapped Types: Transforming Types Programmatically

Mapped types let you create new types by transforming properties of existing types. They’re built from keyof, in, and generic constraints.

// Create a type where every property is readonly
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};

interface User {
  name: string;
  age: number;
}

type ReadonlyUser = Readonly<User>;
// Equivalent to: { readonly name: string; readonly age: number }

// Create a type where every property is optional
type Optional<T> = {
  [K in keyof T]?: T[K];
};

type OptionalUser = Optional<User>;
// Equivalent to: { name?: string; age?: number }

// Create a type where every property value is a getter function
type Getters<T> = {
  [K in keyof T]: () => T[K];
};

type UserGetters = Getters<User>;
// Equivalent to: { name: () => string; age: () => number }

[K in keyof T] iterates over every property key in T. T[K] is the type of that property. This is how mapped types work.

More complex transformations

// Create a type where every property is wrapped in a Promise
type Promises<T> = {
  [K in keyof T]: Promise<T[K]>;
};

type UserPromises = Promises<User>;
// Equivalent to: { name: Promise<string>; age: Promise<number> }

// Create validators for each property
type Validators<T> = {
  [K in keyof T]: (value: T[K]) => boolean;
};

type UserValidators = Validators<User>;
// Equivalent to: { name: (value: string) => boolean; age: (value: number) => boolean }

const userValidators: UserValidators = {
  name: (n) => n.length > 0,
  age: (a) => a >= 0 && a <= 150,
};

// Filter to only certain property types
type StringPropertiesOnly<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};

type UserStrings = StringPropertiesOnly<User>; // { name: string }

// Omit properties where the value type matches a condition
type ExcludeByValue<T, U> = {
  [K in keyof T as T[K] extends U ? never : K]: T[K];
};

type UserWithoutStrings = ExcludeByValue<User, string>;
// Equivalent to: { age: number }

The as clause in [K in keyof T as ...] is the key — it lets you filter which keys to include.

Conditional Types: Type Logic

Conditional types let you select between two types based on a condition. The syntax is like JavaScript ternary: T extends U ? X : Y.

// If T is a string, return string; otherwise return number
type StringOrNumber<T> = T extends string ? string : number;

type A = StringOrNumber<'hello'>; // string
type B = StringOrNumber<42>; // number

// More useful — extract the element type of an array
type ElementType<T> = T extends Array<infer U> ? U : never;

type NumArray = ElementType<number[]>; // number
type StrTuple = ElementType<[string, boolean]>; // string | boolean
type NotArray = ElementType<string>; // never

// Flatten one level of nesting
type Flatten<T> = T extends Array<infer U> ? U : T;

type Flat1 = Flatten<string[]>; // string
type Flat2 = Flatten<string>; // string

// Get the return type of a function
type ReturnOf<T> = T extends (...args: unknown[]) => infer R ? R : never;

type GetUserReturn = ReturnOf<(id: number) => { id: number; name: string }>;
// Equivalent to: { id: number; name: string }

infer U is a placeholder for an unknown type that will be extracted. When TypeScript matches the pattern, it captures whatever fits into U.

Distributive conditional types

When a union is used with a conditional type, the condition is applied to each member of the union:

type Flatten<T> = T extends Array<infer U> ? U : T;

type Str = Flatten<string>; // string
type Num = Flatten<number[]>; // number
type Both = Flatten<string | number[]>; // Flatten<string> | Flatten<number[]> => string | number

// This distributivity is useful for filtering unions
type NonArray<T> = T extends Array<unknown> ? never : T;

type Filtered = NonArray<string | number[] | boolean>;
// Applies to each member: NonArray<string> | NonArray<number[]> | NonArray<boolean>
// Results in: string | never | boolean => string | boolean

Distributivity happens automatically with unions. If you need to prevent it, wrap the type in a tuple:

type NoDistribute<T> = [T] extends [Array<infer U>] ? U : T;

type Result = NoDistribute<string | number[]>; // string | number[] (no distribution)

Template Literal Types: String-Based Types

Template literal types let you create types based on string patterns. They’re particularly useful for strict event naming, API routes, or configuration keys.

// Create event names like 'user.created', 'user.updated', 'post.published'
type EventName = `${'user' | 'post' | 'comment'}.${
  | 'created'
  | 'updated'
  | 'deleted'}`;

const event1: EventName = 'user.created'; // Valid
const event2: EventName = 'post.updated'; // Valid
const event3: EventName = 'user.invalid'; // Error

// Extract parts from template literal types
type SplitEventName<T extends EventName> = T extends `${infer E}.${infer A}`
  ? { entity: E; action: A }
  : never;

type UserEvent = SplitEventName<'user.created'>;
// { entity: 'user'; action: 'created' }

// Create API route types
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type ApiRoute<M extends HttpMethod, P extends string> = `[${M}] ${P}`;

type GetUsers = ApiRoute<'GET', '/api/users'>; // "[GET] /api/users"
type CreatePost = ApiRoute<'POST', '/api/posts'>; // "[POST] /api/posts"

Template literal types don’t generate strings at runtime — they’re purely for type checking. They’re useful for ensuring strings match certain patterns.

Complex Data Structures: Real-World Typing

Now that you understand the tools, let’s apply them to complex structures you actually encounter.

Recursive Types: Trees and Graphs

// A tree node — recursive, but strictly typed
interface TreeNode<T> {
  value: T;
  children: TreeNode<T>[];
}

const tree: TreeNode<number> = {
  value: 1,
  children: [
    {
      value: 2,
      children: [
        { value: 4, children: [] },
        { value: 5, children: [] },
      ],
    },
    {
      value: 3,
      children: [{ value: 6, children: [] }],
    },
  ],
};

// A generic tree walker
function walkTree<T>(node: TreeNode<T>, fn: (value: T) => void): void {
  fn(node.value);
  node.children.forEach((child) => walkTree(child, fn));
}

walkTree(tree, (n) => console.log(n)); // 1, 2, 4, 5, 3, 6

// More complex — JSON-like structure that could be anything
type JSON = null | boolean | number | string | JSON[] | { [key: string]: JSON };

const data: JSON = {
  name: 'Alice',
  age: 30,
  tags: ['engineer', 'typescript'],
  metadata: {
    created: '2024-01-01',
    active: true,
  },
};

The key to recursive types is that the type references itself, but you must have a base case (like children: [] in the tree example) to prevent infinite recursion.

Builder Pattern with Fluent Typing

// A query builder that maintains type information as you chain methods
class QueryBuilder<
  TFrom extends string,
  TSelectFields extends readonly string[] = [],
> {
  private fields: string[] = [];
  private fromTable: string = '';

  constructor(from: TFrom) {
    this.fromTable = from;
  }

  // select returns a new builder with the fields captured in the type
  select<K extends string>(
    ...fields: K[]
  ): QueryBuilder<TFrom, [...TSelectFields, ...K[]]> {
    const builder = new QueryBuilder<TFrom, [...TSelectFields, ...K[]]>(
      this.fromTable
    );
    builder.fields = [...this.fields, ...fields];
    return builder;
  }

  // build returns only the fields that were selected
  build(): {
    [K in TSelectFields[number]]: unknown;
  } {
    const result: Record<string, unknown> = {};
    for (const field of this.fields) {
      result[field] = null;
    }
    return result as {
      [K in TSelectFields[number]]: unknown;
    };
  }
}

// Usage with strict typing
const query = new QueryBuilder('users').select('id').select('name', 'email');

const result = query.build(); // { id: unknown; name: unknown; email: unknown }

This pattern captures the exact fields selected in the type system, so you get autocomplete and type checking on the result.

State Machine Typing

// A state machine where you can only transition from valid states
type OrderState = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled';

type ValidTransitions = {
  pending: 'paid' | 'cancelled';
  paid: 'shipped' | 'cancelled';
  shipped: 'delivered' | 'cancelled';
  delivered: 'delivered'; // Can't leave delivered
  cancelled: 'cancelled'; // Can't leave cancelled
};

class Order {
  private state: OrderState = 'pending';

  // Only allow transitions to valid next states
  transition<S extends OrderState>(
    from: S,
    to: ValidTransitions[S]
  ): to is ValidTransitions[S] {
    this.state = to;
    return true;
  }

  // Cleaner API — only allow valid next states from current state
  canTransitionTo(nextState: ValidTransitions[typeof this.state]): boolean {
    return true;
  }
}

const order = new Order();
order.canTransitionTo('paid'); // Valid — pending → paid is allowed
order.canTransitionTo('shipped'); // Error — pending → shipped is not allowed

The ValidTransitions record ensures you can only transition to states that make sense from the current state.

Generic Handler Registry

// A type-safe event handler registry
interface EventMap {
  'user:created': { userId: number; email: string };
  'user:deleted': { userId: number };
  'post:published': { postId: number; authorId: number };
}

class EventEmitter {
  private handlers: Map<keyof EventMap, Set<(payload: any) => void>> =
    new Map();

  on<K extends keyof EventMap>(
    event: K,
    handler: (payload: EventMap[K]) => void
  ): void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, new Set());
    }
    this.handlers.get(event)!.add(handler);
  }

  emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
    const handlers = this.handlers.get(event);
    if (handlers) {
      handlers.forEach((h) => h(payload));
    }
  }
}

// Usage — type-safe event handling
const emitter = new EventEmitter();

emitter.on('user:created', (payload) => {
  // payload is typed as { userId: number; email: string }
  console.log(payload.email);
});

emitter.emit('user:created', { userId: 1, email: 'alice@example.com' }); // Valid
emitter.emit('user:created', { userId: 1 }); // Error — missing email
emitter.emit('user:deleted', { userId: 1, email: 'x' }); // Error — email not expected

The EventEmitter uses the EventMap to ensure handlers receive exactly the right payload type for each event.

Database ORM-Style Typing

// A simple ORM where queries are typed based on what you're querying
interface UserRecord {
  id: number;
  name: string;
  email: string;
  password: string;
}

class Query<T> {
  private selectedFields: (keyof T)[] = [];

  // Select specific fields (like ORM)
  select<K extends keyof T>(...fields: K[]): Query<Pick<T, K>> {
    const query = new Query<Pick<T, K>>();
    query.selectedFields = fields as any;
    return query;
  }

  // Execute and get results with only selected fields
  async execute(): Promise<Partial<T>[]> {
    // This would hit a database
    return [];
  }
}

// Usage
const query = new Query<UserRecord>();
const results = await query
  .select('id', 'name', 'email') // Exclude password
  .execute();

// results has type: Pick<UserRecord, 'id' | 'name' | 'email'>[]
// You can't access .password because it wasn't selected
results[0].name; // OK
results[0].password; // Error — not selected

This pattern prevents accidentally exposing sensitive fields. The type system enforces you only get back what you asked for.

Common Typing Mistakes and How to Avoid Them

Over-typing with any

// Bad — loses all type safety
function processData(data: any): void {
  console.log(data.name); // Could crash at runtime
}

// Better — be specific about what you accept
function processData(data: { name: string; age: number }): void {
  console.log(data.name);
}

// Or use a generic if it could be any type
function processData<T extends { name: unknown }>(data: T): void {
  console.log(data.name as string);
}

any turns off type checking. Use unknown if the type is truly unknown, or generics if it could be multiple types.

Wide type annotations hiding bugs

// Bad — type is too wide
const id: string | number = getUserId(); // Could be either!
if (typeof id === 'string') {
  const num = parseInt(id); // Should immediately narrow
}

// Better — be as specific as possible at assignment
const id: number = getUserId() as number; // Make the assumption explicit

// Or use literal types for more precision
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; // Not just string

The wider your type is, the more conditions you need to check before using a value.

Forgetting to handle union edge cases

// Bad — incomplete handling
type Status = 'pending' | 'success' | 'error' | 'loading';

function handleStatus(status: Status): string {
  if (status === 'success') {
    return 'Done!';
  }
  return 'Still working...'; // Handles everything else the same
}

// Better — explicitly handle each case or use exhaustiveness checking
function handleStatus(status: Status): string {
  switch (status) {
    case 'pending':
      return 'Waiting...';
    case 'success':
      return 'Done!';
    case 'error':
      return 'Failed!';
    case 'loading':
      return 'Loading...';
    default:
      assertNever(status); // Ensure all cases are covered
  }
}

Missing a case in a union is one of the most common bugs. Structured pattern matching (switch with exhaustiveness) catches it.

Not leveraging readonly for immutability

// Bad — mutable by default, easy to mutate accidentally
interface Config {
  apiUrl: string;
  maxRetries: number;
}

const config: Config = { apiUrl: 'https://api.example.com', maxRetries: 3 };
config.apiUrl = 'https://evil.com'; // Oops, mutated global config

// Better — make it readonly
interface Config {
  readonly apiUrl: string;
  readonly maxRetries: number;
}

const config: Config = { apiUrl: 'https://api.example.com', maxRetries: 3 };
config.apiUrl = 'https://evil.com'; // Error — cannot assign to readonly

readonly on properties prevents accidental mutations. Use it liberally on configuration and state that shouldn’t change.

Over-abstracting with generics

// Bad — too generic, unclear what it does
function process<T, U, V>(
  data: T,
  transform: (x: T) => U,
  validate: (x: U) => V
): V {
  return validate(transform(data));
}

// Better — name the generics meaningfully, or split into specific functions
function transformAndValidate<TInput, TOutput>(
  data: TInput,
  transform: (x: TInput) => TOutput,
  validate: (x: TOutput) => boolean
): TOutput | null {
  const result = transform(data);
  return validate(result) ? result : null;
}

// Or even better — use specific domain types
function enrichUserProfile(
  rawUser: RawUserData,
  enrichment: (user: RawUserData) => EnrichedUser
): EnrichedUser | null {
  // More specific and clearer intent
}

Every generic should serve a purpose. If you can’t explain what it does, it’s probably too abstract.

Higher-Order Types: Advanced Patterns

Currying and partial application in TypeScript

// Create a partially applied function with a known first argument
function curried<A, B, C>(fn: (a: A, b: B) => C) {
  return (a: A) =>
    (b: B): C =>
      fn(a, b);
}

const add = (a: number, b: number) => a + b;
const curriedAdd = curried(add);
const add5 = curriedAdd(5); // Function waiting for one more arg
const result = add5(3); // 8

// More typefully — extract and transform argument types
type Params<F> = F extends (...args: infer P) => unknown ? P : never;

function applyFirst<F extends (arg: unknown, ...rest: unknown[]) => unknown>(
  fn: F,
  arg: Params<F>[0]
): (
  ...rest: Params<F> extends [unknown, ...infer R] ? R : never[]
) => ReturnType<F> {
  return (...rest) => fn(arg, ...rest) as ReturnType<F>;
}

This is more advanced and less commonly needed, but useful for creating decorators and higher-order functions.

Type-safe dictionary factories

// Create a typed singleton registry of factories
type Factories = {
  user: () => User;
  post: () => Post;
  comment: () => Comment;
};

class FactoryRegistry<T extends Record<string, () => unknown>> {
  constructor(private factories: T) {}

  create<K extends keyof T>(key: K): ReturnType<T[K]> {
    return this.factories[key]() as ReturnType<T[K]>;
  }
}

const registry = new FactoryRegistry({
  user: () => ({ id: 1, name: 'Alice' }),
  post: () => ({ id: 1, title: 'Hello', content: 'World' }),
  comment: () => ({ id: 1, text: 'Nice post' }),
});

const user = registry.create('user'); // Type is User
const post = registry.create('post'); // Type is Post
// registry.create('invalid'); // Error — 'invalid' is not a key

This pattern is useful for dependency injection and factory patterns where you want strict type mapping.

Practical Advice for Daily TypeScript

Type inference vs explicit annotations

// When to rely on inference — simple cases where it's obvious
const name = 'Alice'; // TypeScript infers string
const age = 30; // TypeScript infers number

// When to be explicit — interfaces, complex types, API boundaries
interface User {
  name: string;
  age: number;
  email: string;
}

// Even if users are clearly defined elsewhere, define it at API boundaries
function getCurrentUser(): User {
  // Be explicit — the function's contract is clear
}

// When inference gets confusing — specify even if TypeScript could infer it
const results: string[] = data
  .filter((item) => item.status === 'active')
  .map((item) => item.name);
// Type is obvious from the chain, but being explicit makes it scannable

Explicit annotations at API boundaries are always worth the extra typing. Inference inside functions is usually fine.

Organizing types into namespaces

// For large projects, organize related types together
namespace Database {
  export interface User {
    id: number;
    name: string;
  }

  export interface Post {
    id: number;
    authorId: number;
    title: string;
  }

  export type AnyRecord = User | Post;
}

// Or use module pattern (preferred in modern code)
export namespace API {
  export namespace Users {
    export interface Request {
      name: string;
      email: string;
    }

    export interface Response {
      id: number;
      name: string;
      email: string;
    }
  }
}

// Usage
const user: API.Users.Request = { name: 'Alice', email: 'alice@example.com' };

Organizing types into groups makes large codebases more navigable.

When to extract types into separate files

// If a type is used by multiple modules, extract it to a shared file
// types/user.ts
export interface User {
  id: number;
  name: string;
  email: string;
}

export type UserId = User['id']; // Extract just the ID type

// Or use branded types for stronger guarantees
export type UserId = number & { readonly __userIdBrand: unique symbol };

// Creating a branded ID
function createUserId(id: number): UserId {
  return id as UserId;
}

// Now you can't accidentally use a regular number where UserId is expected
const userId: UserId = 123; // Error —cannot assign number to UserId
const userId: UserId = createUserId(123); // OK

Branded types (or “opaque types”) prevent accidentally mixing up numbers that represent different things.

Understanding Distribution in Conditional Types

One of the trickiest aspects of conditional types is distribution — when you use a union with a conditional type, the condition applies to each member individually:

// This distributes over the union
type IsString<T> = T extends string ? true : false;

type Result1 = IsString<string>; // true
type Result2 = IsString<number>; // false
type Result3 = IsString<string | number>; // true | false

// Distribution can be prevented by wrapping in a tuple
type IsStringNoDistribute<T> = [T] extends [string] ? true : false;

type Result4 = IsStringNoDistribute<string | number>; // false (the whole union is tested)

// Practical example — filtering a union
type Flatten<T> = T extends Array<infer U> ? U : T;

type Test = Flatten<string | number[]>;
// Distributes: Flatten<string> | Flatten<number[]>
// Result: string | number (flattens each one)

type FilterArrays<T> = T extends Array<unknown> ? never : T;

type OnlyScalars = FilterArrays<string | number[] | boolean>;
// Distributes: FilterArrays<string> | FilterArrays<number[]> | FilterArrays<boolean>
// Result: string | never | boolean => string | boolean

Understanding distribution is key to using conditional types effectively. It often does what you want, but sometimes you need to opt out by wrapping in a tuple.

Difference between Good TypeScript Developers and the Great Ones

They understand that types are documentation. Great TypeScript developers write types that are so clear and expressive that the code reads like documentation. A Pick<User, 'id' | 'name'> is more self-documenting than a UserDTO with the same fields.

They use discriminated unions liberally. The codebase becomes simpler because edge cases are handled by the type system. Missing a case becomes a compile error, not a runtime bug.

They are precise with their constraints. T extends { id: number } is different from T extends Entity, and they know when each one is correct. Overly broad types lead to defensive coding.

They know when to extract types and when to leave them inline. Types at API boundaries are always explicit. Internal implementation types are often inferred to reduce noise. The balance matters.

They stay current with TypeScript releases. Distribution narrowing, conditional types with infer, template literal types — new features become powerful once you fully understand them. The TypeScript 5.x releases introduced significant improvements to type inference that change how you write certain patterns.

They read error messages carefully. The TypeScript error messages are usually telling you exactly what’s wrong. Developers who invest time understanding them debug faster.

Conclusion

TypeScript’s type system is not a constraint placed on JavaScript developers — it’s a tool for expressing your intent clearly enough that the compiler can catch your mistakes before they reach production.

The surface of TypeScript is easy: interfaces, optional fields, unions. The depth — generics, utility types, conditional types, mapped types — is where you express complex structures and prevent whole categories of bugs.

The developers I’ve worked with who invested time understanding these patterns wrote code that required less debugging, was easier to refactor, and made fewer unexpected assumptions about data shapes.

Write types. Push them to their limits. Read the TypeScript handbook deeply. When you encounter a structure you can’t express in types, that’s the time to invent a new pattern.

Type safely.

Resources