Skip to main content
Back to Research
Field ReportDeep Readtypescript-mastery

Advanced TypeScript Type System Patterns

Discriminated unions, template literal types, conditional types, branded types, and the type-level patterns that make impossible states actually impossible.

Abstract

TypeScript's type system, used beyond basic annotations, becomes a tool for encoding domain constraints and eliminating entire categories of bugs at compile time. This article covers discriminated unions for exhaustive matching, infer and conditional types for API modeling, branded types for domain safety, and the principle of making impossible states unrepresentable.

March 5, 2026
8 min read

Beyond Basic Annotations

Most TypeScript usage stops at annotating function parameters and return types. This is valuable — it catches obvious bugs and provides autocomplete. But it's a small fraction of what the type system can express.

The shift from "TypeScript as documentation" to "TypeScript as constraint enforcement" happens when you start using the type system to encode domain invariants: states that cannot coexist, values that must come from a restricted set, functions that can only be called in specific contexts. At this level, the type system eliminates categories of bugs rather than individual bugs.

Discriminated Unions for Exhaustive Matching

A discriminated union is a union of object types, each with a common literal-typed discriminant field:

type LoadingState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: ArticleContent }
  | { status: 'error'; error: string; retryable: boolean };

function renderState(state: LoadingState): React.ReactNode {
  switch (state.status) {
    case 'idle': return null;
    case 'loading': return <Spinner />;
    case 'success': return <Article content={state.data} />;
    case 'error': return <ErrorMessage message={state.error} retryable={state.retryable} />;
    // TypeScript error here if any case is missing:
    default: state satisfies never; // exhaustiveness check
  }
}

The state satisfies never pattern is the exhaustiveness check. If you add a new variant to LoadingState and forget to add a case, TypeScript produces a type error at the satisfies never line. The error is at the switch statement, not at a random runtime crash three call stacks deep.

This pattern scales to complex state machines. Model your entire application state as a discriminated union, and every component that renders based on that state gets compile-time guarantees that it handles every possible state.

Template Literal Types

Template literal types construct string types programmatically:

type Locale = 'en' | 'es';
type Namespace = 'nav' | 'hero' | 'admin' | 'errors';
type TranslationKey = `${Namespace}.${string}`;

// Or for more specific key structures:
type AdminAction =
  | `SET_${Uppercase<string>}`
  | `UPDATE_${Uppercase<string>}`
  | `RESET`;

// Event handler naming conventions:
type EventName = 'click' | 'focus' | 'blur';
type Handler = `on${Capitalize<EventName>}`; // 'onClick' | 'onFocus' | 'onBlur'

Template literal types are most useful for type-safe event systems, API endpoint construction, and translation key validation. They prevent typos in string-based APIs at compile time rather than runtime.

// Type-safe CSS variable access
type CSSVar<T extends string> = `--${T}`;
type DesignToken = CSSVar<'color-brand' | 'color-surface' | 'radius-card'>;

function getToken(name: DesignToken): string {
  return getComputedStyle(document.documentElement).getPropertyValue(name);
}

getToken('--color-brand'); // OK
getToken('--color-unknown'); // TypeScript error

The infer Keyword

infer extracts type information from other types within conditional type expressions. It's the mechanism for writing type-level functions:

// Extract the resolved value type from a Promise
type Awaited<T> = T extends Promise<infer R> ? Awaited<R> : T;

// Extract the element type from an array
type ElementOf<T> = T extends (infer E)[] ? E : never;

// Extract the first parameter type of a function
type FirstParam<T extends (...args: any[]) => any> =
  T extends (first: infer F, ...rest: any[]) => any ? F : never;

// Extract the return type of an async function
type AsyncReturn<T extends (...args: any[]) => Promise<any>> =
  T extends (...args: any[]) => Promise<infer R> ? R : never;

The real-world use case: typing the output of a generic data fetching function based on the schema it was given:

type SchemaOutput<T> = T extends z.ZodType<infer O> ? O : never;

async function fetchAndValidate<T extends z.ZodType>(
  url: string,
  schema: T
): Promise<SchemaOutput<T>> {
  const response = await fetch(url);
  const data = await response.json();
  return schema.parse(data); // TypeScript knows the return type from the schema
}

Conditional Types for API Modeling

Conditional types compute different output types based on input types. They're the TypeScript equivalent of a type-level if statement:

// Different return types based on whether an ID is provided
type CreateArgs = { title: string; content: string };
type UpdateArgs = { id: string; title?: string; content?: string };

type ArticleArgs<T extends 'create' | 'update'> =
  T extends 'create' ? CreateArgs : UpdateArgs;

function saveArticle<T extends 'create' | 'update'>(
  mode: T,
  args: ArticleArgs<T>
): T extends 'create' ? { id: string } : { updated: boolean } {
  // Implementation
  throw new Error('not implemented');
}

const created = saveArticle('create', { title: 'Hello', content: 'World' });
created.id; // OK — TypeScript knows this is { id: string }

const updated = saveArticle('update', { id: '123', title: 'New Title' });
updated.updated; // OK — TypeScript knows this is { updated: boolean }

Type Predicates

Type predicates narrow a type within a conditional block based on a runtime check. They're the bridge between runtime validation and compile-time type narrowing:

function isArticle(item: ContentItem): item is Article {
  return item.type === 'article';
}

function isError(value: unknown): value is Error {
  return value instanceof Error;
}

// More sophisticated: check structural shape
function isAdminState(value: unknown): value is AdminState {
  return (
    typeof value === 'object' &&
    value !== null &&
    'theme' in value &&
    'sidebar' in value
  );
}

// Usage: TypeScript narrows the type after the predicate check
function processItem(item: ContentItem) {
  if (isArticle(item)) {
    // TypeScript knows item is Article here
    console.log(item.publishedAt); // property specific to Article
  }
}

Prefer Zod's safeParse over hand-written type predicates for complex objects. But for simple structural checks and discriminant-based narrowing, type predicates express intent more clearly than a parse result.

Branded Types for Domain Safety

Branded types prevent accidentally mixing structurally identical but semantically different values:

type Brand<T, B> = T & { readonly __brand: B };

type UserId = Brand<string, 'UserId'>;
type ArticleId = Brand<string, 'ArticleId'>;
type EmailAddress = Brand<string, 'EmailAddress'>;

// Constructors validate and brand
function toUserId(id: string): UserId {
  if (!/^user_[a-zA-Z0-9]{20}$/.test(id)) throw new Error('Invalid user ID format');
  return id as UserId;
}

function toEmailAddress(email: string): EmailAddress {
  if (!email.includes('@')) throw new Error('Invalid email');
  return email as EmailAddress;
}

// Functions that require specific brands
function getUser(id: UserId): Promise<User> { /* ... */ }
function getArticle(id: ArticleId): Promise<Article> { /* ... */ }

// This is a TypeScript error at compile time:
const userId = toUserId('user_abc123def456ghi789jk');
getArticle(userId); // Error: Argument of type 'UserId' is not assignable to parameter of type 'ArticleId'

Branded types are particularly valuable for IDs in systems with multiple entity types, monetary values (prevent mixing USD and EUR), and validated inputs (prevent passing unvalidated email strings to functions that expect validated ones).

Making Impossible States Unrepresentable

The highest-leverage use of TypeScript's type system: encode invariants such that the types themselves prevent invalid states from being constructed.

A common example — a form state that's "loading" and "errored" simultaneously is impossible, but a naive type allows it:

// Bad: allows impossible state
type FormState = {
  loading: boolean;
  error: string | null;
  data: FormData | null;
};
// { loading: true, error: 'network error', data: null } — invalid but type-safe

// Good: impossible states are unrepresentable
type FormState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; data: FormData };

Another example — a pagination component where currentPage can exceed totalPages:

// Bad: invariant is unenforced
type PaginationState = {
  currentPage: number;
  totalPages: number;
};

// Good: constructor enforces the invariant, type carries the guarantee
type ValidPagination = {
  readonly currentPage: number;
  readonly totalPages: number;
  readonly hasNext: boolean;
  readonly hasPrev: boolean;
};

function createPagination(current: number, total: number): ValidPagination {
  const clampedCurrent = Math.max(1, Math.min(current, total));
  return {
    currentPage: clampedCurrent,
    totalPages: total,
    hasNext: clampedCurrent < total,
    hasPrev: clampedCurrent > 1,
  };
}

The pattern: validate at construction, carry the guarantee in the type. Code that receives a ValidPagination doesn't need to re-check the invariants — the type guarantees they were checked at construction. This is the type system doing operational work, not just documentation work.

Continue Reading
JCJOOTACEE / OPS

Operational laboratory for AI systems, automation infrastructures, and modular digital ecosystems.

Systems

  • AURA Orchestration
  • MCP Ecosystem
  • Graph Memory
  • AI Agents
  • Docker Infrastructure
  • Industrial Intelligence

System Status

PlatformOperational
APIHealthy
3D EngineActive
MCP Nodes8 Online

Try the Konami code...

Stay in the loop

Occasional updates on AI systems, autonomous infrastructure, and new releases.

© 2026 JootaCee. All systems operational.

RSSChangelogNext.js 16 + React 19 + R3F + GSAP