Skip to content

Instantly share code, notes, and snippets.

@amakhrov
Last active May 5, 2022 20:56
Show Gist options
  • Save amakhrov/a7b2a30b13668f714d40e095db437486 to your computer and use it in GitHub Desktop.
Save amakhrov/a7b2a30b13668f714d40e095db437486 to your computer and use it in GitHub Desktop.
Picking a subtype of a union type in Angular templates

Problem

Angular doesn't do type narrowing based on conditions in *ngIf or *ngSwitchCase (angular/angular#20780). And, making it worse, there is no way to write a type assertion directly in a template. With strictInputTypes check turned on it can result in a number of false positive compile time errors.

A common workaround is to have a method in your component that does type assertion, and call this method in a template.

Naive approach

@Component({
  template: `
<component-a
  *ngIf="item.type === 'type-A'"
  [inputOfTypeA]="asTypeA(item)"
>
  `
})
export class ComponentAorB {
  asTypeA = (item: A | B): A => item as A; // note a type assertion here
}

It's a lot of boilerplate: you need a separate method for each subtype you need to narrow down to. And it's not type safe. Nothing prevents you from accidentally doing smth like this:

<component-a
  *ngIf="item.type === 'type-B'"
  [inputOfTypeA]="asTypeA(item)"
>

Adding type safety

An evolution of this method could be to combine the runtime check with type assertion in a single method in a component:

@Component({
  template: `
<component-a
  *ngIf="maybeAsTypeA(item) as itemA"
  [inputOfTypeA]="itemA"
>
  `
})
export class ComponentAorB {
  maybeAsTypeA = (item: A | B): A | undefined =>
    item.type === 'type-A' ? item : undefined
}

Here we have improved type safety - see how there is no type assertions at all. But still a lot of boilerplate, as we need a separate method for each subtype of the union type.

Fighting boilerplate

With a bit of generic magic we could extract this boilerplate into a reusable function - see below.

Related links:

type A = {
type: 'type-A',
a: string
}
type B = {
type: 'type-A',
b: string
}
@Component({
template: `
<component-a
*ngIf="asSubtype('type-A', item) as concreteItem"
[inputOfTypeA]="concreteItem"
>
<component-B
*ngIf="asSubtype('type-B', item) as concreteItem"
[inputOfTypeB]="concreteItem"
>
`
})
export class ComponentAorB {
@Input item: A | B;
asSubtype = asSubtypeFactory<A | B>()('type');
}
// based on https://stackoverflow.com/a/54600446/2638874
export type Subtype<Union, DtrKey extends keyof Union, DtrValue extends Union[DtrKey]> =
Union extends (infer Inferred)
? Inferred extends Union
? Inferred[DtrKey] extends DtrValue
? Inferred
: never
: never
: never;
/**
* Given a union type and a discriminator property name,
* returns a function that returns a specific subtype of the union type based on the discriminator value
*
* @example
* type Dog = { species: 'dog', breed: string };
* type Cat = { species: 'cat', meowVolume: number };
* type Animal = Dog | Cat;
*
* const asSubtype = asSubtypeFactory<Animal>()('species');
* asSubtype('dog', animal); // inferred as (Dog | undefined)
*
* // match against multiple types at once
* asSubtype(['dog', 'cat]', animal); // inferred as (Dog | Cat | undefined)
*/
const asSubtypeFactory = function<Union>() {
return <DtrKey extends keyof Union>(key: DtrKey) =>
<T extends Union[DtrKey]>(dtr: T | T[], union: Union): Subtype<Union, DtrKey, T> | undefined => {
const dtrArr = Array.isArray(dtr) ? dtr : [dtr];
if (dtrArr.indexOf(union[key] as T) !== -1) {
// potentially unsafe type assertion - but we know what we're doing
return union as Subtype<Union, DtrKey, T>;
}
return undefined;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment