Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Discriminated Union of Generic type

I'd like to be able to use union discrimination with a generic. However, it doesn't seem to be working:

Example Code (view on typescript playground):

interface Foo{     type: 'foo';     fooProp: string }  interface Bar{     type: 'bar'     barProp: number }  interface GenericThing<T> {     item: T; }   let func = (genericThing: GenericThing<Foo | Bar>) => {     if (genericThing.item.type === 'foo') {          genericThing.item.fooProp; // this works, but type of genericThing is still GenericThing<Foo | Bar>          let fooThing = genericThing;         fooThing.item.fooProp; //error!     } } 

I was hoping that typescript would recognize that since I discriminated on the generic item property, that genericThing must be GenericThing<Foo>.

I'm guess this just isn't supported?

Also, kinda weird that after straight assignment, it fooThing.item loses it's discrimination.

like image 207
NSjonas Avatar asked Jun 15 '18 06:06

NSjonas


People also ask

How is a discriminated union defined?

Discriminated unions are useful for heterogeneous data; data that can have special cases, including valid and error cases; data that varies in type from one instance to another; and as an alternative for small object hierarchies. In addition, recursive discriminated unions are used to represent tree data structures.

What is discriminated union TypeScript?

A discriminated type union is where you use code flow analysis to reduce a set of potential objects down to one specific object. This pattern works really well for sets of similar objects with a different string or number constant for example: a list of named events, or versioned sets of objects.

How do you handle a union type in TypeScript?

TypeScript Union Type Narrowing To narrow a variable to a specific type, implement a type guard. Use the typeof operator with the variable name and compare it with the type you expect for the variable.

When would you use an intersection type instead of a union type?

Intersection types are closely related to union types, but they are used very differently. An intersection type combines multiple types into one. This allows you to add together existing types to get a single type that has all the features you need.


2 Answers

The problem

Type narrowing in discriminated unions is subject to several restrictions:

No unwrapping of generics

Firstly, if the type is generic, the generic will not be unwrapped to narrow a type: narrowing needs a union to work. So, for example this does not work:

let func = (genericThing:  GenericThing<'foo' | 'bar'>) => {     switch (genericThing.item) {         case 'foo':             genericThing; // still GenericThing<'foo' | 'bar'>             break;         case 'bar':             genericThing; // still GenericThing<'foo' | 'bar'>             break;     } } 

While this does:

let func = (genericThing: GenericThing<'foo'> | GenericThing<'bar'>) => {     switch (genericThing.item) {         case 'foo':             genericThing; // now GenericThing<'foo'> !             break;         case 'bar':             genericThing; // now  GenericThing<'bar'> !             break;     } } 

I suspect unwrapping a generic type that has a union type argument would cause all sorts of strange corner cases that the compiler team can't resolve in a satisfactory way.

No narrowing by nested properties

Even if we have a union of types, no narrowing will occur if we test on a nested property. A field type may be narrowed based on the test, but the root object will not be narrowed:

let func = (genericThing: GenericThing<{ type: 'foo' }> | GenericThing<{ type: 'bar' }>) => {     switch (genericThing.item.type) {         case 'foo':             genericThing; // still GenericThing<{ type: 'foo' }> | GenericThing<{ type: 'bar' }>)             genericThing.item // but this is { type: 'foo' } !             break;         case 'bar':             genericThing;  // still GenericThing<{ type: 'foo' }> | GenericThing<{ type: 'bar' }>)             genericThing.item // but this is { type: 'bar' } !             break;     } } 

The solution

The solution is to use a custom type guard. We can make a pretty generic version of the type guard that would work for any type parameter that has a type field. Unfortunately, we can't make it for any generic type since it will be tied to GenericThing:

function isOfType<T extends { type: any }, TValue extends string>(   genericThing: GenericThing<T>,   type: TValue ): genericThing is GenericThing<Extract<T, { type: TValue }>> {   return genericThing.item.type === type; }  let func = (genericThing: GenericThing<Foo | Bar>) => {   if (isOfType(genericThing, "foo")) {     genericThing.item.fooProp;      let fooThing = genericThing;     fooThing.item.fooProp;   } }; 
like image 192
Titian Cernicova-Dragomir Avatar answered Oct 31 '22 18:10

Titian Cernicova-Dragomir


As @Titian explained the problem arises when what you really need is:

GenericThing<'foo'> | GenericThing<'bar'> 

but you have something defined as:

GenericThing<'foo' | 'bar'> 

Clearly if you only have two choices like this you can just expand it out yourself, but of course that isn't scalable.

Let's say I have a recursive tree with nodes. This is a simplification:

// different types of nodes export type NodeType = 'section' | 'page' | 'text' | 'image' | ....;  // node with children export type OutlineNode<T extends NodeType> = AllowedOutlineNodeTypes> =  {     type: T,     data: NodeDataType[T],   // definition not shown      children: OutlineNode<NodeType>[] } 

The types represented by OutlineNode<...> need to be a discriminated union, which they are because of the type: T property.

Let's say we have an instance of a node and we iterate through the children:

const node: OutlineNode<'page'> = ....; node.children.forEach(child =>  {    // check a property that is unique for each possible child type    if (child.type == 'section')     {        // we want child.data to be NodeDataType['section']        // it isn't!    } }) 

Clearly in this case I don't want to define children with all possible node types.

An alternative is to 'explode out' the NodeType where we define children. Unfortunately I couldn't find a way to make this generic because I can't extract out the type name.

Instead you can do the following:

// create type to take 'section' | 'image' and return OutlineNode<'section'> | OutlineNode<'image'> type ConvertToUnion<T> = T[keyof T]; type OutlineNodeTypeUnion<T extends NodeType> = ConvertToUnion<{ [key in T]: OutlineNode<key> }>; 

Then the definition of children changes to become:

 children: OutlineNodeTypeUnion<NodeType>[] 

Now when you iterate through the children it's an expanded out definition of all possibilities and the descriminated union type guarding kicks in by itself.

Why not just use a typeguard? 1) You don't really need to. 2) If using something like an angular template you don't want a bazillion calls to your typeguard. This way does in fact allow automatic type narrowing within *ngIf but unfortunately not ngSwitch

like image 36
Simon_Weaver Avatar answered Oct 31 '22 17:10

Simon_Weaver