Ler em português

Type Narrowing in TypeScript

Either you haven't heard about type narrowing or not, I bet you've been using it if you've been coding TypeScript for a while. In short, type narrowing is a strategy used to refine a union type (e.g: number | string) to a specific one in order to take some action.

The information in this article was taken from the original and well-written docs, which I encourage you visiting if you want to have find more details.

Truthiness narrowing

This is probably the most common practice amongst the others. By checking the truthiness of a given expression we can narrow a type:

function sayHello(name: string | null) {
  if (name == null) {
    return 'Hi, guest' // (parameter) name: null
  }

  return `Hello, ${name}` // (parameter) name: string
}

Equality narrowing

By using the equality operators it's also possible to narrow types. In the below case, TypeScript assumes that if a value is equal to the other, they also have the same type:

function compare(a: string | null, b: string | number) {
  if (a === b) {
    a // (parameter) a: string
    b // (parameter) b: string
  } else {
    a // (parameter) a: string | null
    b // (parameter) b: string | number
  }
}

The typeof operator

typeof is a well-known operator in JavaScript world. When TypeScript sees it within a condition statement, it understands that as a type guard and can assume a new type:

function getYear(value: Date | number) {
  if (typeof value === 'number') {
    return new Date(value).getFullYear() // (parameter) value: number
  }

  return value.getFullYear() // (parameter) value: Date
}

The in operator

The in operator is used to identify if an object has a specific property. When used in a condition whose result is true, it narrows down the types to those whose the checked property is optional or required. On the opposite side, if condition leads to false, the type is narrowed to the ones whose property is either optional or missing.

type Sandwich = { ingredients: String[] }
type Pizza = { size: string }
type Salad = { size?: string, ingredients?: String[] }

function eat(food: Sandwich | Pizza | Salad) {
  if ('ingredients' in food) {
    // (parameter) food: Sandwich | Salad
  } else {
    // (parameter) food: Pizza | Salad
  }
}

Type predicates

Similar to how we used in operator, we can use type predicates to narrow a type, but this time without relying on JavaScript constructs. For that we need to create a function whose return type is a type predicate. Type predicate takes the following form: parameterName is Type, see below:

type Sandwich = { ingredients: string[] }
type Pizza = { size: string }

function isPizza(food: Sandwich | Pizza): food is Pizza {
  return (food as Pizza).size !== undefined
}

const food = getPizzaOrSandwich() // Sandwich | Pizza
if (isPizza(food)) {
  // const food: Pizza
} else {
  // const food: Sandwich
}

Discriminated unions

In some cases we might need to narrow types for more complex structures, such type unions of custom types. Assume we have following types:

type Fruit = { type: "fruit", color: string }
type Snack = { type: "snack", size: string }
type Food = Fruit | Snack

Although the below example is correct, TypeScript would not be able to identify the type, and will give an error:

function getRecipe(food: Food) {
  if (food.color !== undefined) {
    // Property 'color' does not exist on type 'Food'.
      // Property 'color' does not exist on type 'Snack'.
  }
}

In order to narrow the Food type, we need to use a common property for both Apple and Pizza. We consider that proper the discriminant property:

function getRecipe(food: Food) {
  if (food.type === 'fruit') {
    // (parameter) food: Fruit
  } else {
    // (parameter) food: Snack
  }
}

Conclusion

Narrowing types is a common task and usually simple. However, in more complex scenarios it can cause some confusion and become really annoying. Understanding the concepts can prevent us from getting some exhaustive and annoying error messages.