---
title: "How to Check Types in TypeScript (The Complete Guide)"
date: "2022-08-10"
slug: "how-to-check-types-in-typescript"
author: "Dany Paredes"
canonical: "https://danywalls.com/how-to-check-types-in-typescript"
description: "Learn how to check and validate types in TypeScript at compile-time and runtime using typeof, instanceof, the in operator, discriminated unions, custom type guards, and Zod or Valibot."
---


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

```typescript
// 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`:

```javascript
// 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:

```typescript
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:
   ```typescript
   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:

```typescript
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:

```typescript
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

```typescript
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:

```typescript
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.

```typescript
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:

```typescript
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](https://zod.dev)** or **[Valibot](https://valibot.dev)**.

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

```typescript
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:

```typescript
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:

| Technique | Works On | When to Use | Runtime Cost |
| :--- | :--- | :--- | :--- |
| **`typeof`** | Primitives (`string`, `number`, `boolean`, `symbol`, `bigint`, `function`) | Quick primitive checks and basic function parameters | Zero |
| **`instanceof`** | Classes, DOM Elements, Custom Errors (`Error`, `Date`, `RegExp`) | Catching specific exceptions and OOP class hierarchies | Minimal |
| **`in` Operator** | Object properties | Differentiating union types with unique property keys | Minimal |
| **Discriminated Unions** | Object unions with a common literal tag (`status`, `kind`) | State machines, API results, Redux/NgRx actions | Minimal |
| **Custom Type Guards (`is`)** | Complex data structures | Small internal validations without third-party dependencies | Low |
| **Zod / Valibot** | Any external / unknown data | API boundaries, user inputs, configuration files | Low (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:

- [Why Avoid Using 'any' in TypeScript](https://danywalls.com/why-avoid-using-any-in-typescript)
- [3 Ways of Type Transformation in TypeScript](https://danywalls.com/3-ways-of-type-transformation-in-typescript)
- [Combining Types and Interfaces with & or | in TypeScript](https://danywalls.com/combining-types-and-interfaces-with-or-in-typescript)
- [Interfaces in TypeScript with an Example](https://danywalls.com/interfaces-in-typescript-with-an-example)

