TypeScript
·11 min read·↗

How to Check Types in TypeScript (The Complete Guide)

Main cover illustration for article: How to Check Types in TypeScript (The Complete Guide)
Summarize with AI:

When I first started building enterprise applications with TypeScript, I ran into a confusing scenario that almost every developer encounters: I tried to write if (data instanceof UserInterface) or typeof data === 'UserProfile', and nothing worked as expected.

Why does this happen? The answer lies in the fundamental difference between compile-time types and runtime JavaScript execution.

In this guide, I will share everything you need to know about checking types in TypeScript. We will cover why type erasure happens, how native JavaScript operators (typeof, instanceof, in) perform type narrowing, how to leverage discriminated unions and custom type predicates, and how to use modern schema validators like Zod and Valibot for complete runtime safety.


The Core Concept: Compile-Time vs. Runtime (Type Erasure)

Before writing any type checks, we must understand how TypeScript works behind the scenes.

TypeScript provides a static type system that validates your code while you write it in your editor and during the build step (tsc). However, once the TypeScript compiler emits JavaScript, all types, interfaces, type aliases, and generic type parameters are completely erased.

// 1. TypeScript source code
interface User {
  id: string;
  name: string;
  isAdmin: boolean;
}
 
function printUser(user: User) {
  console.log(user.name);
}

When compiled to JavaScript, the output is plain JavaScript without any trace of interface User:

// 2. Compiled JavaScript output (Types are erased!)
function printUser(user) {
  console.log(user.name);
}

Note: Because interfaces and type aliases do not exist in the compiled JavaScript bundle, you cannot inspect them at runtime using JavaScript operators.

To check types when your application runs in the browser or on Node.js, TypeScript relies on Type Narrowing—using runtime JavaScript checks that the TypeScript compiler statically analyzes.

Now that we understand why type erasure happens, let's explore the first and simplest runtime check: the typeof operator.


1. Checking Primitive Types with typeof

The JavaScript typeof operator checks the primitive data type of a variable at runtime. TypeScript's compiler understands typeof inside conditional blocks and automatically narrows the variable type.

typeof returns one of the following strings:

  • "string"
  • "number"
  • "boolean"
  • "undefined"
  • "symbol"
  • "bigint"
  • "function"
  • "object"

Example: Handling Union Types

Let's look at a function that formats an invoice identifier or monetary amount:

function formatIdentifier(id: string | number): string {
  // At this point, id is string | number
  if (typeof id === 'number') {
    // TypeScript narrows id to: number
    return `INV-${id.toFixed(0).padStart(6, '0')}`;
  }
 
  // TypeScript narrows id to: string
  return `INV-${id.trim().toUpperCase()}`;
}
 
console.log(formatIdentifier(42));        // "INV-000042"
console.log(formatIdentifier("  es-99 ")); // "INV-ES-99"

The typeof Gotchas to Watch Out For

While typeof is great for numbers, strings, and booleans, it has well-known JavaScript quirks:

  1. typeof null === 'object': In JavaScript, typeof null returns "object". If you want to verify that a value is a real object, always check for null:
    function isObject(val: unknown): boolean {
      return typeof val === 'object' && val !== null;
    }
  2. Arrays return "object": typeof [1, 2, 3] returns "object". Use Array.isArray(val) to check for arrays.

While typeof works well for primitive values, real-world apps often deal with class instances. Let's look at how instanceof solves that.


2. Checking Class Instances with instanceof

Unlike interfaces, classes exist at runtime as constructor functions and prototypes. Because classes survive compilation, we can use the JavaScript instanceof operator to test whether an object prototype chain contains the constructor.

Real-World Example: Custom Error Handling and Domain Entities

Consider an invoice processing workflow with domain classes and custom error types:

class ApplicationError extends Error {
  constructor(message: string, public readonly statusCode: number) {
    super(message);
    this.name = 'ApplicationError';
  }
}
 
class NetworkError extends ApplicationError {
  constructor(message: string) {
    super(message, 503);
    this.name = 'NetworkError';
  }
}
 
class ValidationError extends ApplicationError {
  constructor(message: string, public readonly invalidField: string) {
    super(message, 400);
    this.name = 'ValidationError';
  }
}
 
function handleError(error: unknown) {
  if (error instanceof ValidationError) {
    // TypeScript knows error has invalidField and statusCode
    console.error(`Validation failed on field [${error.invalidField}]: ${error.message}`);
  } else if (error instanceof NetworkError) {
    console.error(`Network issue (${error.statusCode}): ${error.message}`);
  } else if (error instanceof Error) {
    console.error(`Generic Error: ${error.message}`);
  } else {
    console.error('An unexpected non-error occurred:', error);
  }
}

When Does instanceof Fail?

instanceof relies on JavaScript prototype references:

  • Plain JSON objects: Data received from a fetch() call or JSON.parse() does not have the prototype of your TypeScript class. Calling response instanceof MyClass will return false.
  • Multiple execution realms: If objects are created in different iframes or Node.js VM contexts, the constructors have different references in memory.

For plain objects and API payloads, we need structural checks. That brings us to our next tool: the in operator.


3. Checking Object Properties with the in Operator

The in operator tests whether a specific property exists on an object or its prototype chain. TypeScript uses 'property' in object to narrow union types based on property existence.

Example: Differentiating Object Shapes

Imagine we handle two types of users in our application:

type RegisteredUser = {
  id: string;
  email: string;
  profileUrl: string;
};
 
type GuestUser = {
  id: string;
  sessionToken: string;
};
 
type AppUser = RegisteredUser | GuestUser;
 
function authenticateUser(user: AppUser) {
  if ('email' in user) {
    // TypeScript automatically narrows user to RegisteredUser
    console.log(`Sending login confirmation to ${user.email}`);
  } else {
    // TypeScript narrows user to GuestUser
    console.log(`Continuing with guest session: ${user.sessionToken}`);
  }
}

The in operator is clean and requires no special boilerplate. However, when managing complex state machines or domain models, there is an even cleaner pattern: Discriminated Unions.


4. Narrowing with Discriminated Unions (Tagged Unions)

Discriminated unions (also known as tagged unions or algebraic data types) are one of my favorite features in TypeScript.

A discriminated union consists of multiple object types that share a single common literal property (the discriminant or tag), such as type, kind, or status.

Example: Handling Async HTTP States

type IdleState = {
  status: 'idle';
};
 
type LoadingState = {
  status: 'loading';
};
 
type SuccessState<T> = {
  status: 'success';
  data: T;
  receivedAt: Date;
};
 
type ErrorState = {
  status: 'error';
  error: Error;
};
 
type AsyncState<T> = IdleState | LoadingState | SuccessState<T> | ErrorState;

Type-Safe State Reducer with Exhaustiveness Checking

When using a switch statement on the discriminant property (status), TypeScript narrows each branch automatically. We can also add an exhaustiveness check using the never type:

function renderState<T>(state: AsyncState<T>): string {
  switch (state.status) {
    case 'idle':
      return 'Ready to start.';
    case 'loading':
      return 'Fetching data, please wait...';
    case 'success':
      return `Data loaded successfully at ${state.receivedAt.toLocaleTimeString()}`;
    case 'error':
      return `Error: ${state.error.message}`;
    default: {
      // Exhaustiveness check: If a new status is added to AsyncState,
      // TypeScript will report a compile-time error right here!
      const _unreachable: never = state;
      throw new Error(`Unhandled state: ${JSON.stringify(_unreachable)}`);
    }
  }
}

Note: Discriminated unions make invalid states unrepresentable in your code, eliminating whole categories of runtime bugs.

Discriminated unions work well when you control the object definitions. But what if you receive completely untyped data from an external source? Let's build custom type guards.


5. Custom Type Guards and Assertion Functions

When built-in operators aren't enough, TypeScript allows us to write custom type guards using Type Predicates.

Type Predicates (value is Type)

A type predicate is a function whose return type is value is TargetType. If the function returns true, TypeScript narrows the argument to TargetType in the calling scope.

interface InvoiceItem {
  id: string;
  sku: string;
  price: number;
  quantity: number;
}
 
// Custom Type Guard
function isInvoiceItem(item: unknown): item is InvoiceItem {
  return (
    typeof item === 'object' &&
    item !== null &&
    'id' in item &&
    typeof (item as Record<string, unknown>).id === 'string' &&
    'sku' in item &&
    typeof (item as Record<string, unknown>).sku === 'string' &&
    'price' in item &&
    typeof (item as Record<string, unknown>).price === 'number' &&
    'quantity' in item &&
    typeof (item as Record<string, unknown>).quantity === 'number'
  );
}
 
// Usage with unknown data
function processRawItem(rawData: unknown) {
  if (isInvoiceItem(rawData)) {
    // TypeScript knows rawData is InvoiceItem!
    console.log(`Processing SKU: ${rawData.sku}, Total: ${rawData.price * rawData.quantity}`);
  } else {
    console.warn('Invalid item format received');
  }
}

Assertion Functions (asserts value is Type)

If you prefer throwing an error instead of wrapping code inside if checks, you can use assertion functions:

function assertIsInvoiceItem(item: unknown): asserts item is InvoiceItem {
  if (!isInvoiceItem(item)) {
    throw new TypeError('Given value is not a valid InvoiceItem.');
  }
}
 
function handleItem(item: unknown) {
  // Throws if invalid, otherwise execution continues
  assertIsInvoiceItem(item);
 
  // For all subsequent lines, item is typed as InvoiceItem!
  console.log(item.sku.toUpperCase());
}

Writing manual checks for every field in deeply nested objects can be tedious and error-prone. In modern TypeScript projects, we use runtime schema validation libraries.


6. Modern Runtime Validation: Zod and Valibot

For untrusted external data—such as REST API responses, form inputs, configuration files, and WebSockets—the modern standard in the TypeScript ecosystem is schema-driven parsing with Zod or Valibot.

These libraries validate the data at runtime, strip malicious unexpected fields, provide detailed error messages, and automatically infer the TypeScript type so you never have to write duplicate interfaces.

Validating Data with Zod

import { z } from 'zod';
 
// 1. Define the Runtime Schema
const UserSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(2),
  email: z.string().email(),
  role: z.enum(['admin', 'member', 'guest']).default('member'),
  age: z.number().int().positive().optional(),
});
 
// 2. Automatically derive the static TypeScript type
type User = z.infer<typeof UserSchema>;
 
// 3. Parse unknown input safely
async function fetchUserProfile(userId: string): Promise<User> {
  const response = await fetch(`/api/users/${userId}`);
  const rawData: unknown = await response.json();
 
  const parseResult = UserSchema.safeParse(rawData);
 
  if (!parseResult.success) {
    console.error('Validation errors:', parseResult.error.format());
    throw new Error('Invalid user payload from API');
  }
 
  // parseResult.data is fully typed as User!
  return parseResult.data;
}

Lightweight Alternative: Valibot

If bundle size is critical for your client-side application, Valibot offers a modular, tree-shakable alternative that functions similarly with minimal bundle footprint:

import * as v from 'valibot';
 
const ProductSchema = v.object({
  id: v.pipe(v.string(), v.uuid()),
  title: v.pipe(v.string(), v.minLength(1)),
  price: v.pipe(v.number(), v.minValue(0)),
});
 
type Product = v.InferOutput<typeof ProductSchema>;
 
function parseProduct(input: unknown): Product {
  return v.parse(ProductSchema, input);
}

With schema validation, you get both rock-solid runtime safety and zero-drift TypeScript types.


Quick Reference & Decision Matrix

To help you choose the right approach for your codebase, here is a comparison of all the type checking techniques:

TechniqueWorks OnWhen to UseRuntime Cost
typeofPrimitives (string, number, boolean, symbol, bigint, function)Quick primitive checks and basic function parametersZero
instanceofClasses, DOM Elements, Custom Errors (Error, Date, RegExp)Catching specific exceptions and OOP class hierarchiesMinimal
in OperatorObject propertiesDifferentiating union types with unique property keysMinimal
Discriminated UnionsObject unions with a common literal tag (status, kind)State machines, API results, Redux/NgRx actionsMinimal
Custom Type Guards (is)Complex data structuresSmall internal validations without third-party dependenciesLow
Zod / ValibotAny external / unknown dataAPI boundaries, user inputs, configuration filesLow (micro-library)

Conclusion & Next Steps

Checking types effectively in TypeScript requires understanding the boundary between compile-time types and runtime values:

  1. At compile-time: TypeScript types guide your editor, catch bugs early, and document your interfaces.
  2. At runtime: Native operators (typeof, instanceof, in), discriminated unions, and custom type guards protect your application execution.
  3. At external boundaries: Use schema validation libraries like Zod or Valibot to turn untrusted unknown data into guaranteed types.

By combining these strategies, you can write resilient, type-safe TypeScript applications that never fail unexpectedly in production.

If you want to deepen your TypeScript skills, check out these related guides on my blog:

Part of the TypeScript Series

Mastering the language of the web. From basic types to advanced generics and patterns.

View Entire Series

Frequently Asked Questions

Can I check TypeScript interfaces or type aliases at runtime with typeof or instanceof?

No. TypeScript types and interfaces are completely removed during the compilation step (a process known as type erasure). JavaScript has no record of TypeScript interfaces at runtime. To validate data structures at runtime, you need runtime type guards, the in operator, discriminated unions, or schema validation libraries like Zod or Valibot.

What is type narrowing in TypeScript?

Type narrowing is TypeScript's ability to refine a broad type (like string | number, unknown, or a union of objects) into a more specific type within a conditional code block. TypeScript analyzes control flow statements like if (typeof x === 'string') or if ('role' in user) and automatically narrows the variable's type inside that branch.

What is the difference between a custom type guard and an assertion function in TypeScript?

A custom type guard uses the 'parameter is Type' return signature and returns a boolean (true/false) to narrow the type inside an if-statement. An assertion function uses the 'asserts condition' or 'asserts parameter is Type' syntax and throws an error if the check fails, narrowing the variable's type for the rest of the current execution scope without nesting code in if-blocks.

When should I use schema validation libraries like Zod or Valibot instead of manual type guards?

Manual type guards are fast and zero-dependency for simple or internal checks. However, when dealing with untrusted external boundaries—such as HTTP API responses, user form inputs, webhook payloads, or localStorage—libraries like Zod and Valibot provide robust runtime validation, deep nested parsing, friendly error messages, and automatic static type inference with z.infer.

Related Articles

Share this article

If you found this guide helpful, consider sharing it with your team or fellow developers.


Real Software. Real Lessons.

I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.

User avatar
User avatar
User avatar
User avatar
+13K

Join 13,800+ developers and readers.

No spam ever. Unsubscribe at any time.