TypeScript Functions

 

Type Guards and Type Assertions in TypeScript

TypeScript is a powerful superset of JavaScript that adds static typing capabilities to the language. With static typing, TypeScript allows developers to specify the types of variables, function parameters, and return values. This helps catch type-related errors early during development, leading to more robust and maintainable codebases. However, when working with complex data structures or handling dynamic data, TypeScript’s strict type checking can sometimes pose challenges.

Type Guards and Type Assertions in TypeScript

In this blog post, we’ll explore two essential concepts in TypeScript: Type Guards and Type Assertions. These mechanisms play a vital role in dealing with complex or uncertain types, ensuring type safety, and enabling smooth interaction with JavaScript libraries that may lack type information.

1. Understanding TypeScript’s Type System

TypeScript’s type system is built around the concept of static typing. Variables, function parameters, and return types can be explicitly typed using various TypeScript types like number, string, boolean, object, array, etc. This enables the TypeScript compiler to catch type errors during the development phase, preventing many runtime errors.

However, there are cases when TypeScript cannot infer the exact type of a value, especially when working with union types, intersection types, or generic types. This is where Type Guards and Type Assertions come into play.

2. What are Type Guards?

Type Guards are conditional checks in TypeScript that allow us to narrow down the type of a value within a block of code. By using type guards, we can make the TypeScript compiler aware of the specific type of a variable, even when the type is not immediately evident.

2.1 Type Predicates

Type Predicates are special functions that return a boolean value and are used to perform type guards. A type predicate takes the form of variableName is Type, where variableName is the name of the variable being checked, and Type is the type we want to narrow it down to. Let’s look at an example:

typescript
function isString(value: any): value is string {
  return typeof value === 'string';
}

// Usage of the type predicate
function capitalize(input: unknown): string {
  if (isString(input)) {
    return input.charAt(0).toUpperCase() + input.slice(1);
  } else {
    throw new Error('Expected a string input.');
  }
}

console.log(capitalize('hello')); // Output: "Hello"

In this example, the isString function checks if the given value is of type ‘string’ using the typeof operator. The TypeScript compiler now understands that within the if (isString(input)) block, the input variable is of type string, allowing us to safely use string methods.

2.2 typeof Type Guards

The typeof operator in TypeScript can also act as a type guard. It checks the type of a value and narrows it down accordingly. However, its usage is limited to primitive types such as ‘number’, ‘string’, ‘boolean’, ‘symbol’, ‘function’, and ‘undefined’. Let’s see an example:

typescript
function printLength(value: number | string) {
  if (typeof value === 'number') {
    console.log(`The number is: ${value}`);
  } else {
    console.log(`The string has ${value.length} characters.`);
  }
}

printLength(42);       // Output: "The number is: 42"
printLength('hello');  // Output: "The string has 5 characters."

Here, the typeof type guard helps differentiate between a number and a string to perform type-specific operations.

2.3 instanceof Type Guards

The instanceof operator is another type guard used to check whether an object is an instance of a particular class or constructor function. It’s commonly used when working with inheritance and class-based structures. Let’s see how it works:

typescript
class Dog {
  bark() {
    console.log('Woof! Woof!');
  }
}

class Cat {
  meow() {
    console.log('Meow!');
  }
}

function makeSomeNoise(animal: Dog | Cat) {
  if (animal instanceof Dog) {
    animal.bark();
  } else if (animal instanceof Cat) {
    animal.meow();
  }
}

makeSomeNoise(new Dog());  // Output: "Woof! Woof!"
makeSomeNoise(new Cat());  // Output: "Meow!"

In this example, the makeSomeNoise function takes an argument of type Dog | Cat. Inside the function, we use the instanceof type guard to determine whether the animal variable is a Dog or a Cat and perform the respective actions accordingly.

3. User-Defined Type Guards

While the examples above demonstrate built-in type guards like typeof and instanceof, you can also create your own custom type guards. These user-defined type guards provide more flexibility and are especially helpful when dealing with complex data structures.

3.1 The is Keyword

The is keyword is often used in user-defined type guards. It helps to create a more expressive and readable code by combining type checks with custom logic. Here’s an example:

typescript
interface Square {
  side: number;
}

interface Rectangle {
  width: number;
  height: number;
}

function isSquare(shape: Square | Rectangle): shape is Square {
  return 'side' in shape;
}

function calculateArea(shape: Square | Rectangle) {
  if (isSquare(shape)) {
    return shape.side ** 2;
  } else {
    return shape.width * shape.height;
  }
}

const square: Square = { side: 5 };
const rectangle: Rectangle = { width: 4, height: 6 };

console.log(calculateArea(square));    // Output: 25
console.log(calculateArea(rectangle)); // Output: 24

In this example, we have two interfaces, Square and Rectangle. We then define the isSquare function, which checks if a given shape is a Square by looking for the existence of the side property. The calculateArea function uses this custom type guard to calculate the area of either a Square or a Rectangle.

3.2 Type Guard Functions

Instead of using the is keyword, we can also create type guard functions that return booleans based on complex checks. Here’s an example:

typescript
interface Person {
  name: string;
  age: number;
}

interface Car {
  brand: string;
  model: string;
}

function isPersonOrCar(obj: Person | Car): obj is Person {
  return 'name' in obj && 'age' in obj;
}

function processObject(obj: Person | Car) {
  if (isPersonOrCar(obj)) {
    console.log(`Person: ${obj.name}, Age: ${obj.age}`);
  } else {
    console.log(`Car: ${obj.brand}, Model: ${obj.model}`);
  }
}

const person: Person = { name: 'Alice', age: 30 };
const car: Car = { brand: 'Toyota', model: 'Corolla' };

processObject(person); // Output: "Person: Alice, Age: 30"
processObject(car);    // Output: "Car: Toyota, Model: Corolla"

In this example, we have two interfaces, Person and Car. The isPersonOrCar function acts as a type guard, checking if the object is a Person by verifying the existence of name and age properties.

4. What are Type Assertions?

Type Assertions, also known as type casts, are a way to tell the TypeScript compiler that we know the type of a value more precisely than it can infer. They allow developers to override the inferred type of a value, which can be helpful when TypeScript’s inference isn’t accurate enough.

4.1 Using the as Keyword

Type Assertions in TypeScript are performed using the as keyword. With a type assertion, we are essentially telling TypeScript that we are confident that a value is of a particular type. Here’s an example:

typescript
function getLength(input: string | number): number {
  const length = (input as string).length;
  return length;
}

console.log(getLength('hello')); // Output: 5
console.log(getLength(42));      // Output: Error - Property 'length' does not exist on type 'number'.

In this example, the getLength function takes a parameter of type string | number. Inside the function, we use a type assertion (input as string) to inform TypeScript that we are treating the input variable as a string. The compiler then allows us to access the length property, which is specific to strings.

4.2 When to Use Type Assertions

TypeScript encourages developers to avoid excessive use of type assertions, as they can potentially lead to runtime errors if misused. Instead, it’s better to rely on type guards and let TypeScript’s type inference handle most of the type checking.

Use type assertions sparingly, mainly in situations where you know the type more precisely than TypeScript can infer or when dealing with external libraries without type declarations.

5. Working with Union Types

Union Types are a way to express that a variable can hold multiple types of values. Type Guards and Type Assertions become particularly useful when dealing with union types.

typescript
interface Circle {
  radius: number;
}

interface Square {
  side: number;
}

type Shape = Circle | Square;

function getArea(shape: Shape): number {
  if ('radius' in shape) {
    // Type Guard: shape is a Circle
    return Math.PI * shape.radius ** 2;
  } else {
    // Type Guard: shape is a Square
    return shape.side ** 2;
  }
}

const circle: Circle = { radius: 3 };
const square: Square = { side: 4 };

console.log(getArea(circle)); // Output: 28.27
console.log(getArea(square)); // Output: 16

In this example, we have two interfaces, Circle and Square, and a union type Shape. The getArea function uses the ‘radius’ in shape type guard to differentiate between a Circle and a Square and calculate the area accordingly.

6. Handling null and undefined

TypeScript has null and undefined as separate types that represent the absence of a value. When working with nullable types, Type Guards and Type Assertions can help ensure type safety.

typescript
interface User {
  name: string;
  age: number | null;
}

function getUserName(user: User): string {
  if (user.age !== null) {
    // Type Guard: user.age is not null
    return `${user.name}, ${user.age} years old.`;
  } else {
    // Type Guard: user.age is null
    return `${user.name}, age unknown.`;
  }
}

const user1: User = { name: 'Alice', age: 30 };
const user2: User = { name: 'Bob', age: null };

console.log(getUserName(user1)); // Output: "Alice, 30 years old."
console.log(getUserName(user2)); // Output: "Bob, age unknown."

In this example, the User interface has an age property that can be either a number or null. The getUserName function uses a type guard user.age !== null to check if the age is not null and safely accesses the age property.

7. Advanced Type Guards and Type Assertions

7.1 in Operator for Discriminated Unions

Discriminated Unions are a pattern in TypeScript where a property common to all objects in a union type is used to discriminate between the different members of the union. The in operator can be used to create more precise type guards for discriminated unions.

typescript
interface Square {
  kind: 'square';
  side: number;
}

interface Circle {
  kind: 'circle';
  radius: number;
}

type Shape = Square | Circle;

function getShapeArea(shape: Shape): number {
  switch (shape.kind) {
    case 'square':
      // Type Guard: shape is a Square
      return shape.side ** 2;
    case 'circle':
      // Type Guard: shape is a Circle
      return Math.PI * shape.radius ** 2;
    default:
      throw new Error('Invalid shape.');
  }
}

const square: Square = { kind: 'square', side: 5 };
const circle: Circle = { kind: 'circle', radius: 3 };

console.log(getShapeArea(square)); // Output: 25
console.log(getShapeArea(circle)); // Output: 28.27

In this example, we have two interfaces, Square and Circle, and a discriminated union type Shape. The kind property is used to discriminate between the members of the union. The getShapeArea function uses a switch statement and the shape.kind property to create type guards for each member of the union.

7.2 Type Narrowing with never and unknown

The never and unknown types can be used to achieve type narrowing in TypeScript.

never type is used when a function never returns or when a type guard eliminates all possible types.

typescript
function throwError(message: string): never {
  throw new Error(message);
}

function neverReturns(): never {
  while (true) {
    // Some infinite loop or other condition that never returns
  }
}

function checkUnknownType(value: unknown) {
  if (typeof value === 'string') {
    // Type Guard: value is a string
    console.log(value.toUpperCase());
  } else if (value === null) {
    // Type Guard: value is null
    console.log('Value is null.');
  } else if (typeof value === 'object') {
    // Type Guard: value is an object
    console.log(Object.keys(value));
  } else {
    // Type Guard: value is of type never (all other possibilities exhausted)
    throwError('Invalid type.');
  }
}

In this example, never is used as the return type for the throwError function, which indicates that the function never completes normally. Additionally, the neverReturns function explicitly never returns, and its return type is inferred as never.

unknown is used when a value may be of any type, and we want to narrow it down to a more specific type.

Conclusion

In this blog post, we’ve explored the concepts of Type Guards and Type Assertions in TypeScript. These powerful mechanisms allow us to handle complex types, ensure type safety, and improve the robustness of our code. With Type Guards, we can narrow down the type of variables within specific blocks of code, while Type Assertions help us override TypeScript’s type inference when we have more accurate knowledge about the types.

By understanding and effectively using Type Guards and Type Assertions, TypeScript developers can create more reliable, maintainable, and error-free codebases, making TypeScript an invaluable tool in modern web development.

TypeScript’s type system continues to evolve, and staying updated with the latest features and best practices will enable developers to harness its full potential and build even more robust and scalable applications. Happy coding!

Previously at
Flag Argentina
Argentina
time icon
GMT-3
Experienced software engineer with a passion for TypeScript and full-stack development. TypeScript advocate with extensive 5 years experience spanning startups to global brands.