Understanding TypeScript Type Checking
You're debugging a React component that crashes because user.name is undefined, but TypeScript didn't catch it. The API returned { user: null } instead of the expected user object, and your component tried to access user.name anyway. This is exactly why runtime type checking matters—TypeScript's compile-time checks can't protect you from unpredictable external data.
This guide covers practical type checking techniques you'll actually use: validating API responses, handling form inputs, and creating bulletproof type guards that catch these issues before they crash your app.
1. TypeScript typeof Operator: Validating API Response Data
When your app fetches user data from an API, you can't trust that the response matches your TypeScript interface. Here's how to validate it safely using the typeof operator:
interface UserProfile {
id: string;
name: string;
email: string;
age: number;
}
async function fetchUserProfile(userId: string): Promise<UserProfile> {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
// Don't trust the API - validate the response
if (typeof data.id !== 'string') {
throw new Error('Invalid user ID from API');
}
if (typeof data.name !== 'string') {
throw new Error('Invalid user name from API');
}
if (typeof data.email !== 'string') {
throw new Error('Invalid user email from API');
}
if (typeof data.age !== 'number') {
throw new Error('Invalid user age from API');
}
return data as UserProfile;
}
This validation prevents runtime crashes when the API returns unexpected data. The typeof operator works at runtime, catching issues that TypeScript's compile-time checks miss.
2. Type Narrowing with Union Types: Handling Form Input Data
Form inputs are notorious for causing type issues. Here's how to safely process different input types using runtime type checking:
function processFormField(fieldValue: string, fieldType: 'text' | 'number' | 'email') {
// Convert and validate based on expected type
if (fieldType === 'number') {
const numValue = Number(fieldValue);
if (typeof numValue !== 'number' || isNaN(numValue)) {
throw new Error(`Invalid number: ${fieldValue}`);
}
return numValue;
}
if (fieldType === 'email') {
if (typeof fieldValue !== 'string' || !fieldValue.includes('@')) {
throw new Error(`Invalid email: ${fieldValue}`);
}
return fieldValue.toLowerCase();
}
// Default to text
if (typeof fieldValue !== 'string') {
throw new Error(`Expected string, got ${typeof fieldValue}`);
}
return fieldValue.trim();
}
// Usage with actual form data
const formData = new FormData(document.querySelector('form'));
const age = processFormField(formData.get('age') as string, 'number');
const email = processFormField(formData.get('email') as string, 'email');
This pattern, called type narrowing, lets TypeScript understand the specific type within each conditional block. The compiler provides proper autocompletion and catches type errors.
Here's how typeof works with all JavaScript primitive types when debugging unknown data:
function debugUnknownValue(value: unknown, context: string) {
if (typeof value === 'string') {
console.log(`${context}: String with ${value.length} characters`);
} else if (typeof value === 'number') {
console.log(`${context}: Number ${isNaN(value) ? '(NaN)' : value}`);
} else if (typeof value === 'boolean') {
console.log(`${context}: Boolean ${value}`);
} else if (typeof value === 'object') {
// Watch out: typeof null === 'object'!
console.log(`${context}: ${value === null ? 'null' : 'Object'}`);
} else if (typeof value === 'undefined') {
console.log(`${context}: undefined`);
} else if (typeof value === 'function') {
console.log(`${context}: Function`);
} else {
console.log(`${context}: ${typeof value}`);
}
}
// Useful for debugging API responses
debugUnknownValue(apiResponse.data, 'API data');
3. Implementing Custom Type Guards
When typeof and instanceof aren't enough for complex type checks, custom type guards step in. These functions return a special type predicate that tells TypeScript about the type within a specific scope.
Decision Tree for Type Checking:
- Use
typeoffor primitives (string, number, boolean) - Use
instanceoffor class instances - Use custom type guards for interfaces and complex objects
Here's the basic pattern with realistic examples:
// 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. You'll find custom type guards most helpful 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. Use it 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 works great when implementing type-safe APIs in TypeScript. TypeScript can infer the specific type based on this discriminant property, giving you 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. Use them 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.
Common Pitfalls and Troubleshooting
Every developer hits these type checking gotchas. Here's how to avoid the most frustrating ones:
The typeof null === 'object' Trap
This JavaScript quirk catches everyone at least once:
function validateUserInput(input: unknown) {
// This will incorrectly pass for null!
if (typeof input === 'object') {
return input.name; // Runtime error if input is null
}
// Always check for null explicitly
if (typeof input === 'object' && input !== null) {
return (input as any).name; // Safe
}
}
Interface vs Runtime Validation Confusion
TypeScript interfaces don't exist at runtime—they're compile-time only:
interface User {
name: string;
email: string;
}
// This doesn't work—interfaces aren't real!
// if (data instanceof User) { ... }
// Use type guards instead
function isUser(obj: unknown): obj is User {
return typeof obj === 'object' && obj !== null &&
typeof (obj as any).name === 'string' &&
typeof (obj as any).email === 'string';
}
When typeof Fails with Complex Objects
For arrays, dates, and custom objects, typeof returns 'object'—not helpful:
function handleData(data: unknown) {
// All return 'object'
console.log(typeof []); // 'object'
console.log(typeof new Date()); // 'object'
console.log(typeof { name: 'John' }); // 'object'
// Use specific checks
if (Array.isArray(data)) {
// Handle array
} else if (data instanceof Date) {
// Handle date
} else if (typeof data === 'object' && data !== null) {
// Handle plain object
}
}
Performance Considerations
Type checking performance matters in hot code paths. Here's what you need to know:
Runtime Performance Impact
typeof: Extremely fast (~1-2ns)instanceof: Fast for simple inheritance (~5-10ns)- Custom type guards: Depends on complexity (~10-100ns)
- JSON schema validation: Slower but more thorough (~1-10μs)
Optimization Strategies
// Fast: Check cheap conditions first
function validateApiResponse(data: unknown) {
// Quick typeof check first
if (typeof data !== 'object' || data === null) {
return false;
}
// Then check required properties
const obj = data as Record<string, unknown>;
return typeof obj.id === 'string' &&
typeof obj.name === 'string';
}
// Cache type guards for repeated checks
const isValidUser = memoize((data: unknown) => {
return typeof data === 'object' && data !== null &&
// ... expensive validation logic
});
For production applications processing thousands of objects, consider using libraries like zod or yup for complex validation—they're optimized for performance.
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.