DEV Community

Cover image for Type-Safe TypeScript with Type Narrowing
Rainer Hahnekamp for This is Learning

Posted on • Updated on • Originally published at rainerhahnekamp.com

Type-Safe TypeScript with Type Narrowing

This article shows common patterns to maximize TypeScript's potential for type-safe code. These techniques are all part of the same group, which we call type narrowing.

The source code can be found on: https://github.com/rainerhahnekamp/type-safe-typescript-with-type-narrowing

If you prefer a video over an article, then this is for you:

1. Introduction

Whenever we deal with a variable that can be of multiple types, like an unknown or a union type, we can apply type narrowing, to "narrow" it down to one specific type. We work together with the TypeScript compiler because it understands the context of our code and guarantees that this narrowing happens in a fully type-safe way.

Let's say we have a function with a parameter of type Date | undefined. Every time the function executes, the variable's type can either be Date or undefined, but not both types at the same time.

function print(value: Date | undefined): void {}
Enter fullscreen mode Exit fullscreen mode

If we apply an if condition, checking if that variable is not undefined, TypeScript understands its meaning and treats the value inside the condition only as a string. This is type narrowing.

function print(input: Date | undefined): void {
  if (input !== undefined) {
    input.getTime(); // 👍 value is only Date
  }

  input.getTime(); // 💣 fails because value can be Date or undefined
}
Enter fullscreen mode Exit fullscreen mode

There is also a very similar technique which is called type assertion. It looks like the easier option at first sight. In terms of type-safety though, it is not. We manually set the type and therefore overwrite the compiler.

If the compiler could speak to us, it would say something like: "OK, you know what you are doing there, but don't blame me if something goes wrong."

Therefore we should avoid type assertion and always favour type narrowing. (And in general: trying to be smarter than the compiler is never a good idea.)

function print(input: Date | undefined): void {
  (input as Date).getTime(); // type assertion - don't!
}
Enter fullscreen mode Exit fullscreen mode

After this short introduction, let's come up with an example where we see the major type narrowing techniques in action.

This will be our "workbench":

declare function diffInYears(input: Date): number;
declare function parse(input: string): Date;

function calcAge(input: Date | null | undefined): number {
  return diffInYears(input); // will not work
}
Enter fullscreen mode Exit fullscreen mode

calcAge, should return - as the name says - the age.

Additionally, we use the two utility functions diffInYears and parse. For simplicity, the snippet doesn't show their implementation.

The type of input in calcAge can be of three different types. The return statement will therefore fail to compile.

2. Equality Narrowing

An obvious start would be to check if input doesn't have the value null and undefined. If that's the case, then it can only be a Date.

If we add this condition, TypeScript understands it and treats input inside the if-block as Date and we can safely call diffInYears.

This "implicit understanding" of TypeScript is already our first type guard and it is called "Equality Narrowing".

function calcAge(input: Date | null | undefined): number {
  if (input !== null && input !== undefined) {
    return diffInYears(input);
  }
}
Enter fullscreen mode Exit fullscreen mode

Please be aware, that the if condition is not a direct check for the type undefined or null. It runs against the value. In the same way, we would not be able to write a type check against a Date: value === Date. Date is the type of input and not its value.

So why can we use undefined and null then? The answer is quite obvious. The type undefined has just one value, which is 'undefined'. And the same is true for null. The type null can only have one value, and that is 'null'.

So what our condition really does, is that it is excluding all possible values that can come for undefined or null. For obvious reasons, we don't want want to exclude all possible values for type Date :)

3. typeof

Let's try another type guard. JavaScript provides typeof which we can place in front of any variable. As the name says, it will return the name of the variable's type as string… well not really. Otherwise we would have already reached the end of this article :).

In JavaScript, we have seven primitive types and the rest is just of type object. The primitive types are boolean, string, number, undefined, null, bigint, and symbol.

typeof returns the name of every primitive type, except for null. For null it returns 'object'. So one could assume that for null and any non-primitive type, we would get 'object'. But there is a second exception. typeof also returns 'function', if the variable is actually a function or if you pass the name of a class. Strictly speaking function is not a real type, it is a callable object.

With that knowledge, would the following code work?

function calcAge(input: Date | null | undefined): number {
  if (typeof input === "object") {
    return diffInYears(input);
  }
}
Enter fullscreen mode Exit fullscreen mode

No. Since typeof returns for null also 'object' the compilation will fail. So for now, we don't have any use for the typeof type guard, but let's keep it in mind. We might need it later.

4. Truthiness Narrowing

JavaScript invented two new English terms. Falsy and Truthy. If we put a falsy value into a condition, it will return false and true for a truthy value. There is an exhaustive list of falsy values in JavaScript. They are false, 0, 0n, '', null, NaN, and undefined.

We could use that to our advantage. If we put only the input into the if condition, it would be true if the value is not undefined or null. In our case, this is very similar to the equalness operator but just shorter.

function calcAge(input: Date | null | undefined): number {
  if (input) {
    return diffInYears(input);
  }
}
Enter fullscreen mode Exit fullscreen mode

We missed one tiny bit here. If the value is an empty string, it would also be false and we would not end up inside the if-condition. But that's OK for our use case, as long as we are aware of it.

Let's make our example more interesting. We add a fourth possible type which is a string. This is now the time where the typeof enters the game.

First, we get rid of the possibility that the value is null or undefined via truthiness narrowing and return the value 0 (we'll improve that later).

Then it is only between Date and string. And here we can use typeof to check if input is a string. If not, it can only be Date. Perfect!

function calcAge(input: Date | null | undefined | string): number {
  if (!input) {
    return 0;
  } else if (typeof input === "string") {
    return diffInYears(parse(input));
  } else {
    return diffInYears(input);
  }
}
Enter fullscreen mode Exit fullscreen mode

5. instanceof

There is also a possibility for type narrowing when we deal with classes. For that purpose, let's add a class Person, which has a property birthday of type Date.

class Person {
  birthday = new Date();
}

function calcAge(input: Date | null | undefined | string | Person): number {
  if (!input) {
    return 0;
  } else if (typeof input === "string") {
    return diffInYears(parse(input));
  } else {
    return diffInYears(input); // failure: can be Date or Person
  }
}
Enter fullscreen mode Exit fullscreen mode

Obviously,the last return in the else clause will fail because input can be of type Date or Person. The good message is though, that both are actually class instances and we can use instanceof.

instanceof returns true if a value is an instance of a particular class. So if we add a condition with a check for class Date, we are on the type-safe side again:

function calcAge(input: Date | null | undefined | string | Person): number {
  if (!input) {
    return 0;
  } else if (typeof input === "string") {
    return diffInYears(parse(input));
  } else if (input instanceof Date) {
    return diffInYears(input);
  } else {
    return diffInYears(input.birthday);
  }
}
Enter fullscreen mode Exit fullscreen mode

Be aware that instanceof returns true for the whole class inheritance chain. So if Person would extend from class Entity, instanceof Entity would also return true.

6. Discriminated Union

We use classes quite often, but more often we deal with object literals or - put in TypeScript jargon - interfaces or types.

Check out the slightly modified example:

type Person = {
  birthday: Date;
  category: "person";
};

type Car = {
  yearOfConstruction: Date;
  category: "car";
};

function calcAge(
  input: Date | null | undefined | string | Person | Car
): number {
  if (!input) {
    return 0;
  } else if (typeof input === "string") {
    return diffInYears(parse(input));
  } else if (input instanceof Date) {
    return diffInYears(input);
  } else {
    return diffInYears(input.birthday); // Person | Car
  }
}
Enter fullscreen mode Exit fullscreen mode

Ouch, again a compilation error. Can't we just use instanceof here as well? No. Person and Car are both only a type which is an element that only exists in TypeScript. When it is transpiled to JavaScript the definition of Person and Car is not there anymore. Classes, on the other hand, do also exist in JavaScript. That's why instanceof works for them.

Okay, so what can we do?

We are in a kind of lucky position. Both Person and Car share the same property category and each has a distinct value. By verifying if category has the value "car", TypeScript is smart enough to understand that it can only be of type Car. For Person the value would be 'person' obviously. The name of this type guard is "discriminated union". Let's fix our code again:

function calcAge(
  input: Date | null | undefined | string | Person | Car
): number {
  if (!input) {
    return 0;
  } else if (typeof input === "string") {
    return diffInYears(parse(input));
  } else if (input instanceof Date) {
    return diffInYears(input);
  } else if (input.category === "Car") {
    return diffInYears(input.yearOfConstruction);
  } else {
    return diffInYears(input.birthday);
  }
}
Enter fullscreen mode Exit fullscreen mode

We don't need to call the property for the discriminator category. We can pick whatever name we want.

7. in Type Guard

Consider the case where we are not so lucky to have a property that we can use as a discriminator value:

type Person = {
  birthday: Date;
};

type Car = {
  yearOfConstruction: Date;
};

function calcAge(
  input: Date | null | undefined | string | Person | Car
): number {
  if (!input) {
    return 0;
  } else if (typeof input === "string") {
    return diffInYears(parse(input));
  } else if (input instanceof Date) {
    return diffInYears(input);
  } else if (input.category === "Car") {
    return diffInYears(input.yearOfConstruction);
  } else {
    return diffInYears(input.birthday);
  }
}
Enter fullscreen mode Exit fullscreen mode

In such a case, we can make use of the in type guard. With in we can request, if an object has a certain property or not. So if it is between Person and Car, and the property birthday is present, the type can only be Person.

type Person = {
  birthday: Date;
};

type Car = {
  yearOfConstruction: Date;
};

function calcAge(
  input: Date | null | undefined | string | Person | Car
): number {
  if (!value) {
    return 0;
  } else if (typeof input === "string") {
    return diffInYears(parse(input));
  } else if (input instanceof Date) {
    return diffInYears(input);
  } else if ("birthday" in input) {
    return diffInYears(input.birthday);
  } else {
    return diffInYears(input.yearOfConstruction);
  }
}
Enter fullscreen mode Exit fullscreen mode

8. Type Predicate

Our next type guard is not a real type guard in that sense, it is some kind of a compromise.

First, let's replace the type Car with a type PersonJson. It has also a property birthday but it is of type string.

We could get away with it by typeof value.birthday === 'string'. This a combination of the typeof and the discriminated union type guard:

type Person = {
  birthday: Date;
};

type PersonJson = {
  birthday: string;
};

function calcAge(
  input: Date | null | undefined | string | Person | PersonJson
): number {
  if (!input) {
    return 0;
  } else if (typeof input === "string") {
    return diffInYears(parse(input));
  } else if (input instanceof Date) {
    return diffInYears(input);
  } else if (typeof input.birthday === "string") {
    return diffInYears(parse(input.birthday));
  } else {
    return diffInYears(input.birthday);
  }
}
Enter fullscreen mode Exit fullscreen mode

This code compiles and everything looks alright, but it is not perfect. If we would assign input to a new variable inside the last else if statement, we would see that TypeScript identifies the type not as PersonJson but as Person | PersonJson.

This is the point in time, where we reached the limits of TypeScript. Fortunately, it doesn't mean game over.

Whenever TypeScript runs out of options, it gives us the possibility to come up with a function that contains validation code for a particular type.

In a way, this is a compromise. We can write whatever we want in that function, as long as we return true or false. TypeScript will trust us.

This special function is called "type predicate" and for Person it looks like that:

function isPerson(value: Person | PersonJson): value is Person {
  return value.birthday instanceof Date;
}
Enter fullscreen mode Exit fullscreen mode

Please note the special notation where we normally have the return type.

We use the predicate as any other function and place it into the last else if condition:

type Person = {
  birthday: Date;
};

type PersonJson = {
  birthday: string;
};

function isPerson(value: Person | PersonJson): value is Person {
  return value.birthday instanceof Date;
}

function calcAge(
  input: Date | null | undefined | string | Person | PersonJson
): number {
  if (!input) {
    return 0;
  } else if (typeof input === "string") {
    return diffInYears(parse(input));
  } else if (input instanceof Date) {
    return diffInYears(input);
  } else if (isPerson(input)) {
    return diffInYears(input.birthday);
  } else {
    return diffInYears(parse(input.birthday));
  }
}
Enter fullscreen mode Exit fullscreen mode

9. Type Narrowing against unknown

With type predicates, we could even deal with variables of type unknown. Everything it takes, is to apply an if condition with the type predicate and voilà, problem gone.

But we have to be careful and shouldn't trick ourselves. The type narrowing is only as good as our validation logic.

If we would have a value of type unknown and we would like to verify if that has the "shape" of Person we would have to come up with a better code than the one we used before. By "shape", we mean any object literal or instance which has a property of birthday: Date.

9.1. Manual Validation

A type-safe type predicate would look like this:

function isPerson(value: unknown): value is Person {
  return (
    typeof value === "object" &&
    value !== null &&
    "birthday" in value &&
    (value as { birthday: unknown }).birthday instanceof Date
  );
}
Enter fullscreen mode Exit fullscreen mode

What a huge amount of code! Unfortunately, it is necessary if we want to be full type-safe.

The function shows another limitation of TypeScript's capabilities. Although we checked that birthday is a property of value, we still have to apply type assertion in the last condition to check if birthday is of type Date.

We can expect that over time TypeScript's limitations become less but at the moment it is what it is.

9.2. Automatic Validation: zod

We stay with type narrowing against the unknown type. Depending on the application type, we might quite often have to deal with the unknown type. Clearly, we don't want to write these huge type predicates all the time on our own. It takes quite a lot of precious time away.

Fortunately, it doesn't have to be like that. There are special libraries that do the validation automatically.

One of the most popular ones is "zod".

For every type, we have to come up with a schema first. This means we programmatically define the type and store it into a variable. So the schema information is also present during the runtime.

The generated schema is then used inside of a type predicate to validate if a value is of that type or not.

With zod, our isPerson would look like that:

const personSchema = z.object({
  birthday: z.date(),
});

type Person = z.infer<typeof personSchema>;

function isPerson(value: unknown): value is Person {
  return personSchema.safeParse(value).success;
}
Enter fullscreen mode Exit fullscreen mode

First, we define the schema and store it under personSchema. With an existing schema, we can ask zod to generate the type automatically for us. This works with z.infer. This simplifies things and also makes sure that we don't have double work when we change the Person type.

Last, we use the method safeParse which doesn't throw an error, if the value is not of type Person. It returns the result via the success property.

This is way much better than writing validation code manually all the time.

10. Assertion Functions

The last feature which is not a type guard, but comes in very handy, is the assertion function.

We return the number 0 in the first condition, when the type is unknown, null, or an empty string.

Returning number 0 is one way how to do it. The alternative is to throw an error.

If we throw an error, TypeScript's type narrowing will exclude undefined or null from the rest of the function's code.

An assertion function is a special function which does exactly do that. If we call it, it will - similar to the type guard - narrow down the parameter but it doesn't return a boolean. It guarantees us that it throws an error, if the value is not of the specific type.

Dealing with types that should not be undefined or null is so common, that TypeScript even provides a special type utility. It goes under the name NonNullable<T>. This type utility means, whatever type T is, it is not undefined or null. Let's see how NonNullable<T> and the assertion function work in action:

function assertNonNullable<T>(value: T): asserts value is NonNullable<T> {
  if (value === undefined || value === null) {
    throw new Error("undefined or null are not allowed");
  }
}

function calcAge(
  input: Date | null | undefined | string | Person | PersonJson
): number {
  assertNonNullable(input);

  if (typeof input === "string") {
    return diffInYears(parse(input));
  } else if (input instanceof Date) {
    return diffInYears(input);
  } else if (isPerson(input)) {
    return diffInYears(input.birthday);
  } else {
    return diffInYears(parse(input.birthday));
  }
}
Enter fullscreen mode Exit fullscreen mode

One final question must be allowed: When we throw an error, why don't we just remove them from the parameter's union type in the first place? Like function calcAge(value: Date | string | Person | PersonJson): number {}?

Well, because the caller might force us to include it. For example, in Angular, a form's input value is undefined when it is disabled. So we could of course say, that our program logic doesn't allow an undefined because we didn't provide a disable function. Nevertheless, the type of our form library has it and that's why it is among the parameter's types.

11. Summary

This article showed various techniques to deal with union types and how to narrow them down to one specific type. TypeScript is able to validate these patterns which means we are not trying to outsmart the compiler but produce code which is as much type-safe as possible.

We should always try to favour type narrowing over type assertion. It means more effort but we don't have to sacrifice type-safety.

Type-safety is actually the main reason, why we use TypeScript over JavaScript. We want to get as much type-safety as possible. From that perspective, the proper usage of type narrowing is the most important TypeScript skill for an application developer.

Top comments (0)