Skip to main content

Understanding TypeScript Type Checking

TypeScript's type system helps catch errors during development before they reach production. When you specify types for variables, function parameters, and return values, TypeScript verifies these types match throughout your codebase. This article covers practical techniques for type checking in TypeScript, from basic variable checks to advanced patterns like custom type guards and enumerated types.

1. Checking a Variable's Type in TypeScript

You can easily check a variable's type using the typeof operator. This operator gives you a string that indicates the type of the variable, like "string" or "number". For example:

let name: string = 'John Doe';
let age: number = 30;
let isActive: boolean = true;

console.log(typeof name); // Output: "string"
console.log(typeof age); // Output: "number"
console.log(typeof isActive); // Output: "boolean"

TypeScript's static type system will catch type errors during development, but typeof gives you runtime type information. This is particularly useful when working with values from external sources or when handling functions that might return different types.

2. Using the typeof Operator for Type Checking

The typeof operator is essential for runtime type checking. This is helpful when a variable might have different types, such as a function that takes a parameter of a union type. Here's an example:

function handleValue(value: string | number | boolean) {
if (typeof value === 'string') {
return value.toUpperCase();
} else if (typeof value === 'number') {
return value.toFixed(2);
} else {
return value ? 'Yes' : 'No';
}
}

console.log(handleValue('hello')); // Output: "HELLO"
console.log(handleValue(3.14159)); // Output: "3.14"
console.log(handleValue(true)); // Output: "Yes"

This pattern, known as type narrowing, is one of TypeScript's core strengths. The compiler understands that within each conditional block, the variable has a specific type and provides appropriate type checking and autocompletion.

Type narrowing works with all JavaScript primitive types:

function describeType(value: unknown) {
if (typeof value === 'string') {
console.log(`String with length ${value.length}`);
} else if (typeof value === 'number') {
console.log(`Number ${value.toFixed(2)}`);
} else if (typeof value === 'boolean') {
console.log(`Boolean: ${value ? 'true' : 'false'}`);
} else if (typeof value === 'object') {
console.log(`Object: ${value === null ? 'null' : JSON.stringify(value)}`);
} else if (typeof value === 'undefined') {
console.log('Undefined value');
} else if (typeof value === 'function') {
console.log(`Function with ${value.length} parameters`);
} else if (typeof value === 'symbol') {
console.log(`Symbol: ${String(value)}`);
} else if (typeof value === 'bigint') {
console.log(`BigInt: ${value}`);
}
}

3. Implementing Custom Type Guards

When typeof and instanceof aren't enough for complex type checks, custom type guards come to the rescue. These functions return a special type predicate that tells TypeScript about the type within a specific scope:

// Define some interfaces
interface User {
id: string;
name: string;
email: string;
}

interface Admin extends User {
role: 'admin';
permissions: string[];
}

interface Customer extends User {
customerId: string;
subscription: string;
}

// Custom type guards
function isUser(obj: any): obj is User {
return typeof obj === 'object' && obj !== null &&
'id' in obj && 'name' in obj && 'email' in obj;
}

function isAdmin(obj: any): obj is Admin {
return isUser(obj) && 'role' in obj && obj.role === 'admin' && Array.isArray(obj.permissions);
}

function isCustomer(obj: any): obj is Customer {
return isUser(obj) && 'customerId' in obj && 'subscription' in obj;
}

// Using the type guards
function processEntity(entity: unknown) {
if (isAdmin(entity)) {
// TypeScript knows entity is Admin here
console.log(`Admin ${entity.name} has permissions: ${entity.permissions.join(', ')}`);
return;
}

if (isCustomer(entity)) {
// TypeScript knows entity is Customer here
console.log(`Customer ${entity.name} has subscription: ${entity.subscription}`);
return;
}

if (isUser(entity)) {
// TypeScript knows entity is User here
console.log(`User ${entity.name} with email ${entity.email}`);
return;
}

console.log('Unknown entity type');
}

The obj is Type syntax is a type predicate that tells TypeScript that if the function returns true, the argument must be of the specified type. Custom type guards are particularly valuable when working with data from external sources or when implementing discriminated unions.

4. Checking for Specific Object Types

To see if an object is of a certain type, you can use the instanceof operator or custom type guards. The instanceof operator checks if an object is an instance of a certain class, while custom type guards can check for specific properties or methods. Here's how you can use instanceof:

class Person {
constructor(public name: string) {}

greet() {
return `Hello, my name is ${this.name}`;
}
}

class Employee extends Person {
constructor(name: string, public department: string) {
super(name);
}

greet() {
return `${super.greet()}. I work in ${this.department}`;
}
}

class Customer extends Person {
constructor(name: string, public accountNumber: string) {
super(name);
}

greet() {
return `${super.greet()}. My account number is ${this.accountNumber}`;
}
}

function processPersons(people: Person[]) {
for (const person of people) {
console.log(person.greet());

if (person instanceof Employee) {
console.log(`Employee in ${person.department}`);
// TypeScript knows person is Employee here
}

if (person instanceof Customer) {
console.log(`Customer with account ${person.accountNumber}`);
// TypeScript knows person is Customer here
}
}
}

const people = [
new Person('Alice'),
new Employee('Bob', 'Engineering'),
new Customer('Charlie', 'C-12345')
];

processPersons(people);

Unlike typeof, the instanceof operator works with custom classes and respects inheritance. It's especially useful when implementing object-oriented patterns in TypeScript.

For objects that aren't class instances, use custom type guards (as shown in the previous section) or type assertions with caution.

5. Verifying Union Types

Union types let a variable be one of several types. To check these, use the typeof operator or custom type guards. For example:

type ID = string | number;
type Status = 'pending' | 'active' | 'completed' | 'failed';
type ApiResponse =
| { status: 'success'; data: any }
| { status: 'error'; error: string };

function processID(id: ID) {
if (typeof id === 'string') {
// TypeScript knows id is a string here
return `ID-${id.toUpperCase()}`;
} else {
// TypeScript knows id is a number here
return `ID-${id.toString().padStart(5, '0')}`;
}
}

function handleStatus(status: Status) {
switch (status) {
case 'pending':
return '⏳ Waiting...';
case 'active':
return '✅ In progress';
case 'completed':
return '🎉 Finished';
case 'failed':
return '❌ Error occurred';
}
}

function handleResponse(response: ApiResponse) {
if (response.status === 'success') {
// TypeScript knows response has 'data' property
return response.data;
} else {
// TypeScript knows response has 'error' property
throw new Error(response.error);
}
}

The last example demonstrates a discriminated union pattern, where a common property (here, status) identifies the specific type. This pattern is especially useful when implementing type-safe APIs in TypeScript. TypeScript can infer the specific type based on this discriminant property, allowing for cleaner, more type-safe code. Using proper type checking with union types is essential when working with data from external sources or handling complex state transitions in your application.

6. Runtime Type Checking

Runtime type checking often uses the typeof operator or While TypeScript provides compile-time type safety, runtime type checking is essential when working with data from external sources such as API requests and responses or user inputs:

// Simple runtime type checking function
function validateUserData(data: unknown): asserts data is {
id: string;
name: string;
email: string;
age: number;
} {
if (typeof data !== 'object' || data === null) {
throw new TypeError('User data must be an object');
}

const user = data as any;

if (typeof user.id !== 'string') {
throw new TypeError('User id must be a string');
}

if (typeof user.name !== 'string') {
throw new TypeError('User name must be a string');
}

if (typeof user.email !== 'string') {
throw new TypeError('User email must be a string');
}

if (typeof user.age !== 'number' || isNaN(user.age)) {
throw new TypeError('User age must be a number');
}
}

// Using the validation function
function processUserData(userData: unknown) {
try {
validateUserData(userData);
// TypeScript now knows userData has the correct shape
console.log(`Processing user ${userData.name} with email ${userData.email}`);
} catch (error) {
console.error(`Invalid user data: ${error.message}`);
}
}

The asserts keyword introduces an type assertion function that tells TypeScript the variable has the specified type after the function completes successfully. This pattern is valuable when implementing argument validation in a type-safe way.

For more complex validations, consider using schema validation tools that integrate with TypeScript, as recommended in Convex TypeScript best practices.

7. Ensuring Type Safety with Enums

Enums let you define a set of named values, making your code more readable and maintainable. Here's how you can use enums:

enum Color {
Red,
Green,
Blue,
}

function checkColor(color: Color) {
if (color === Color.Red) {
console.log('Color is red');
} else if (color === Color.Green) {
console.log('Color is green');
} else if (color === Color.Blue) {
console.log('Color is blue');
}
}

Enums integrate well with TypeScript type checking and enable the compiler to catch errors when an invalid value is assigned. They're particularly useful when implementing custom functions with a finite set of options.

When working with string-based APIs, you can also use string literal unions instead of enums for better type inference:

// Using string literal union type instead of enum
type Status = 'pending' | 'active' | 'completed' | 'failed';

function updateStatus(newStatus: Status) {
// TypeScript ensures only valid status values are passed
console.log(`Status updated to ${newStatus}`);
}

Solutions to Common Problems

Developers often face challenges with type checking in TypeScript, like telling similar object types apart, verifying types in union scenarios, and ensuring type safety with custom type guards.

Distinguishing Between Similar Object Types

You can use custom type guards to check for specific properties or methods to tell similar object types apart. Here's an example:

interface Product {
id: string;
name: string;
price: number;
}

interface User {
id: string;
name: string;
email: string;
}

function isProduct(item: any): item is Product {
return (
typeof item === 'object' && item !== null &&
'id' in item && typeof item.id === 'string' &&
'name' in item && typeof item.name === 'string' &&
'price' in item && typeof item.price === 'number'
);
}

function isUser(item: any): item is User {
return (
typeof item === 'object' && item !== null &&
'id' in item && typeof item.id === 'string' &&
'name' in item && typeof item.name === 'string' &&
'email' in item && typeof item.email === 'string'
);
}

function processItem(item: unknown) {
if (isProduct(item)) {
console.log(`Product: ${item.name} - $${item.price}`);
} else if (isUser(item)) {
console.log(`User: ${item.name} (${item.email})`);
} else {
console.log('Unknown item type');
}
}

This approach aligns with Convex's best practices for type-safe data handling.

Working with External Data Sources

When consuming API data, you need runtime validation before using the data with your TypeScript types:

interface ApiUser {
id: string;
name: string;
email: string;
}

async function fetchUser(id: string): Promise<ApiUser> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();

// Validate the response matches our expected type
if (
typeof data !== 'object' || data === null ||
typeof data.id !== 'string' ||
typeof data.name !== 'string' ||
typeof data.email !== 'string'
) {
throw new Error('Invalid user data from API');
}

return data as ApiUser;
}

This validation pattern is critical when integrating with external APIs in your TypeScript applications.

Type-Safe Function Parameters

For functions with complex parameters, use interface definitions and type guards:

interface SortOptions<T> {
field: keyof T;
direction: 'asc' | 'desc';
nullsPosition?: 'first' | 'last';
}

function sortItems<T>(items: T[], options: SortOptions<T>): T[] {
// Implement sorting logic here
return [...items].sort((a, b) => {
const aValue = a[options.field];
const bValue = b[options.field];

// Handle nulls according to nullsPosition
if (aValue === null || aValue === undefined) {
return options.nullsPosition === 'first' ? -1 : 1;
}
if (bValue === null || bValue === undefined) {
return options.nullsPosition === 'first' ? 1 : -1;
}

// Compare values based on direction
const compare = aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
return options.direction === 'asc' ? compare : -compare;
});
}

This approach to function parameters follows the patterns recommended in Convex's argument validation guide.

Final Thoughts about TypeScript Check Type

TypeScript's type checking features help you write safer, more maintainable code. By leveraging typeof checks, custom type guards, and other techniques covered in this guide, you'll catch errors early and improve development productivity. As you integrate these patterns with Convex or other tools, you'll experience the full benefits of TypeScript's type system in your projects.