References

TypeScript 5.x Patterns

NoInfer, branded types, exhaustive switch, using keyword, and const type parameters

Core Patterns

  • NoInfer<T> Utility Type
  • Branded / Nominal Types
  • Exhaustive Switch with never
  • using / await using (TS 5.2)
  • Const Type Parameters (TS 5.0)

NoInfer<T> Utility Type

NoInfer<T> (TypeScript 5.4) prevents TypeScript from using a specific position to infer a type parameter, forcing inference from other arguments.

Problem: Unintended Inference from Default Value

// ❌ WRONG: TypeScript infers T from defaultValue, ignoring values array
function createStore<T>(values: T[], defaultValue: T): T {
  return values.includes(defaultValue) ? defaultValue : values[0];
}

// T is inferred as string | number because defaultValue is 0
createStore(['a', 'b', 'c'], 0); // No error — but should be an error!

Solution: NoInfer<T> on the Default Parameter

// ✅ CORRECT: T inferred from values only; defaultValue must match
function createStore<T>(values: T[], defaultValue: NoInfer<T>): T {
  return values.includes(defaultValue) ? defaultValue : values[0];
}

createStore(['a', 'b', 'c'], 0);   // ✅ Error: 0 not assignable to string
createStore(['a', 'b', 'c'], 'a'); // ✅ OK

Common Use Cases

// Component default prop must match options type
function Select<T extends string>(
  options: T[],
  defaultSelected: NoInfer<T>
): void { /* ... */ }

// Animation keyframe end must match start type
function animate<T>(from: T, to: NoInfer<T>, duration: number): void { /* ... */ }

Branded / Nominal Types

TypeScript uses structural typing — two objects with the same shape are interchangeable. Branded types add a unique “brand” to prevent mixing semantically different values.

Without Branding: Silent Bugs

type UserId = string;
type PostId = string;

function getPost(userId: UserId, postId: PostId): Post { /* ... */ }

const userId: UserId = 'user-123';
const postId: PostId = 'post-456';

getPost(postId, userId); // ✅ TypeScript allows this — arguments are swapped silently!

With Branded Types: Compile-Time Safety

// ✅ Brand pattern using unique symbol
declare const _brand: unique symbol;
type Brand<T, TBrand> = T & { readonly [_brand]: TBrand };

type UserId = Brand<string, 'UserId'>;
type PostId = Brand<string, 'PostId'>;

// Constructor functions validate and brand the value
function createUserId(id: string): UserId {
  if (!id.startsWith('user-')) throw new Error('Invalid UserId');
  return id as UserId;
}

function createPostId(id: string): PostId {
  if (!id.startsWith('post-')) throw new Error('Invalid PostId');
  return id as PostId;
}

function getPost(userId: UserId, postId: PostId): void { /* ... */ }

const userId = createUserId('user-123');
const postId = createPostId('post-456');

getPost(postId, userId); // ✅ Error: PostId not assignable to UserId
getPost(userId, postId); // ✅ OK

Branded Numeric Types

type USD = Brand<number, 'USD'>;
type EUR = Brand<number, 'EUR'>;

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const price = 9.99 as USD;
const tax   = 0.99 as USD;
const rate  = 1.08 as EUR; // Different currency

addUSD(price, tax);  // ✅ OK
addUSD(price, rate); // ✅ Error: EUR not assignable to USD

Exhaustive Switch with never

Use never to make the compiler verify that a switch statement handles all cases. Adding a new case to a union type without handling it becomes a compile error.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'triangle'; base: number; height: number };

// ✅ Exhaustive switch — compiler error if a new Shape is added
function area(shape: Shape): number {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'square':
      return shape.side ** 2;
    case 'triangle':
      return (shape.base * shape.height) / 2;
    default:
      // If all cases are handled, shape is never here
      // If a new kind is added, this becomes an error
      const _exhaustive: never = shape;
      throw new Error(`Unhandled shape: ${JSON.stringify(_exhaustive)}`);
  }
}

// ❌ Adding 'rectangle' without updating the switch → compile error on _exhaustive line
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'rectangle'; width: number; height: number }; // ← new

assertNever Helper

// ✅ Reusable helper for exhaustive checks across the codebase
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unhandled value: ${JSON.stringify(value)}`);
}

switch (shape.kind) {
  case 'circle': return Math.PI * shape.radius ** 2;
  case 'square': return shape.side ** 2;
  default:       return assertNever(shape); // Compile error if case is missing
}

using / await using (TS 5.2)

The using keyword implements the Explicit Resource Management proposal. Resources declared with using are automatically disposed when the scope exits — even on error.

Synchronous Resource Disposal

// ✅ Object with [Symbol.dispose] is auto-disposed at end of scope
function getConnection() {
  const conn = openDatabaseConnection();

  return {
    query: conn.query.bind(conn),
    [Symbol.dispose]() { conn.close(); },
  };
}

function processData() {
  using conn = getConnection(); // conn.close() called when scope exits
  const data = conn.query('SELECT * FROM users');
  return data;
  // conn[Symbol.dispose]() called here automatically (even on throw)
}

Asynchronous Resource Disposal

// ✅ await using for async cleanup (file handles, streams)
async function writeReport() {
  await using file = await openFile('report.txt', 'w');
  // [Symbol.asyncDispose] called with await when scope exits

  await file.write('Report content...');
  // file is closed automatically, even if write throws
}

Built-in Resource Types

TypeScript 5.2+ adds [Symbol.dispose] to several built-in APIs:

// FileHandle (Node.js 20+)
await using fileHandle = await fs.promises.open('data.txt', 'r');

// DisposableStack for managing multiple resources
using stack = new DisposableStack();
const conn1 = stack.use(openConnection('db1'));
const conn2 = stack.use(openConnection('db2'));
// Both disposed on scope exit

Const Type Parameters (TS 5.0)

The const modifier on type parameters infers the most specific literal type instead of widening to a general type.

Without const: Type Is Widened

function identity<T>(value: T): T { return value; }

const result = identity({ x: 10, y: 'hello' });
//    result: { x: number, y: string }  ← widened, not literal

With const: Literal Types Preserved

function identity<const T>(value: T): T { return value; }

const result = identity({ x: 10, y: 'hello' });
//    result: { x: 10, y: 'hello' }  ← literal types preserved

// Useful for creating type-safe builders and tuples
function createRoute<const T extends string>(path: T) {
  return { path, matcher: (url: string) => url.startsWith(path) };
}

const route = createRoute('/api/users');
//    route.path: '/api/users'  ← literal, not string

Practical: Type-Safe Event Maps

function createEventMap<const T extends Record<string, unknown>>(events: T): T {
  return events;
}

const events = createEventMap({
  click: (e: MouseEvent) => void 0,
  focus: (e: FocusEvent) => void 0,
});
// events.click is typed as (e: MouseEvent) => void — not widened to Function

Common Pitfalls

NoInfer requires TS 5.4: Earlier versions can simulate it with T & {} but with less precision. Check tsconfig.json target and TypeScript version.

Branded types require cast at creation: The as UserId cast at the createUserId function is the only place where the type is unsound. Keep constructors narrow and validated.

using is a Stage 3 proposal: Requires "lib": ["ES2022", "ESNext.Disposable"] or "target": "ESNext" in tsconfig.json. Node.js 20+ supports Symbol.dispose natively.

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "ESNext.Disposable"]
  }
}

Const type parameters don’t replace as const: as const works on values at call sites; const type parameters work on the generic function definition. Both can be combined.