LinkedIn
LinkedInLinkedIn

Mastering type guards in TypeScript

Sun, June 11, 2023

I've been on a TypeScript journey for years, and I admit I didn't start off on the right foot. Dealing with undefined or poorly defined variable types and situations where variables could have multiple types left me scratching my head. At the time, I didn't know any better, so I resorted to casting variables directly to the type I hoped they were. Surprisingly, it worked most of the time, and I thought that was my only option given the library or the specific case I was working with.

I quickly discovered my lifesavers: TypeScript type guards! These little helpers transformed the way I handle unknown types cases, saving me from those nasty runtime errors and unlocking the true potential of TypeScript. The act of eliminating the type ambiguity of a variable is called "narrowing" in TypeScript, the type guards are the tools that help you achieve that.

Here is a quick overview of the type guards you can use in TypeScript:

"instanceof"

Imagine you have classes representing different animals. With the "instanceof" type guard, you can confidently check if an object belongs to a specific class or its subclasses. It's like having a map guiding you through class hierarchies, ensuring you access the right properties without worry. Example:

class Animal {
  name: string;
  constructor(name: string) {
    this.name = name;
  }
}

class Dog extends Animal {
  breed: string;
  constructor(name: string, breed: string) {
    super(name);
    this.breed = breed;
  }
}

const animal: Animal = new Dog('Buddy', 'Golden Retriever');

if (animal instanceof Dog) {
  console.log(animal.breed); // Accessing Dog-specific property
}

"typeof"

When dealing with variables that can have different primitive types, the "typeof" type guard comes to the rescue. It helps you determine the actual type of a variable, whether it's a string, number, boolean, or symbol. Armed with this knowledge, you can perform type-specific operations confidently. Example:

function processValue(value: string | number) {
  if (typeof value === 'string') {
    console.log(value.toUpperCase()); // String-specific operation
  } else if (typeof value === 'number') {
    console.log(value.toFixed(2)); // Number-specific operation
  }
}

"in"

The in operator allows you to check if a property exists in an object. It can be used to narrow down the type of an object based on the presence of a specific property.

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

function processPerson(person: Person) {
  if ('age' in person) {
    console.log(person.age); // Property 'age' exists, so it's safe to access
  } else {
    console.log('Age not specified');
  }
}

Custom user-defined type guards

Finally, here is the type guard I wanted to talk about today! The others were pretty basic and you probably use on a regular basis if you're using TypeScript. But what if you want to check for a more complex type? Sometimes, the predefined guards don't cover all the unique scenarios you encounter. That's where custom type guards shine. Using the "is" keyword, you can define your own checks to determine the type of a variable. It gives you the freedom to handle complex situations with precision. Example:

interface Car {
  brand: string;
}

interface Bike {
  type: string;
}

function isCar(vehicle: Car | Bike): vehicle is Car {
  return (vehicle as Car).brand !== undefined;
}

function printVehicleInfo(vehicle: Car | Bike) {
  if (isCar(vehicle)) {
    console.log('Brand:', vehicle.brand);
  } else {
    console.log('Type:', vehicle.type);
  }
}

All of that is great, but what about real life examples? Let's take a look at a scenario where type guards can be useful. I was recently using the opensearch-js library to interact with an OpenSearch cluster. The library provides a client that allows you to perform CRUD operations on the cluster. Anyway, using this library, when querying the cluster, you're using the following response type:

export interface SearchResponse<TDocument = unknown> {
  took: long;
  timed_out: boolean;
  _shards: ShardStatistics;
  hits: SearchHitsMetadata<TDocument>;
  aggregations?: Record<AggregateName, AggregationsAggregate>;
  _clusters?: ClusterStatistics;
  documents?: TDocument[];
  fields?: Record<string, any>;
  max_score?: double;
  num_reduce_phases?: long;
  profile?: SearchProfile;
  pit_id?: Id;
  _scroll_id?: ScrollId;
  suggest?: Record<SuggestionName, SearchSuggest<TDocument>[]>;
  terminated_early?: boolean;
}

As you can see TDocument is a generic type, which means you can specify the type of the documents you're querying. However, notice the aggregations property. It's a Record of AggregateName and AggregationsAggregate. The AggregateName is a string, and the AggregationsAggregate is a union type of different types of aggregations. BUT, there is no way to know which type of aggregation you're getting back from the cluster or even specify the aggregation type. So, how do you handle this situation?

Your first option would be to patch the type directly in the library. But that's not ideal, because you'll have to do it every time you update the library. Not to mention, you'll have to maintain your own fork of the library. Not fun. The second option is to cast the aggregation to the type you think it is. But that's not ideal either, because you're not 100% sure of the type, and you might end up with a runtime error. The third option, and the one I'm advocating for, is to use a custom type guard to determine the type of aggregation you're dealing with. Here is an example:

// DO NOT DO THIS!
import { AggregationsMultiBucketAggregate } from '@elastic/opensearch';

type MyAggregation = AggregationsMultiBucketAggregate<{
  myAverage: { value: number };
  mySum: { value: number };
}>;

// DO NOT DO THIS!
const aggregation = response.aggregations?.my_aggregation as MyAggregation;
console.log('Average:', response.aggregations?.my_aggregation.myAverage.value);
console.log('Sum:', response.aggregations?.my_aggregation.mySum.value);

// DO THIS INSTEAD!
function isMyAggregation(
  aggregation: AggregationsAggregate
): aggregation is MyAggregation {
  if (typeof aggregation !== 'object' || aggregation === null) {
    return false;
  }

  const myAverage = (aggregation as MyAggregation).myAverage;
  const mySum = (aggregation as MyAggregation).mySum;

  return (
    typeof myAverage === 'object' &&
    typeof myAverage.value === 'number' &&
    typeof mySum === 'object' &&
    typeof mySum.value === 'number'
  );
}

if (isMyAggregation(response.aggregations?.my_aggregation)) {
  console.log(
    'Average:',
    response.aggregations?.my_aggregation.myAverage.value
  );
  console.log('Sum:', response.aggregations?.my_aggregation.mySum.value);
}

Embracing type guards in TypeScript has been a game-changer for me. They banish those pesky runtime errors caused by hasty type casting and ensure my code is solid. By harnessing the power of type guards, you can bid farewell to runtime errors and enjoy a coding experience that's more collaborative, maintainable, and productive. Feel free to explore the TypeScript documentation for more insights and examples.

Happy coding, fellow TypeScript enthusiasts!