Skip to content

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.

Ayhan Sipahi Ayhan Sipahi

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#

Type Safety Gaps: any used everywhere defeats the point of TypeScript. The type errors it hides don’t surface until runtime.

Array Access Without Guards: array[5] can return undefined without a warning. TypeScript’s default configuration stays silent about it, even under strict mode.

Structural Type Confusion: TypeScript’s structural typing treats UserID and OrderID (both numbers) as interchangeable. Mixed-up IDs corrupt data instead of throwing an error.

Incomplete Union Handling: Production code ends up with unhandled cases, because a new case added to a state type doesn’t break the switch statements written before it.

Weak Validation Boundaries: External API data needs runtime validation. The logic that performs it rarely ties back to type narrowing in an obvious way.

Configuration Type Loss: A type assertion on a configuration object erases the literal type information that could otherwise catch errors.

Nested Type Extraction: Complex generic types require manual extraction by hand, and the duplicated logic drifts over time.

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. Configuration objects need validation against a type schema, but as type assertions lose literal type information along the way.

// 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

as const satisfies recovers both: immutability and 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']

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. The noUncheckedIndexedAccess Compiler 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.

Array and object indexed access can return undefined, but TypeScript’s default behavior doesn’t reflect that 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

Turning on noUncheckedIndexedAccess explicitly fixes this:

// 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();

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. The option isn’t part of strict mode either, which is why 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
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.

Branded types close the gap: they 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'

Adding a validation function on top completes the guarantee:

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.

Switch statements that don’t handle every case 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 never type enforces 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

When you add a new union member, such as a 'retry' state on an API client, TypeScript immediately highlights every location that needs updating, so you no longer have to audit every call site by hand.

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.

A type predicate returns a boolean and works well 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
}

An assertion function throws or returns void, narrowing 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

The same pattern extends to validating a custom domain object:

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
}

Reach for a predicate in optional checks, filter operations, and conditional logic; reach for an assertion in mandatory validation, parse functions, and guard clauses at system boundaries. One gotcha matters more than the rest: assertion functions must throw on failure, not return false, since a false return never narrows the type.

6. Template Literal Types for String Patterns#

Template literal types (TypeScript 4.1+) enable type-safe string manipulation at the type level, catching pattern mistakes that a plain string type misses.

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
}

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
  }
};

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 takes the biggest hit: 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 impact is zero, since all type information is erased during compilation. Editor responsiveness is the one to watch: the part that gets slow is deeply recursive conditional types, not satisfies or branded types, so if the language server starts lagging, look at the infer chains first, then split the codebase with project references.

Where Type Assertions, Indexing, and infer Chains Break Down#

Type assertions that skip validation

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;

Forgetting noUncheckedIndexedAccess exists

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

Runaway 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.

References#

Related posts