Ensuring Type Safety at Runtime: Effective Techniques for Checking Custom Types in TypeScript
- In TypeScript,
typeof
primarily acts as the JavaScripttypeof
operator during runtime. It returns a string representing the underlying data type of a value:"string"
,"number"
,"boolean"
,"object"
, etc. - However, TypeScript also leverages
typeof
in a type guard context. Here, it's used to narrow down the possible types that a variable might hold based on the runtime type it evaluates to.
Why typeof
Doesn't Directly Check Custom Types
- TypeScript's type system is for static type checking, which happens at compile time. During compilation, type annotations, interfaces, and other type constructs are erased.
- Since
typeof
operates at runtime, it can't directly access TypeScript's type information that's no longer present.
Alternative: Type Guards for Runtime Type Checking
To check if a value conforms to a custom type at runtime, TypeScript employs type guards. These are functions that return a boolean indicating whether the input value matches the expected type. Here's a common approach:
interface Point {
x: number;
y: number;
}
function isPoint(value: any): value is Point {
return typeof value === 'object' && 'x' in value && 'y' in value;
}
const someValue: any = { x: 10, y: 20 };
if (isPoint(someValue)) {
// Now you can safely access someValue.x and someValue.y
console.log(someValue.x, someValue.y);
}
In this example:
- We define a
Point
interface to describe the expected structure of a point object. - The
isPoint
function acts as a type guard. It checks if the inputvalue
is an object and has the requiredx
andy
properties. - Inside the
if
statement, the type guard ensures thatsomeValue
is narrowed to thePoint
type if the condition is true. This allows safe access to its properties.
Key Points:
typeof
on its own can't directly check for custom types in TypeScript.- Use type guards like
isPoint
to perform runtime type checks and narrow down possible types. - Type guards are crucial for working with dynamic values (like
any
) and ensuring type safety at runtime.
const fruit = ['apple', 'orange', 'banana'] as const; // `as const` asserts the array elements are literals
type Fruit = typeof fruit[number]; // Type inference from literal array
let myFruit: Fruit = 'apple'; // Allowed
// myFruit = 'grape'; // Error: 'grape' is not a valid Fruit
if (myFruit === 'apple') {
console.log(`${myFruit} is a valid fruit.`);
}
- We create a constant array
fruit
with literal string values. - Using
as const
, we tell TypeScript to preserve these values as literals. - The
typeof fruit[number]
expression infers a type that's a union of the literal string types in the array ("apple" | "orange" | "banana"
). - This inferred type is assigned to
Fruit
, allowing us to restrictmyFruit
to only those specific fruit names.
User-Defined Type Guard Function (for more complex type checks):
interface Product {
name: string;
price: number;
stock?: number; // Optional property
}
function isProduct(value: any): value is Product {
return typeof value === 'object' &&
'name' in value && typeof value.name === 'string' &&
'price' in value && typeof value.price === 'number' &&
// Optional property check (if needed)
(typeof value.stock === 'undefined' || typeof value.stock === 'number');
}
const someData: any = { name: 'T-Shirt', price: 19.99, stock: 10 }; // Matches Product
if (isProduct(someData)) {
console.log(`Product details: ${someData.name}, Price: $${someData.price}`);
} else {
console.error('Invalid product data format.');
}
Here's a breakdown:
- The
isProduct
function acts as a type guard, checking for required properties and their types. - Inside the
if
statement, the type guard ensures thatsomeData
is narrowed to theProduct
type if the condition is true. - We can then safely access its properties like
name
andprice
.
Choosing the Right Approach:
- Use type inference with literal types when you have a fixed set of known values and want to restrict the type based on those literals.
- Use user-defined type guards for more complex type checks, especially when dealing with dynamic or user-provided data.
- The
in
operator can be used to check if a specific property exists on an object, but it doesn't guarantee the property's type. - While not a complete type check, it can be helpful in certain scenarios.
interface User {
name: string;
age: number;
}
function hasRequiredProperties(obj: any): obj is User {
return 'name' in obj && 'age' in obj;
}
const userData: any = { name: 'Alice', age: 30 };
if (hasRequiredProperties(userData)) {
// userData might still have other properties, so be cautious
console.log(`User: ${userData.name} (age: ${userData.age})`);
} else {
console.error('Missing required user properties.');
}
instanceof (for classes):
- The
instanceof
operator can be used with classes to check if an object was created from that class. However, it only works for classes, not interfaces.
class Animal {
constructor(public type: string) {}
}
class Dog extends Animal {
constructor() {
super('dog');
}
}
const unknownAnimal: Animal = new Dog();
if (unknownAnimal instanceof Dog) {
console.log(`It's a dog!`);
} else if (unknownAnimal instanceof Animal) {
console.log(`It's an animal, but not necessarily a dog.`);
} else {
console.error('Unexpected type for unknownAnimal.');
}
Custom Type Guards with Discriminated Unions (for more control):
- Discriminated unions involve interfaces or classes that share a common property with a specific type value. This property helps differentiate between types within the union.
- You can write type guards that check for the presence and value of this discriminator property.
interface Shape {
type: 'circle' | 'rectangle';
radius?: number; // Only applicable for circles
width?: number;
height?: number; // Only applicable for rectangles
}
function getShapeArea(shape: Shape): number {
if (shape.type === 'circle') {
if (shape.radius === undefined) {
throw new Error('Circle requires a radius property.');
}
return Math.PI * Math.pow(shape.radius, 2);
} else if (shape.type === 'rectangle') {
if (shape.width === undefined || shape.height === undefined) {
throw new Error('Rectangle requires both width and height properties.');
}
return shape.width * shape.height;
} else {
throw new Error('Invalid shape type.');
}
}
const circle: Shape = { type: 'circle', radius: 5 };
const rectangle: Shape = { type: 'rectangle', width: 10, height: 2 };
console.log(`Circle area: ${getShapeArea(circle)}`);
console.log(`Rectangle area: ${getShapeArea(rectangle)}`);
- Use
in
for quick checks of property existence (but be cautious). - Use
instanceof
only when dealing with classes. - Use discriminated unions and custom type guards for more control and complex type checks with clear type differentiation.
typescript types