angular: Discriminated union types not resolved properly with ngSwitch

šŸž bug report

Affected Package

@angular/compiler
@angular/language-service

Is this a regression?

No

Description

In templates, type resolution fails when using ngSwitch on the discriminant property of a discriminated union type; in all cases, the type seems to be resolved to the first option.

šŸ”¬ Minimal Reproduction

The following example uses an enum value as the discriminant, but the same behaviour is seen when using string values as the discriminant. See https://github.com/jinbijin/minimal-example for the full repository; itā€™s a new Angular project except for: app.component.ts:

import { Component } from '@angular/core';

enum MaybeType {
  Nothing = 0,
  Just = 1
}

interface Nothing {
  type: MaybeType.Nothing;
}

interface Just<T> {
  type: MaybeType.Just;
  value: T;
}

type Maybe<T> = Nothing | Just<T>;

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  MaybeType = MaybeType;
  maybeText: Maybe<string> = { type: MaybeType.Just, value: 'hello!' };
}

app.component.html:

<ng-container [ngSwitch]="maybeText.type">
  <span *ngSwitchCase="MaybeType.Nothing">Nothing</span>
  <span *ngSwitchCase="MaybeType.Just">{{ maybeText.value }}</span>
</ng-container>

<router-outlet></router-outlet>

Expected behaviour: Project compiles without any errors. With strict full template type checking the language service doesnā€™t generate errors.

Actual behaviour: The following error is generated on running ng serve with full template type checking:

ERROR in src/app/app.component.html:3:43 - error TS-992339: Property 'value' does not exist on type 'Maybe<string>'.
  Property 'value' does not exist on type 'Nothing'.

3   <span *ngSwitchCase="MaybeType.Just">{{ maybeText.value }}</span>
                                            ~~~~~~~~~~~~~~~~

  src/app/app.component.ts:21:16
    21   templateUrl: './app.component.html',
                      ~~~~~~~~~~~~~~~~~~~~~~
    Error occurs in the template of component AppComponent.

With strict template checking, the language service generates the following error:

Identifier 'value' is not defined. 'Maybe<string>' does not contain such a member

šŸŒ Your Environment

Angular Version:

     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / ā–³ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |
 /_/   \_\_| |_|\__, |\__,_|_|\__,_|_|       \____|_____|___|
                |___/
    

Angular CLI: 9.0.0-rc.7
Node: 12.6.0
OS: darwin x64

Angular: 9.0.0-rc.7
... animations, cli, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router
Ivy Workspace: Yes

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.900.0-rc.7
@angular-devkit/build-angular     0.900.0-rc.7
@angular-devkit/build-optimizer   0.900.0-rc.7
@angular-devkit/build-webpack     0.900.0-rc.7
@angular-devkit/core              9.0.0-rc.7
@angular-devkit/schematics        9.0.0-rc.7
@ngtools/webpack                  9.0.0-rc.7
@schematics/angular               9.0.0-rc.7
@schematics/update                0.900.0-rc.7
rxjs                              6.5.3
typescript                        3.6.4
webpack                           4.41.2

Anything else relevant? tsconfig.json:

{
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "./dist/out-tsc",
    "sourceMap": true,
    "declaration": false,
    "downlevelIteration": true,
    "experimentalDecorators": true,
    "module": "esnext",
    "moduleResolution": "node",
    "importHelpers": true,
    "target": "es2015",
    "typeRoots": [
      "node_modules/@types"
    ],
    "lib": [
      "es2018",
      "dom"
    ]
  },
  "angularCompilerOptions": {
    "fullTemplateTypeCheck": true,
    "strictInjectionParameters": true,
    "strictTemplates": true
  }
}

About this issue

  • Original URL
  • State: closed
  • Created 5 years ago
  • Reactions: 64
  • Comments: 32 (8 by maintainers)

Most upvoted comments

The solution to this problem is to use *ngIf with a pipe that evaluates a type guard and returns the original object with a narrowed type if the object passes the type guard and undefined if it doesnā€™t

// guard-type.pipe.ts

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 | undefined {
    return typeGuard(value) ? value : undefined;
  }

}

This pipe effectively narrows the type without ā€œlying to the compilerā€. (it actually performs proper run-time type checking via the type guard function). It will also throw an error if the object being type-checked cannot be narrowed by the type guard (i.e. it is not a member of the union type being narrowed)

Sample usage:

// shapes.type.ts

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

export interface Square {
  kind: "square";
  size: number;
}

export interface Rectangle {
  kind: "rectangle";
  width: number;
  height: number;
}

export interface Circle {
  kind: "circle";
  radius: number;
}

export type Shape = Square | Rectangle | Circle;

export const isSquare: TypeGuard<Shape, Square> = (
  shape: Shape
): shape is Square => shape.kind === "square";

export const isRectangle: TypeGuard<Shape, Rectangle> = (
  shape: Shape
): shape is Rectangle => shape.kind === "rectangle";

export const isCircle: TypeGuard<Shape, Circle> = (
  shape: Shape
): shape is Circle => shape.kind === "circle";
// app.component.ts

import { Component, VERSION } from "@angular/core";
import { isCircle, isRectangle, isSquare, Shape } from "./shapes.type";

@Component({
  selector: "my-app",
  templateUrl: "./app.component.html",
  styleUrls: ["./app.component.css"]
})
export class AppComponent {
  shapes: Shape[] = [
    {
      kind: "square",
      size: 5
    },
    {
      kind: "circle",
      radius: 10
    },
    {
      kind: "rectangle",
      height: 7,
      width: 9
    },
    {
      kind: "rectangle",
      height: 8.5,
      width: 11
    },
    {
      kind: "circle",
      radius: 8
    }
  ];

  // set the imported type guard functions as component properties
  // so they can be passed by the template to the guardType pipe
  isCircle = isCircle;
  isRectangle = isRectangle;
  isSquare = isSquare;
}

<!-- app.component.html -->
<ul>
  <li *ngFor="let shape of shapes">
    <strong>{{shape.kind}}</strong>:

    <span *ngIf="shape| guardType: isCircle as circle">
      <!-- circle is strongly typed as a Circle -->
      radius = {{circle.radius}}
    </span>

    <span *ngIf="shape| guardType: isSquare as square">
      <!-- square is strongly typed as a Square -->
      size = {{square.size}}
    </span>

    <span *ngIf="shape| guardType: isRectangle as rectangle">
      <!-- rectangle is strongly typed as a Rectangle -->
      width = {{rectangle.width}}, height = {{rectangle.height}}
    </span>
  </li>
</ul>

which renders as: image

I created a StackBlitz to demonstrate this live in action. See: https://stackblitz.com/edit/angular-guard-type-pipe

(edit: fixed error in Rectangle type guard)

This is something we would really love to see fixed. We use discriminated unions very heavily in our code, which also means we canā€™t turn on the strict type checking yet, even though it would provide a tremendous value for us.

This is really importantā€¦ The amount of if else in template based on discriminate union that needs those hacks is too much to not fix itā€¦

This is a big challenge, mainly as itā€™s a design limitation with how NgSwitch works. I think we should look into ways of doing this, but realistically I donā€™t think this is something we can address quickly.

@jaufgang thanks for this easy and reusable solution. I adapted it a little bit so it also works for unknown data as well.

export type TypeGuard<T> = (a: unknown) => a is T;

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

https://angular-guard-type-pipe-ahwgyp.stackblitz.io

Closing as this is now a solved problem with the new control flow syntax.

Any chances this annoying bug is going to be fixed any time soon?

Maybe some way to cast values in templates could be a workaround?

Introducing workarounds as features is not a good idea either. Thereā€™s also other issues, however, that pitch the idea of bringing types into the templates in some way.

In any case, type-casting like that is already possible in a slightly more verbose way (consider replacing ngIf with something that can deal with falsy values). Just be aware that at the end of the day this is still just a lie to the compiler and not bullet-proof.

public readonly asFoo = (x: unknown): Foo => x as Foo;
<ng-container *ngIf="asFoo(untypedX) as x">
  {{ x.notInFoo }} <!-- error -->
</ng-container>

Personally I use this ā€œtrickā€ everytime I need to type the context variables of an ng-template which would otherwise just be any.

This is a big challenge [ā€¦] but realistically I donā€™t think this is something we can address quickly.

Maybe some way to cast values in templates could be a workaround?

<span *ngSwitchCase="MaybeType.Just" ngCast="maybeText as Just">{{ maybeText.value }}</span>

Of course right now we can use $any:

<span *ngSwitchCase="MaybeType.Just">{{ $any(maybeText).value }}</span>

As long as it is as simple as this example this works, although it completely looses type-checks. However when the element contains many references to the type in question, the template becomes quickly littered with $any-calls. Some way to cast for a whole element would reduce this.

@jaufgang, yep, that fixed it. Thanks for looking into it.

I am familiar with the advisory against invoking methods or getters directly in templates, though I am somewhat skeptical that it translates to poor performance here, given that the type guard method is so lightweight. I did a quick performance audit in my browser and didnā€™t observe a meaningful difference between the two approaches (your ā€˜pure pipeā€™ vs directly calling the guard).

In any case, thanks for putting the work into this workaround. Hopefully the underlying issue gets an actual fix soon.

FYI , running npm install -S @angular/compiler@11.0.9 in the downloaded stackblitz was enough to get it to build cleanly for me.

Iā€™ve created a stackblitz to report this issue, and only after that I was able to find this issue thatā€™s already open. Anyway, just in case it helps, hereā€™s my stackblitz: https://stackblitz.com/edit/angular-issue-ngswitch-type-narrowing

This issue is quite annoying, so I really hope that it gets enough attention from the Angular team and gets fixed soon.

(Animal & { type: T })

This is not what we want to represent. instead, we want to have a subtype of Animal that satisfies the constraint type: 'some-type'.

You can see example implementation in the playground

I also created gist with usage example https://gist.github.com/amakhrov/a7b2a30b13668f714d40e095db437486

Hope this helps.

This is an actually really annoying issue which happens with any GraphQL generated schema. It also prevents my team from using strict template checking.

We hope this is addressed soon.

Our workaround for the time being (instead of disabling template checking completely) is to set the type of these values to any. Is there any better solution to apply until this is resolved?

@grantwhiting Nope, this one hasnā€™t been figured out yet. I donā€™t see this being modeled using the currently available type checking tools, as ngSwitchCase expressions have to be combined with the corresponding ngSwitch case during type checking codegen, so I think there is a need for some custom codegen logic.