How to Check Types in TypeScript (The Complete Guide)

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:
typeof null === 'object': In JavaScript,typeof nullreturns"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; }- Arrays return
"object":typeof [1, 2, 3]returns"object". UseArray.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 orJSON.parse()does not have the prototype of your TypeScript class. Callingresponse instanceof MyClasswill returnfalse. - 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:
| 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:
- At compile-time: TypeScript types guide your editor, catch bugs early, and document your interfaces.
- At runtime: Native operators (
typeof,instanceof,in), discriminated unions, and custom type guards protect your application execution. - At external boundaries: Use schema validation libraries like Zod or Valibot to turn untrusted
unknowndata 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:
Mastering the language of the web. From basic types to advanced generics and patterns.
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
Why You Should Avoid Using 'any' in TypeScript
Why using any in TypeScript leads to hidden runtime bugs, and how to replace it with unknown, custom type guards, utility types, and the satisfies operator.
3 Ways of Type Transformation in Typescript
When we use types or interfaces, the typescript compiler enforces the object fit with them to avoid runtime errors for missing fields. Sometimes we want the flexibility to create an object without breaking the contract with the interface type. For ex...
Combining Types and Interfaces with & and | in TypeScript
Learn how to compose TypeScript types using Union (|), Intersection (&), and interface extends with practical examples, decision matrices, and real-world patterns.
Interfaces in Typescript with an Example
The interface is an excellent Typescript feature and helps to write structured and explicit code. The interface helps you describe a structure like fields without values or methods without implementation and forces objects and classes to have. Interf...
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.
Join 13,800+ developers and readers.
No spam ever. Unsubscribe at any time.