Resolving *ngSwitch Type Errors When Using Discriminated Unions in Angular Templates

Discriminated unions are extremely useful for defining advanced types in TypeScript. They provide a simple way to compose or combine existing types instead of creating new types from scratch.

To discriminate between the types used to compose the union, each type includes a field with a literal type that can be used by TypeScript to narrow down the current type. For example, we can create a union type Pet encompassing Dog, Bird, and Fish. Each type composing the union has a kind field, indicating the type of animal, and another field that is unique to each type.

Example


export interface Dog {
  kind: "dog";
  paws: number;
}

export interface Bird {
  kind: "bird";
  wings: number;
}

export interface Fish {
  kind: "fish";
  fins: number;
}

export type Pet = Dog | Bird | Fish;

In a recent project, my team was tasked with turning on TS strictness for an existing Angular project. Discriminated unions were crucial in composing complex types for the project. Unfortunately, we ran into an issue when trying to switch on the union type within an Angular template.

It turns out that, although Angular version 9 introduced strict type checking within templates with the fullTemplateTypeCheck option, this type checking does not extend to the *ngSwitch statement used within a template. The example below demonstrates the issue. Typescript does not narrow the union type based on the kind field as we would expect!

The Problem

<ul>
  <li *ngFor="let pet of pets">
    <p [ngSwitch]="pet.kind">
      <span *ngSwitchCase="dog"> 
        paws = {{ dog.paws }} 
      </span>
      <span *ngSwitchCase="bird"> 
        wings = {{ bird.wings }} 
      </span>
      <span *ngSwitchCase="fish"> 
        fins = {{ fish.fins }} 
      </span>
    </p>
  </li>
</ul>

Resulting errors:

Error in src/app/app.component.html
error TS2339: Property 'paws' does not exist on type 'Dog | Bird | Fish'. Property 'paws' does not exist on type 'Bird'.
error TS2339: Property 'wings' does not exist on type 'Dog | Bird | Fish'. Property 'wings' does not exist on type 'Dog'.
error TS2339: Property 'fins' does not exist on type 'Dog | Bird | Fish'. Property 'fins' does not exist on type 'Dog'.

While this is a known issue in Angular 9, turning off template type checking for the whole project based on one issue is not ideal – especially considering the benefits strict type checking provides. So, what can be done to quash the errors without sacrificing type safety?

The Workaround

Instead of trying to make *ngSwitch work for us, we can implement a simple solution using *ngif in combination with a pipe (many thanks to jaufgangfor providing this solution here). First, we create the pipe to provide us with the run-time type checking to discriminate the union and generate errors if the type does not belong to the union.


import { Pipe, PipeTransform } from '@angular/core';

export type TypeGuard<A, B extends A> = (a: A) => a is B;


@Pipe({
  name: 'guardType'
})
export class GuardTypePipe implements PipeTransform {
 transform<A, B extends A>(value: A, typeGuard: TypeGuard<A, B>): B {
    return typeGuard(value) ? value : undefined;
  }
}

We then implement type guard functions using this pipe.


import { TypeGuard } from './guard-type.pipe';

export interface Dog {
  kind: 'dog';
  paws: number;
}

export interface Bird {
  kind: 'bird';
  wings: number;
}

export interface Fish {
  kind: 'fish';
  fins: number;
}

export type Pet = Dog | Bird | Fish;

export const isDog: TypeGuard<Pet, Dog> = (pet: Pet): pet is Dog =>
pet.kind === 'dog';

export const isBird: TypeGuard<Pet, Bird> = (pet: Pet): pet is Bird =>
pet.kind === 'bird';

export const isFish: TypeGuard<Pet, Fish> = (pet: Pet): pet is Fish =>
pet.kind === 'fish';

Next, we must add the type guards to the component as class properties. This allows us to pass these functions to the guardType pipe within the template.


import { Component, VERSION } from "@angular/core";
import { isDog, isBird, isFish, Pet } from "./pets.type";

@Component({
selector: "my-app",
templateUrl: "./app.component.html",
styleUrls: ["./app.component.css"]
})
export class AppComponent {
  pets: Pet[] = [
    {
      kind: "dog",
      paws: 4
    },
    {
      kind: "bird",
      wings: 2
    },
    {
      kind: "fish",
      fins: 5
    }
  ];

  isDog = isDog;
  isBird = isBird;
  isFish = isFish;
}

Finally, we put it all together in our template like so:

<ul>
  <li *ngFor="let pet of pets">
    <strong>{{ pet.kind }}</strong
  >:
    <span *ngIf="pet | guardType: isDog as dog">
      paws = {{ dog.paws }}
    </span>
    <span *ngIf="pet | guardType: isBird as bird">
      wings = {{ bird.wings }}
    </span>
    <span *ngIf="pet | guardType: isFish as fish">
      fins = {{ fish.fins }}
    </span>
  </li>
</ul>

Now we can successfully discriminate types within the union. 🎉

Resolving *ngSwitch Type Errors with Discriminated Unions

Although this example requires a bit of extra work, it can make the difference between having full template type checking for your entire project or no template type checking at all. I hope this example helps you preserve TS strictness and keep template type checking turned on in your Angular project!