Skip to content
Ayhan Sipahi Ayhan Sipahi

7 Lesser-Known TypeScript Features: satisfies, Branded Types, and More

Seven lesser-known TypeScript features that improve production code: satisfies, noUncheckedIndexedAccess, branded types, discriminated unions, and more.

Turning on strict in tsconfig.json feels like the end of the type-safety conversation. It is not. Indexed access still returns a type that hides undefined, a UserID still slots into a function that expects an OrderID, and a member added to a union still slips past every switch you already wrote.

The highest-value change is noUncheckedIndexedAccess, a compiler flag that strict does not turn on. Add it first, then reach for the type-level tools that cover the rest: satisfies, branded types, discriminated unions with exhaustiveness checking, type predicates, template literal types, and infer. All seven are compile-time constructs, so none of them cost anything at runtime.

The Gaps Strict Mode Leaves Open

Each of these problems reaches production through code that type-checks cleanly.

Type Safety Gaps: Using any everywhere defeats TypeScript’s purpose, allowing type errors to slip through to runtime.

Array Access Without Guards: The expression array[5] can return undefined, but TypeScript’s default configuration doesn’t warn you - even with strict mode enabled.

Structural Type Confusion: TypeScript uses structural typing, meaning UserID and OrderID (both numbers) are interchangeable, leading to data corruption when IDs get mixed up.

Incomplete Union Handling: Adding a new case to a state type doesn’t break existing switch statements, causing unhandled cases in production.

Weak Validation Boundaries: External data from APIs needs runtime validation, but the connection between validation logic and type narrowing is often unclear.

Configuration Type Loss: Using type assertions on configuration objects loses valuable type information that could catch errors.

Nested Type Extraction: Complex generic types require manual type extraction, leading to duplication and drift.

The Seven Features

1. The satisfies Operator with Const Assertions

The satisfies operator (TypeScript 4.9+) combines type validation with precise literal type inference - giving you both compile-time checking and specific types.

The Problem: Configuration objects need validation against a type schema, but using as type assertions loses literal type information.

// Without satisfies - loses type information
const routes = {
  home: { path: '/', methods: ['GET', 'POST'] },
  api: { path: '/api', methods: ['POST'] }
} as const;
// routes.home.path is '/' (good), but no validation

The Solution: Use as const satisfies for immutability plus validation.

type Route = {
  path: string;
  methods: readonly ('GET' | 'POST' | 'PUT' | 'DELETE')[];
};

type Routes = Record<string, Route>;

const routes = {
  home: { path: '/', methods: ['GET', 'POST'] },
  api: { path: '/api', methods: ['POST'] },
  // TypeScript error if you uncomment:
  // invalid: { path: '/bad', methods: ['INVALID'] }
} as const satisfies Routes;

// Now: type-checked AND precise literal types
routes.home.path; // type: '/' (literal, not string)
routes.api.methods; // type: readonly ['POST']

When to use: API configurations, theme definitions, routing tables, feature flags.

When NOT to use: Dynamic runtime data, values that change frequently.

What it catches: A typo in an HTTP method or a missing path fails the build instead of a request. The error also points at the offending key inside the object literal rather than at the type alias, which is what makes it useful in a large config file.

2. noUncheckedIndexedAccess - The Missing Strict Flag

Here’s a critical configuration detail: the noUncheckedIndexedAccess compiler option is not included in strict mode, yet it prevents an entire class of “Cannot read property of undefined” errors.

The Problem: Array and object indexed access can return undefined, but TypeScript’s default behavior doesn’t reflect this reality.

// tsconfig.json - default strict mode
{
  "compilerOptions": {
    "strict": true
  }
}

// This code looks safe but isn't
const users = ['Alice', 'Bob'];
const user = users[5]; // type: string (WRONG - it's actually undefined!)
user.toUpperCase(); // Runtime error: Cannot read property 'toUpperCase' of undefined

The Solution: Enable noUncheckedIndexedAccess explicitly.

// tsconfig.json - production-ready strict mode
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true  // Add this!
  }
}

// Now TypeScript reflects reality
const users = ['Alice', 'Bob'];
const user = users[5]; // type: string | undefined (CORRECT)

// TypeScript forces you to handle undefined
if (user) {
  user.toUpperCase(); // Safe
}

// Or use optional chaining
const upperName = users[5]?.toUpperCase();

Migration cost: Turning this on in an existing codebase produces one error per unguarded index, so the count scales with how much array work the code does. Most are mechanical fixes: an ?., a length check, or a destructure with a default. Do them in one pass rather than file by file, or the flag gets switched back off.

Why underutilized: This option isn’t part of strict mode, so many developers don’t know it exists.

3. Branded Types for Nominal Type Safety

TypeScript uses structural typing, meaning two types with identical structure are interchangeable. While this is powerful, it can lead to subtle bugs when you want nominal typing behavior.

The Problem: Structurally identical types (both numbers) can be confused.

// The problem
type UserID = number;
type OrderID = number;

function getUser(id: UserID) { /* ... */ }
function getOrder(id: OrderID) { /* ... */ }

const userId: UserID = 123;
const orderId: OrderID = 456;

getUser(orderId); // TypeScript allows this (BAD!)

In a multi-tenant system this failure mode does not announce itself. The query runs successfully against the wrong tenant, so the mistake surfaces as leaked data rather than as an exception in the logs.

The Solution: Branded types create nominal-like behavior at the type level.

// Generic brand utility
type Brand<K, T> = K & { readonly __brand: T };

type UserID = Brand<number, 'UserID'>;
type OrderID = Brand<number, 'OrderID'>;

// Constructor functions for creating branded values
const UserID = (id: number): UserID => id as UserID;
const OrderID = (id: number): OrderID => id as OrderID;

const userId = UserID(123);
const orderId = OrderID(456);

getUser(orderId); // BAD: TypeScript error: Type 'OrderID' is not assignable to type 'UserID'

Production use cases:

  • Database IDs (preventing ID confusion in multi-tenant systems)
  • Currency values (USD vs EUR)
  • Email addresses vs general strings
  • Validated vs unvalidated user input

Performance: Zero runtime overhead - the brand exists only at the type level and is erased during compilation.

Advanced pattern: Combine branded types with validation functions.

type Email = Brand<string, 'Email'>;

const Email = (value: string): Email => {
  if (!value.includes('@') || !value.includes('.')) {
    throw new Error('Invalid email format');
  }
  return value as Email;
};

// Now email variables are guaranteed to be validated
function sendEmail(to: Email) {
  // No need to validate again - type system guarantees it
}

4. Discriminated Unions with Exhaustiveness Checking

State machines and API responses benefit greatly from discriminated unions combined with exhaustiveness checking through the never type.

The Problem: Switch statements that don’t handle all cases lead to runtime failures.

type Result<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }
  | { status: 'loading' };

// Without exhaustiveness checking
function handleResult<T>(result: Result<T>) {
  switch (result.status) {
    case 'success':
      return result.data;
    case 'error':
      throw result.error;
    // Forgot 'loading' case - no error!
  }
  // Returns undefined for loading state - bug!
}

The Solution: Use the never type to enforce exhaustiveness.

function handleResult<T>(result: Result<T>) {
  switch (result.status) {
    case 'success':
      return result.data;
    case 'error':
      throw result.error;
    case 'loading':
      return null;
    default:
      // This forces TypeScript to check all cases
      const exhaustive: never = result;
      throw new Error(`Unhandled case: ${exhaustive}`);
  }
}

// Adding a new status breaks compilation
type Result<T> =
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error }
  | { status: 'loading' }
  | { status: 'cancelled' }; // TypeScript now errors in handleResult

Why powerful: When you add a new union member, TypeScript immediately highlights every location that needs updating. Adding a 'retry' state to an API client stops being an audit of every call site and becomes a compiler-generated task list.

success

error

loading

cancelled

unhandled

API Request

Result Type

Return Data

Throw Error

Return Null

Return Undefined

Compile Error via never

5. Type Predicates vs Assertion Functions

TypeScript offers two patterns for type narrowing: type predicates and assertion functions. Understanding when to use each is crucial for clean, type-safe validation logic.

Type Predicate: Returns a boolean, used in conditional checks.

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

// Use in conditionals
const data: unknown = getSomeData();
if (isString(data)) {
  data.toUpperCase(); // data is string here
}

Assertion Function: Throws or returns void, narrows the type for the rest of the scope.

function assertString(value: unknown): asserts value is string {
  if (typeof value !== 'string') {
    throw new Error('Not a string');
  }
}

// Use for validation
const data: unknown = getSomeData();
assertString(data); // throws if not string
data.toUpperCase(); // data is string for rest of scope

Advanced example: Custom domain object validation.

type User = {
  id: number;
  email: string;
  name: string;
};

function assertUser(obj: unknown): asserts obj is User {
  if (
    typeof obj !== 'object' ||
    obj === null ||
    !('id' in obj) ||
    !('email' in obj) ||
    !('name' in obj) ||
    typeof obj.id !== 'number' ||
    typeof obj.email !== 'string' ||
    typeof obj.name !== 'string'
  ) {
    throw new Error('Invalid user object');
  }
}

// API response validation
async function fetchUser(id: number): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data: unknown = await response.json();

  assertUser(data); // validates structure
  return data; // TypeScript knows it's User
}

When to use predicates: Optional checks, filter operations, conditional logic.

When to use assertions: Mandatory validation, parse functions, guard clauses at system boundaries.

Critical gotcha: Assertion functions should throw on failure, not return false. Returning false doesn’t narrow the type.

6. Template Literal Types for String Patterns

Template literal types (TypeScript 4.1+) enable type-safe string manipulation with zero runtime cost.

The Problem: String patterns and conventions need compile-time validation.

CSS Unit Types:

type CSSUnit = 'px' | 'em' | 'rem' | '%';
type CSSValue<T extends string> = `${number}${T}`;

type Padding = CSSValue<CSSUnit>;
const padding: Padding = '10px'; // const invalid: Padding = '10abc'; // BAD: Error

Event Handler Naming:

type EventName = 'click' | 'focus' | 'blur' | 'hover';
type EventHandler<T extends EventName> = `on${Capitalize<T>}`;

type ClickHandler = EventHandler<'click'>; // 'onClick'
type FocusHandler = EventHandler<'focus'>; // 'onFocus'

type Handlers = {
  [K in EventName as EventHandler<K>]: (event: Event) => void;
};
// Generates: { onClick: ..., onFocus: ..., onBlur: ..., onHover: ... }

API Route Typing:

type HTTPMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
type Endpoint = '/users' | '/posts' | '/comments';
type Route = `${HTTPMethod} ${Endpoint}`;

const route: Route = 'GET /users'; // const invalid: Route = 'GET /invalid'; // BAD: Error

// Type-safe route matcher
function matchRoute(route: Route): void {
  // TypeScript knows route is valid
}

Path Parameter Extraction (advanced):

type ExtractParams<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string }
    : T extends `${infer _Start}:${infer Param}`
    ? { [K in Param]: string }
    : {};

type UserRoute = '/users/:userId/posts/:postId';
type Params = ExtractParams<UserRoute>; // { userId: string; postId: string }

function getPost(params: Params) {
  console.log(params.userId, params.postId); // Type-safe
  // console.log(params.invalid); // BAD: Error
}

Where it helps: Type-safe API clients, CSS-in-JS libraries, internationalization key validation, database query builders.

Performance: All computation happens at compile time - zero runtime cost.

7. The infer Keyword for Type Extraction

The infer keyword allows you to extract types from complex generic structures, enabling powerful type-level programming.

Extract Promise Value Type:

type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;

type A = UnwrapPromise<Promise<string>>; // string
type B = UnwrapPromise<number>; // number

// Useful for typing async functions
declare function fetchData(): Promise<{ id: number; name: string }>;

type Data = UnwrapPromise<ReturnType<typeof fetchData>>;
// { id: number; name: string }

Extract Array Element Type:

type ElementType<T> = T extends (infer U)[] ? U : T;

type Items = ElementType<string[]>; // string
type Single = ElementType<number>; // number

// Useful for generic array utilities
function first<T extends any[]>(arr: T): ElementType<T> | undefined {
  return arr[0];
}

Type-Safe API Client:

type APIResponse = {
  '/users': { id: number; name: string }[];
  '/posts': { id: number; title: string; body: string }[];
  '/comments': { id: number; text: string; authorId: number }[];
};

type FetchResult<T extends keyof APIResponse> = APIResponse[T];

async function fetchAPI<T extends keyof APIResponse>(
  endpoint: T
): Promise<FetchResult<T>> {
  const res = await fetch(endpoint);
  return res.json();
}

// Type-safe usage
const users = await fetchAPI('/users');
// type: { id: number; name: string }[]

const posts = await fetchAPI('/posts');
// type: { id: number; title: string; body: string }[]

Deep Partial Utility:

type DeepPartial<T> = T extends object
  ? { [P in keyof T]?: DeepPartial<T[P]> }
  : T;

type Config = {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
};

type PartialConfig = DeepPartial<Config>;
// All properties optional recursively
const config: PartialConfig = {
  database: {
    credentials: {
      username: 'admin'
      // password optional
    }
    // host and port optional
  }
};

Use cases: Generic utility types, library authoring, complex type transformations.

Learning curve: Moderate to advanced, but the power is worth the investment.

Adoption in an Existing Codebase

Essential Configuration

A production-ready tsconfig.json that turns on every flag mentioned above:

{
  "compilerOptions": {
    // Standard strict flags
    "strict": true,

    // Additional strictness (NOT in strict mode!)
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "exactOptionalPropertyTypes": true,

    // Modern module handling (TS 5.0+)
    "verbatimModuleSyntax": true,
    "moduleDetection": "force",

    // Target modern JavaScript
    "target": "ES2022",
    "lib": ["ES2022", "DOM"],

    // Better imports
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "allowJs": true,

    // Performance
    "skipLibCheck": true,
    "incremental": true,

    // Unused code detection
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "allowUnreachableCode": false,
    "allowUnusedLabels": false
  }
}

Migration Path

Three phases, in this order:

Phase 1: Enable strict mode

  • Focus on noImplicitAny first: replace any with unknown or a proper type
  • Use // @ts-expect-error comments temporarily for the cases you cannot resolve in the same pass

Phase 2: Add noUncheckedIndexedAccess

  • Most fixes are adding ?. optional chaining or if (arr[i]) guards
  • Read each error before silencing it. Some of them mark a real bug

Phase 3: Adopt the type-level patterns (ongoing)

  • Introduce branded types for critical domain identifiers
  • Replace switch statements with discriminated unions + exhaustiveness
  • Use satisfies for configuration objects
  • Gradual adoption as code is refactored

Performance Considerations

Compile Time: The extra flags add work to type checking, and the cost tracks codebase size more closely than it tracks how many of these patterns you use. Measure it on your own project before deciding it is too slow; incremental and skipLibCheck absorb most of it.

Runtime: Zero impact - all type information is erased during compilation.

Editor Responsiveness: The part that gets slow is deeply recursive conditional types, not satisfies or branded types. If the language server starts lagging, look at the infer chains first, then split the codebase with project references.

Common Pitfalls

Pitfall 1: Over-using Type Assertions

Type assertions with as bypass type checking entirely.

// Bad - no validation
const user = response as User;

// Good - validate first
function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'email' in obj
  );
}
const user = isUser(response) ? response : null;

Pitfall 2: Forgetting noUncheckedIndexedAccess Exists

Even with strict: true, indexed access isn’t safe unless you explicitly enable this option.

Pitfall 3: Complex infer Chains

Overly complex type utilities become hard to maintain. Break them into smaller, named types with clear comments.

// Hard to read
type Complex<T> = T extends { a: infer A extends { b: infer B } } ? B : never;

// Better - break down with clear names
type ExtractA<T> = T extends { a: infer A } ? A : never;
type ExtractB<T> = T extends { b: infer B } ? B : never;
type Result<T> = ExtractB<ExtractA<T>>;

When to Use Each Feature

FeatureBest ForAvoid When
satisfiesConfig objects, const dataDynamic runtime data
noUncheckedIndexedAccessAll projects (should be default)Legacy code with heavy array access
Branded typesDomain IDs, validated stringsFrequently converted between systems
Discriminated unionsState machines, API responsesSimple binary states (use boolean)
Template literalsString patterns, type-safe keysComplex parsing logic
inferLibrary code, reusable utilitiesOne-off type manipulations
Type assertionsValidated external dataInternal code (use proper types)

The default holds for most codebases: turn on noUncheckedIndexedAccess, then add branded types and exhaustive unions wherever a wrong value would corrupt data or skip a state. Override it where the cost outruns the benefit. A legacy module that indexes arrays in tight loops produces more noise than bugs under the flag, and a one-off type transformation rarely justifies a hand-written infer chain. If you change one line this week, make it the tsconfig one.

References

Related posts