Siddhant Deval
Siddhant DevalAuthor
Senior Full-Stack Engineer·Aug 27, 2026·15 min read

Advanced Patterns: Variance, Branded Types & Exhaustiveness

The ceiling of TypeScript mastery is encoding business rules into the type system so that illegal states are literally unrepresentable — not documented, not guarded at runtime, but impossible to construct. Variance, branded types, and exhaustive unions are the three tools that get you there.

Advanced Patterns: Variance, Branded Types & Exhaustiveness

Types are a specification language — not an annotation layer. The ceiling of that specification language is encoding business rules so that illegal states are literally unrepresentable — not documented in a README, not guarded at runtime, but impossible to construct at the type level. Variance tells you which substitutions are safe. Branded types prevent semantic confusion between structurally identical values. Exhaustive unions ensure that adding a new case always surfaces as a compile error at every handling site. Together, these three patterns are what distinguishes a TypeScript codebase that uses types versus one that is designed with types.

1. Variance — Covariance and Contravariance

Variance describes how a generic type's assignability changes as its type parameter changes. There are two directions that matter for TypeScript engineers:
  • Covariant (output/return positions): If Dog extends Animal, then () => Dog is assignable to () => Animal. The type narrows with the type parameter.
  • Contravariant (input/parameter positions): If Dog extends Animal, then (x: Animal) => void is assignable to (x: Dog) => void. The type widens with the type parameter.

1.1 Why Function Parameters Are Contravariant

typescript
type Animal = { name: string }
type Dog    = Animal & { breed: string }

// ❌ This looks reasonable but is unsound
type DogHandler   = (dog: Dog) => void
type AnimalHandler = (animal: Animal) => void

// A function that handles Dogs CANNOT safely replace one that handles Animals
// — the caller might pass a Cat (which satisfies Animal but not Dog)
const handler: DogHandler = (dog) => console.log(dog.breed)
const animalHandler: AnimalHandler = handler  // Unsound!

// The caller passes a Cat — valid for AnimalHandler, not for DogHandler:
animalHandler({ name: 'Cat' })  // 💥 dog.breed is undefined at runtime
typescript
// ✅ Contravariance is correct — an AnimalHandler can safely substitute a DogHandler
// because it handles MORE values (any Animal, not just Dogs)
const animalHandler: AnimalHandler = (animal) => console.log(animal.name)
const dogHandler: DogHandler = animalHandler  // ✅ Safe — Animal ⊃ Dog; handles Dogs too
Crucial Requirement
strictFunctionTypes: true (part of strict) enables correct contravariant checking for function types in non-method positions. Method positions (obj.method(x: Dog)) remain bivariant for backward compatibility — this is a known TypeScript trade-off documented in the specification.
 Two-column diagram. Column headers: 'Return position (covariant)' and 'Parameter position (contravariant)'. Covariant column: a vertical arrow labeled 'assignable direction →' points from () => Dog to () => Animal — smaller type is assignable to larger type. Contravariant column: a vertical arrow labeled 'assignable direction →' points from (x: Animal) => void to (x: Dog) => void — the direction is reversed. Annotation: 'Covariant: subtype flows up (Dog → Animal). Contravariant: supertype flows up (Animal → Dog handler position)'. Caption: 'Return positions are covariant — safe to substitute a subtype. Parameter positions are contravariant — safe to substitute a supertype'.
Figure: Two-column diagram. Column headers: 'Return position (covariant)' and 'Parameter position (contravariant)'. Covariant column: a vertical arrow labeled 'assignable direction →' points from () => Dog to () => Animal — smaller type is assignable to larger type. Contravariant column: a vertical arrow labeled 'assignable direction →' points from (x: Animal) => void to (x: Dog) => void — the direction is reversed. Annotation: 'Covariant: subtype flows up (Dog → Animal). Contravariant: supertype flows up (Animal → Dog handler position)'. Caption: 'Return positions are covariant — safe to substitute a subtype. Parameter positions are contravariant — safe to substitute a supertype'.

2. Branded / Nominal Types

TypeScript's structural system means type UserId = string and type OrderId = string are completely interchangeable. When they represent semantically distinct concepts, that silent interchangeability creates bugs that the compiler cannot catch.

2.1 The Semantic Mix-Up Problem

typescript
type UserId  = string
type OrderId = string

function cancelOrder(userId: UserId, orderId: OrderId): void {
  api.delete(`/users/${userId}/orders/${orderId}`)
}

const uid = 'user_abc' as UserId
const oid = 'order_xyz' as OrderId

cancelOrder(oid, uid)  // ✅ Compiles — but the arguments are swapped. A production bug.

2.2 The Brand Pattern

typescript
// Branded types — structurally incompatible despite the same base type
type UserId  = string & { readonly __brand: 'UserId' }
type OrderId = string & { readonly __brand: 'OrderId' }

// Smart constructor — the only correct way to create a UserId
function toUserId(raw: string): UserId {
  // Optionally: add validation here (format check, etc.)
  if (!raw.startsWith('user_')) throw new Error(`Invalid UserId format: ${raw}`)
  return raw as UserId  // The ONE safe cast, contained inside the constructor
}

function toOrderId(raw: string): OrderId {
  if (!raw.startsWith('order_')) throw new Error(`Invalid OrderId format: ${raw}`)
  return raw as OrderId
}

function cancelOrder(userId: UserId, orderId: OrderId): void {
  api.delete(`/users/${userId}/orders/${orderId}`)
}

const uid = toUserId('user_abc')
const oid = toOrderId('order_xyz')

cancelOrder(oid, uid)
// ❌ Error: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
// The compiler now prevents the semantic mix-up.

cancelOrder(uid, oid)  // ✅ Correct order
Architectural Note
The __brand property is never present at runtime — it exists only in the type system. The as UserId cast inside the smart constructor is the only cast required, and it is safely encapsulated. All consuming code is free of casts.
 Funnel diagram. At the top: a wide box labeled 'Raw input: string — any string value'. An arrow labeled 'Smart constructor: toUserId(raw: string): UserId' points down through a narrowing funnel. Inside the funnel: a validation diamond with 'startsWith("user_")?' and two paths: red 'no → throw Error' and green 'yes → return raw as UserId'. At the bottom: a narrow box labeled 'Output: UserId — guaranteed valid format, branded, incompatible with OrderId'. Caption: 'Branded smart constructors contain the single as UserId cast inside the constructor — all external code receives a properly typed value with no casts required'.
Figure: Funnel diagram. At the top: a wide box labeled 'Raw input: string — any string value'. An arrow labeled 'Smart constructor: toUserId(raw: string): UserId' points down through a narrowing funnel. Inside the funnel: a validation diamond with 'startsWith("user_")?' and two paths: red 'no → throw Error' and green 'yes → return raw as UserId'. At the bottom: a narrow box labeled 'Output: UserId — guaranteed valid format, branded, incompatible with OrderId'. Caption: 'Branded smart constructors contain the single as UserId cast inside the constructor — all external code receives a properly typed value with no casts required'.

3. Phantom Types

A phantom type carries type information that has no runtime representation — useful for encoding state machines into the type system:
typescript
// The state tag exists only at the type level — no runtime property
type FormState<State extends 'draft' | 'submitted' | 'validated'> = {
  data: FormData
  __state: State  // This field is never actually populated at runtime
}

function createDraft(data: FormData): FormState<'draft'> {
  return { data, __state: 'draft' as any }
}

function submit(form: FormState<'draft'>): FormState<'submitted'> {
  // Only `draft` forms can be submitted — encoded in the type
  return { data: form.data, __state: 'submitted' as any }
}

function validate(form: FormState<'submitted'>): FormState<'validated'> {
  // Only `submitted` forms can be validated — encoded in the type
  return { data: form.data, __state: 'validated' as any }
}

const form = createDraft(data)
validate(form)    // ❌ Error: 'draft' is not assignable to 'submitted' — cannot skip submission
submit(form)      // ✅

4. Result<T, E> — Typed Error Handling Without Exceptions

The Result pattern models success and failure as a discriminated union, making the error channel explicit in the type signature:
typescript
type Result<T, E = Error> =
  | { ok: true;  value: T }
  | { ok: false; error: E }

// Functions that can fail return Result<T, E> — callers are forced to handle both paths
async function fetchUser(id: string): Promise<Result<User, ApiError>> {
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) {
    return { ok: false, error: { code: response.status, message: response.statusText } }
  }
  const data = await response.json()
  return { ok: true, value: UserSchema.parse(data) }
}

// Caller — CFA drives exhaustive handling
const result = await fetchUser('user_abc')

if (result.ok) {
  console.log(result.value.name)  // result: { ok: true; value: User }
} else {
  console.error(result.error.code)  // result: { ok: false; error: ApiError }
}
// No `throw` — no unhandled exception — the error path is impossible to ignore
 Two-path diagram. A central diamond labeled 'result.ok'. Left arrow labeled 'true' points to a green box: 'result: { ok: true; value: User } — .value is accessible'. Right arrow labeled 'false' points to a red box: 'result: { ok: false; error: ApiError } — .error is accessible'. Below both boxes: arrows rejoin labeled 'exhaustive — no default case needed'. Caption: 'Result<T, E> with CFA eliminates the need for a default branch — the ok discriminant makes both paths provably exhaustive'.
Figure: Two-path diagram. A central diamond labeled 'result.ok'. Left arrow labeled 'true' points to a green box: 'result: { ok: true; value: User } — .value is accessible'. Right arrow labeled 'false' points to a red box: 'result: { ok: false; error: ApiError } — .error is accessible'. Below both boxes: arrows rejoin labeled 'exhaustive — no default case needed'. Caption: 'Result<T, E> with CFA eliminates the need for a default branch — the ok discriminant makes both paths provably exhaustive'.

5. The using Keyword (TypeScript 5.2)

using implements the Explicit Resource Management proposal (TC39 Stage 4). It calls Symbol.dispose on the resource at the end of the block — the TypeScript equivalent of RAII:
typescript
// Without `using` — manual cleanup; easy to forget in error paths
{
  const db = await openDatabaseConnection()
  try {
    await db.query('SELECT 1')
  } finally {
    await db.close()  // Must be in finally — easy to miss
  }
}

// ✅ With `using` — disposal is guaranteed, even if an error is thrown
{
  await using db = openDatabaseConnection()
  // db[Symbol.asyncDispose] is called automatically at block exit
  await db.query('SELECT 1')
}  // db is closed here — always, no try/finally needed

6. Const Enums vs. Union Types

PatternTree-shakeableisolatedModulesRuntime valueRecommendation
const enum✅ (inlined by tsc)❌ IncompatibleInlined literalAvoid — breaks with Vite/esbuild
enum❌ Generates an object✅ CompatibleObject propertyAvoid — non-tree-shakeable, generates JS
Union type✅ Erased✅ CompatibleNone (erased)Prefer for type-only enumerations
const object✅ (if unused)✅ CompatibleObjectPrefer when runtime access is needed
typescript
// ✅ Union type — zero runtime footprint, full type safety
type Direction = 'north' | 'south' | 'east' | 'west'

// ✅ `const` object — runtime access, tree-shakeable
const Direction = { North: 'north', South: 'south', East: 'east', West: 'west' } as const
type Direction = typeof Direction[keyof typeof Direction]

Summary

ConceptRule
CovarianceReturn/output positions — Dog → Animal direction (subtype is assignable to supertype)
ContravarianceParameter/input positions — Animal → Dog direction for handler substitution
strictFunctionTypesEnables correct contravariant checking for function types in non-method positions
Branded typesT & { __brand: 'X' } — creates structural incompatibility for semantically distinct values
Smart constructorContains the one as BrandedType cast; all consumers receive a properly typed value
Phantom typesEncode state machine transitions in the type system — zero runtime overhead
Result<T, E>Typed error channel — the ok discriminant drives exhaustive CFA without exceptions
using keywordTS 5.2+ — calls Symbol.dispose at block exit; deterministic cleanup without try/finally
Const enumsAvoid with Vite/esbuild — use union types or const objects instead

What's Next

In Part 11, the final article, we cover TypeScript with React — generic components, forwardRef, typed useReducer, and context typing. These are the patterns that eliminate the daily as casts from every React codebase.
Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#TypeScript#Variance#Branded Types#Exhaustiveness#Advanced TypeScript
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.