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.
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.
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.
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.
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.
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 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; } };
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
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With