Skip to main content

TS Ep 10: Custom Type Guards & Assertion Functions

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
typescript - This article is part of a series.
Part 10: This Article
Standard JavaScript operators (typeof, instanceof) are often insufficient for validating complex object shapes or untrusted API payloads. Custom Type Guards (arg is Type) and Assertion Functions (asserts arg is Type) allow you to write reusable runtime validators that inform TypeScript’s compiler of narrowing decisions.

1. The Limitations of Built-in Guards
#

Imagine receiving data from a fetch() call. The payload is typed as unknown. You want to check if the payload matches a complex interface User:

interface User {
  id: string;
  email: string;
  role: "admin" | "user";
}

// Standard boolean helper function
function checkIsUser(data: any): boolean {
  return (
    typeof data === "object" &&
    data !== null &&
    typeof data.id === "string" &&
    typeof data.email === "string" &&
    (data.role === "admin" || data.role === "user")
  );
}

const response: unknown = { id: "101", email: "alice@acme.com", role: "admin" };

if (checkIsUser(response)) {
  // ❌ Compiler Error: Property 'email' does not exist on type 'unknown'.
  // console.log(response.email); 
}

Why did this fail?
#

Even though checkIsUser returned true at runtime, TypeScript’s type checker treats checkIsUser as returning a plain, uninformative boolean. The compiler does not inspect the internal boolean logic of standard functions to infer narrowing across function boundaries.


2. User-Defined Type Guards (parameter is Type)
#

To tell TypeScript that a boolean function acts as a type narrowing rule, change its return type signature to a Type Predicate using the parameterName is TypeToNarrow syntax:

interface User {
  id: string;
  email: string;
  role: "admin" | "user";
}

// 🟢 Custom Type Guard Function
function isUser(data: unknown): data is User {
  return (
    typeof data === "object" &&
    data !== null &&
    typeof (data as User).id === "string" &&
    typeof (data as User).email === "string" &&
    ((data as User).role === "admin" || (data as User).role === "user")
  );
}

Now, when isUser() is evaluated inside an if statement, TypeScript accepts the predicate and narrows the variable inside the if block:

const response: unknown = { id: "101", email: "alice@acme.com", role: "admin" };

if (isUser(response)) {
  // 🟢 WORKED! TypeScript knows 'response' MUST be of type 'User' here!
  console.log(`User Email: ${response.email.toLowerCase()}`);
  console.log(`Role: ${response.role.toUpperCase()}`);
} else {
  // Inside the else block, response remains 'unknown' (or non-User)
  console.error("Invalid user payload received!");
}

3. Array Filtering with Type Guards
#

One of the most common applications of Type Guards is stripping null or undefined values from an array using .filter().

The Array .filter() Typing Problem
#

const mixedArray: (string | null | undefined)[] = ["Alice", null, "Bob", undefined, "Charlie"];

// ❌ FAILED: Standard filter returns (string | null | undefined)[] !
const badFilter = mixedArray.filter((x) => x !== null && x !== undefined);
// badFilter is STILL typed as (string | null | undefined)[]

The Solution: NonNullable Type Guard
#

By supplying a Type Guard function to .filter(), TypeScript narrows the resulting array element type:

// Reusable Type Guard for Non-Nullable values
function isDefined<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined;
}

const mixedArray: (string | null | undefined)[] = ["Alice", null, "Bob", undefined, "Charlie"];

// 🟢 WORKED: 'cleanArray' is inferred as string[] !
const cleanArray: string[] = mixedArray.filter(isDefined);

console.log(cleanArray); // ["Alice", "Bob", "Charlie"]

4. Assertion Functions (asserts condition)
#

Introduced in TypeScript 3.7, Assertion Functions allow you to write functions that throw an error if a condition is not met, narrowing the caller’s variable type for the remainder of the current scope (without needing an if block!).

Syntax 1: asserts condition
#

function assert(condition: unknown, message: string): asserts condition {
  if (!condition) {
    throw new Error(`[Assertion Error]: ${message}`);
  }
}

function processValue(val: string | null) {
  // If 'val' is null, this throws!
  assert(val !== null, "Value cannot be null!");

  // TypeScript knows 'val' MUST be 'string' for all lines below!
  console.log(val.toUpperCase()); 
}

Syntax 2: asserts val is Type
#

You can combine assertions with type predicates to create dedicated validation functions:

interface Configuration {
  apiKey: string;
  timeoutMs: number;
}

function assertIsConfig(val: unknown): asserts val is Configuration {
  if (
    typeof val !== "object" ||
    val === null ||
    typeof (val as Configuration).apiKey !== "string" ||
    typeof (val as Configuration).timeoutMs !== "number"
  ) {
    throw new Error("Invalid configuration object provided!");
  }
}

function initSDK(rawInput: unknown) {
  // Execute assertion function
  assertIsConfig(rawInput);

  // 'rawInput' is automatically narrowed to Configuration for the rest of initSDK!
  console.log(`SDK Initialized with API Key: ${rawInput.apiKey}`);
  console.log(`Timeout: ${rawInput.timeoutMs}ms`);
}

5. Dangerous Pitfalls of Type Guards
#

Caution

The Type Predicate Trust Paradox: TypeScript does not analyze your Type Guard’s internal runtime implementation to verify if it is accurate. The compiler trusts your val is Type signature unconditionally.

If you write an incorrect Type Guard function:

// ❌ DANGEROUS BUG: Lies to the compiler!
function isNumberDangerous(val: unknown): val is number {
  return true; // Always returns true regardless of runtime value!
}

const text: unknown = "I am a string!";

if (isNumberDangerous(text)) {
  // TypeScript thinks 'text' is a number, but it's a string at runtime!
  // Throws runtime TypeError: text.toFixed is not a function!
  console.log(text.toFixed(2)); 
}

Always test your Type Guard validation logic thoroughly to ensure runtime predicate checks match the TypeScript type signature perfectly.


Summary & Next Steps
#

In this episode:

  • We learned why standard boolean functions do not narrow types across scope boundaries.
  • We built Custom Type Guards using parameter is Type predicates.
  • We fixed array .filter() type loss using generic non-nullable type guards.
  • We created Assertion Functions using asserts val is Type to narrow control flow without if statements.
  • We highlighted the contract risk of lying to the compiler with bad predicates.

In Episode 11: Discriminated Unions, we will combine literal types, union types, and type guards to build type-safe algebraic state machines!

typescript - This article is part of a series.
Part 10: This Article