Typescript Type Guards And Discriminated Unions Complete Guide
Understanding the Core Concepts of TypeScript Type Guards and Discriminated Unions
Type Guards
Type Guards are used at runtime to test an object to determine if it's an instance of a certain class or if a variable belongs to a specific type. Type Guards are crucial when working with union types to ensure that the correct operations and property accesses are performed on the types.
Importantly:
- They are essential for performing operations on objects whose types are not known until runtime.
- TypeScript uses them to refine types within conditional blocks.
- Common type guards include
instanceof
,in
,typeof
, and custom guard functions.
Built-in Type Guards:
instanceof
Guard: Used to check if an object is an instance of a class.class Dog {} class Cat {} let pet: Dog | Cat = new Dog(); if (pet instanceof Dog) { // TypeScript knows 'pet' is a Dog here console.log("Woof!"); } else { // TypeScript knows 'pet' is a Cat here console.log("Meow!"); }
in
Guard: Ensures a property exists within an object.interface Vehicle { type: string; } interface Car extends Vehicle { numberOfDoors: number; } interface Bike extends Vehicle { hasPedals: boolean; } function isCar(vehicle: Vehicle): vehicle is Car { return 'numberOfDoors' in vehicle; } function describeVehicle(vehicle: Vehicle) { if (isCar(vehicle)) { // TypeScript knows 'vehicle' is a Car here console.log(`This car has ${vehicle.numberOfDoors} doors.`); } else { // TypeScript knows 'vehicle' is a Bike here console.log(`This bike has pedals? ${vehicle.hasPedals}`); } }
typeof
Guard: Checks the type of basic primitives likestring
,number
, etc.function printInfo(info: string | number) { if (typeof info === 'string') { // TypeScript knows 'info' is a string here console.log(`The string is: ${info.toUpperCase()}`); } else if(typeof info === 'number') { // TypeScript knows 'info' is a number here console.log(`The number is: ${info * 2}`); } }
Custom Type Guard Function: A user-defined function that returns a type predicate of the form
parameterName is Type
.interface Bird { fly(): void; layEggs(): void; } interface Fish { swim(): void; layEggs(): void; } function isFish(pet: Bird | Fish): pet is Fish { return (pet as Fish).swim !== undefined; } function movePet(pet: Bird | Fish) { if (isFish(pet)) { // TypeScript knows 'pet' is a Fish here pet.swim(); } else { // TypeScript knows 'pet' is a Bird here pet.fly(); } }
Discriminated Unions
A Discriminated Union is a pattern that you can use when you have several types that have a common, singular property—often called a discriminator—and then a type guard that checks the value of the discriminator to determine which branch of the union a particular value belongs to.
Importantly:
- They provide a way to handle multiple types in TypeScript, making the code cleaner and safer.
- Discriminated unions make it easier to work with unions that contain complex types by using a common property to differentiate between members.
Example of a Discriminated Union:
interface Square {
kind: "square";
size: number;
}
interface Rectangle {
kind: "rectangle";
width: number;
height: number;
}
interface Circle {
kind: "circle";
radius: number;
}
type Shape = Square | Rectangle | Circle;
function area(shape: Shape) {
switch(shape.kind) {
case "square":
// TypeScript knows 'shape' is a Square here
return shape.size * shape.size;
case "rectangle":
// TypeScript knows 'shape' is a Rectangle here
return shape.width * shape.height;
case "circle":
// TypeScript knows 'shape' is a Circle here
return Math.PI * shape.radius ** 2;
default:
const _exhaustiveCheck: never = shape;
throw new Error(`Unknown shape: ${_exhaustiveCheck}`);
}
}
Exhaustiveness Checking:
One of the benefits of discriminated unions is that they allow for exhaustiveness checking using the never
type. In the previous example, TypeScript will complain if a new member is added to the Shape
union without corresponding handling in the switch
statement. This ensures your code doesn't accidentally fall through unhandled cases.
Key Points:
- Discriminated unions make type safety easy by leveraging a common property.
- The
switch
statement is typically used with a discriminator to safely handle different possibilities. - Exhaustiveness checking through
never
helps you prevent errors when adding new types.
Conclusion
Incorporating type guards and discriminated unions into your TypeScript projects improves type safety and reduces runtime errors. These tools not only help the compiler but also guide developers by ensuring that specific actions are taken based on the actual type of the object.
For example, when working with a discriminated union, you can safely perform operations knowing the exact structure of the object. In conjunction with type guards, this allows TypeScript to understand and enforce what actions and properties can be used on a particular type within a conditional block.
Online Code run
Step-by-Step Guide: How to Implement TypeScript Type Guards and Discriminated Unions
Understanding TypeScript Type Guards
Type guards are a special kind of type check used in TypeScript to determine the type of objects at compile time. This way TypeScript can narrow down a variable’s type and provide more specific information to the compiler.
Example 1: Using typeof
as a Type Guard
Let's start with a simple example that uses typeof
to guard against potential runtime type errors:
function printVariable(x: number | string) {
if (typeof x === 'string') {
// Here TypeScript recognizes that `x` is a string.
return console.log('The string is: ', x.toUpperCase());
} else if (typeof x === 'number') {
// And here TypeScript knows that `x` must be a number.
return console.log('The number is: ', x.toFixed(2));
}
}
printVariable("hello"); // Output: The string is: HELLO
printVariable(42); // Output: The number is: 42.00
In this example:
- We have a function
printVariable
that accepts a parameterx
which could either be anumber
or astring
. - Inside the function, we use
typeof
to check the type ofx
. - Depending on the result, TypeScript narrows the type and allows us to call
toUpperCase()
for strings andtoFixed()
for numbers.
Example 2: Using in
as a Type Guard
The in
keyword can be used as a type guard to check for the existence of a property in an object:
interface Bird {
fly: () => void;
layEggs: () => void;
}
interface Fish {
swim: () => void;
layEggs: () => void;
}
function getAnimalAction(animal: Bird | Fish) {
if ('fly' in animal) {
animal.fly();
console.log("This animal can fly!");
} else {
animal.swim();
console.log("This animal can swim!");
}
}
let myBird: Bird = {
fly: () => console.log("Bird flying..."),
layEggs: () => console.log("Bird laying eggs...")
};
let myFish: Fish = {
swim: () => console.log("Fish swimming..."),
layEggs: () => console.log("Fish laying eggs...")
};
getAnimalAction(myBird); // Output: Bird flying...
// This animal can fly!
getAnimalAction(myFish); // Output: Fish swimming...
// This animal can swim!
In this example:
- We define two interfaces:
Bird
andFish
. - Both interfaces share a common method
layEggs
, butBird
hasfly
andFish
hasswim
. - In the function
getAnimalAction
, we use thein
operator to check which properties exist in theanimal
object. - Depending on the property, TypeScript understands the specific type and allows us to call the appropriate method.
Example 3: User-defined Type Guard
We can also create our own type guard functions that return a boolean value asserting the type of the variable within a certain scope:
class Dog {
bark() {
console.log("Woof woof!");
}
}
class Cat {
meow() {
console.log("Meow meow!");
}
}
function isDog(pet: Dog | Cat): pet is Dog {
return (pet as Dog).bark !== undefined;
}
function getPetSound(pet: Dog | Cat) {
if (isDog(pet)) {
pet.bark();
} else {
// TypeScript knows that if pet is not a Dog, then it must be a Cat.
pet.meow();
}
}
let myDog: Dog = new Dog();
let myCat: Cat = new Cat();
getPetSound(myDog); // Output: Woof woof!
getPetSound(myCat); // Output: Meow meow!
In this example:
- We create two classes
Dog
andCat
with distinct methods. - We then write a user-defined type guard
isDog
that returns true if the passedpet
argument has abark
method. - Finally, in
getPetSound
function, when we check usingisDog
, TypeScript treatspet
as aDog
inside the if block, and as aCat
in its else clause.
Understanding Discriminated Unions
A discriminated union is a form of a tagged union, wherein each member of the union has a property, typically called a discriminator, that has a unique literal type.
Example 1: Basic Discriminated Unions
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;
function getArea(shape: Shape) {
switch(shape.kind) {
case "circle":
// Inside this block, TypeScript knows shape is a Circle.
return Math.PI * shape.radius ** 2;
case "square":
// Inside this block, TypeScript knows shape is a Square.
return shape.sideLength ** 2;
}
}
console.log(getArea({kind: 'circle', radius: 5})); // Output: 78.53981633974483
console.log(getArea({kind: 'square', sideLength: 5})); // Output: 25
In this example:
- We define two types:
Circle
andSquare
. - Both have a
kind
property which uniquely discriminates them from each other. - When handling
getArea
function, TypeScript uses the discriminant (shape.kind
) to understand the type and narrow down the shape's type within eachcase
.
Example 2: Enhancing Discriminated Union with Additional Properties
interface Bird {
type: "bird";
beakLength: number;
}
interface Fish {
type: "fish";
scaleTexture: string;
}
type Animal = Bird | Fish;
function describeAnimal(animal: Animal) {
switch(animal.type) {
case "bird":
return "I am a bird with a beak length of " + animal.beakLength + " cm";
case "fish":
return "I am a fish with a scale texture like " + animal.scaleTexture;
}
}
// Create an instance of Bird and Fish to test
let sparrow: Bird = { type: 'bird', beakLength: 4 };
let goldFish: Fish = { type: 'fish', scaleTexture: "shiny" };
console.log(describeAnimal(sparrow)); // Output: I am a bird with a beak length of 4 cm
console.log(describeAnimal(goldFish)); // Output: I am a fish with a scale texture like shiny
In this example:
- The interfaces
Bird
andFish
both possess atype
property which serves the purpose of discrimination. - Each interface adds more relevant details specific to their type (
beakLength
for birds andscaleTexture
for fish). - The
describeAnimal
function switches based on these discriminants and accesses type-specific properties.
Example 3: Including a Default Case
It is good practice to include a default case when working with discriminated unions to catch any future possible variants that might be added.
type Animal = Bird | Fish | Reptile;
interface Reptile {
type: "reptile";
scaleColor: string;
hasTail: boolean;
}
function describeAnimal(animal: Animal) {
switch(animal.type) {
case "bird":
return "I am a bird with a beak length of " + animal.beakLength + " cm";
case "fish":
return "I am a fish with a scale texture like " + animal.scaleTexture;
case "reptile":
return `I am a reptile with ${animal.hasTail ? 'a' : 'no'} tail and scales are ${animal.scaleColor}.`;
default:
const exhaustiveCheck: never = animal;
throw new Error(`Unknown animal type: ${exhaustiveCheck}`);
}
}
// Test cases
let turtle: Reptile = { type: 'reptile', scaleColor: 'green', hasTail: true };
console.log(describeAnimal(turtle)); // Output: I am a reptile with a tail and scales are green.
In this example:
- We introduce a third variant
Reptile
. - Inside
describeAnimal
, thedefault
case ensures that every possible variant is handled. - If a new variant were added without a corresponding switch-case, the compiler would complain because
animal
would now have a different structure than thenever
type, thus catching an error during compilation.
Top 10 Interview Questions & Answers on TypeScript Type Guards and Discriminated Unions
Top 10 Questions and Answers on TypeScript Type Guards and Discriminated Unions
1. What are Type Guards in TypeScript?
function isString(val: any): val is string {
return typeof val === 'string';
}
function example(val: string | number) {
if (isString(val)) {
// val is typed as string here
console.log(val.toUpperCase());
} else {
// val is typed as number here
console.log(val.toFixed(2));
}
}
2. Can we use typeof
and instanceof
as Type Guards?
Answer: Yes, both typeof
and instanceof
can serve as type guards in TypeScript.
typeof
works well for primitive types likestring
,number
,boolean
, andsymbol
.instanceof
is used to check against classes for object instances.
function example(val: string | number) {
if (typeof val === 'string') {
console.log(val.charAt(0));
} else {
console.log(val.toFixed(2));
}
}
class Dog {}
class Cat {}
function animalExample(animal: Dog | Cat) {
if (animal instanceof Dog) {
// animal is typed as Dog here
console.log('Woof');
} else {
// animal is typed as Cat here
console.log('Meow');
}
}
3. What are Discriminated Unions in TypeScript?
Answer: Discriminated Unions are a powerful pattern in TypeScript that involves defining a common property or literal type across different types (often an enum or a specific string literal), which TypeScript uses to discriminate between them.
type Bird = {
type: 'bird';
canFly: boolean;
};
type Fish = {
type: 'fish';
swims: boolean;
};
type Animal = Bird | Fish;
function move(animal: Animal) {
switch (animal.type) {
case 'bird':
console.log(animal.canFly ? 'Flying' : 'Cant fly');
break;
case 'fish':
console.log(animal.swims ? 'Swimming' : 'Cant swim');
break;
}
}
4. Why do we need Discriminated Unions?
Answer: Discriminated Unions provide a way to model more sophisticated data types and behaviors. They enable type safety, reduce runtime errors, and make the code more maintainable by ensuring that every possible case is handled. Using a discriminating property means TypeScript can infer the shape of the object based on the value of that property.
5. How do I create a User-Defined Type Guard?
Answer: User-Defined Type Guards are custom functions that return a boolean and have a type predicate in the form parameterName is Type
. This type predicate informs TypeScript that the parameter inside the function is of the given type if the function returns true.
interface Cat {
kind: 'cat';
name: string;
meow: true;
}
interface Dog {
kind: 'dog';
name: string;
woof: true;
}
type Animal = Cat | Dog;
function isCat(animal: Animal): animal is Cat {
return animal.kind === 'cat';
}
function makeAnimalSpeak(animal: Animal) {
if (isCat(animal)) {
console.log('Meow');
} else {
console.log('Woof');
}
}
6. Explain the concept of Type Predicates in TypeScript.
Answer: Type Predicates in TypeScript are expressions used in a user-defined type guard function to tell the TypeScript compiler about the type of a variable within a particular scope. The syntax of a predicate is parameterName is Type
.
For example, animal is Cat
acts as a type predicate in the isCat
function, which returns true only if the animal
is of type Cat
.
7. Can Discriminated Unions be used with classes?
Answer: While Discriminated Unions are commonly used with interfaces or types, they can also work with classes. However, it requires defining a common property that acts as the discriminator.
class Circle {
kind: 'circle' = 'circle';
radius: number;
constructor(radius: number) {
this.radius = radius;
}
}
class Square {
kind: 'square' = 'square';
side: number;
constructor(side: number) {
this.side = side;
}
}
type Shape = Circle | Square;
function getArea(shape: Shape) {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.side ** 2;
}
}
8. In what scenarios should Discriminated Unions be preferred over Type Guards?
Answer: Discriminated Unions are preferred when you have multiple related types with common properties or fields that identify each type. They are especially useful in modeling large union types and avoiding runtime errors due to unhandled cases. Type Guards, on the other hand, are more flexible and can be used in a variety of situations that don’t necessarily involve unions.
9. What happens if I forget a case in a Discriminated Union?
Answer: If you forget a case in a Discriminated Union, TypeScript will flag an error unless you use a default case or ensure that all possible cases are explicitly handled. This is because Discriminated Unions require all cases to be accounted for to maintain type safety.
type Action = { type: 'ADD'; payload: number } | { type: 'SUBTRACT'; payload: number };
function handleAction(action: Action) {
switch (action.type) {
case 'ADD':
return action.payload;
case 'SUBTRACT':
return -action.payload;
// TypeScript will error if this default case is not included
default:
const exhaustiveCheck: never = action;
return exhaustiveCheck;
}
}
10. Can Type Guards and Discriminated Unions be used together?
Answer: Absolutely, Type Guards and Discriminated Unions can be used together to create more robust and flexible type-safe code. Discriminated Unions provide a clear, structured way to handle different types, while Type Guards can be used to further refine and check types within functions or conditional blocks.
Login to post a comment.