Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to discriminate unions with generics?

I am struggling to automatically infer the type of different kind of items based on their geometry (in the context of displaying some GeoJSON data).

I am using a generic types, therefore I did not manage to set a custom typeguards, since it would allow me to distinguish "Individual" items from "Aggregates", but not different type of "Individual" items.

Basically, I need to level of inference:

  • discriminating Individual items from Aggregates
  • discriminating different geometries within each category.

I've created a simplified example, in my real app I have 4 different types of items which may have different possible geometries.

Here is a TypeScript playground, and the code below:

type A = {type: "A", a: string}
type B = {type: "B", b: string}
type C = {type: "C", c: string}
type Geometries = A | B | C

type IndividualFeature<G extends A | B = A | B> = { geometry: G, indivAttribute: string}
type AggregateFeature = { geometry: C, aggAttribute: string}

type DisplayableFeature = IndividualFeature | AggregateFeature


const display = (feature: DisplayableFeature) => {
    switch(feature.geometry.type) {
        case "A":
            console.log("type A", feature.geometry.a, feature.indivAttribute);
            return;
        case "B":
            console.log("type B", feature.geometry.b, feature.indivAttribute)
            return;
        case "C": 
            console.log("type C", feature.geometry.c, feature.aggAttribute)
        default:
        // should not happen
    }
}

const indivFeature: IndividualFeature = { geometry: { type: "A", a: "a"}, indivAttribute: "hello indiv"}
const aggFeature: AggregateFeature = { geometry: { type: "C", c: "c"}, aggAttribute: "hello agg"}

The geometry is correctly discriminated, but not individually vs aggregates (the feature.indivAttribute/feature.aggAttribute trigger an error). For the record, I've tried a typeguard: this allows me to differentiate "Indiv" and "Aggregates", but I've lost the discrimination of the geometry.

How should I structure my types/code so feature.indivAttribute is correctly recognized as a valid attribute in this example?

like image 790
Eric Burel Avatar asked May 20 '21 09:05

Eric Burel


People also ask

What is a discriminated union in TypeScript?

The concept of discriminated unions is how TypeScript differentiates between those objects and does so in a way that scales extremely well, even with larger sets of objects. As such, we had to create a new ANIMAL_TYPE property on both types that holds a single literal value we can use to check against.

Are the unions of F# discriminated briefly explain?

In F#, a sum type is called a “discriminated union” type. Each component type (called a union case) must be tagged with a label (called a case identifier or tag) so that they can be told apart (“discriminated”). The labels can be any identifier you like, but must start with an uppercase letter.


2 Answers

This is indeed a typescript limitation, even without the generic. There is an existing github issue here: microsoft/TypeScript#18758. There is also a PR with some recent activity: microsoft/TypeScript#38839.

Narrowing a union based on a nested discriminated union is currently not possible. The discriminant must be on the same "level".


As a workaround you could write a custom type guard like so:

type AllTypes = DisplayableFeature["geometry"]["type"] // "A" | "B" | "C"

type FeatueOfType<T extends AllTypes> = {
    "A": IndividualFeature<A>,
    "B": IndividualFeature<B>,
    "C": AggregateFeature
}[T]

function isFeatueOfType<T extends AllTypes>(
    feature: DisplayableFeature, type: T
): feature is FeatueOfType<T> {
    return feature.geometry.type === type
}

FeatueOfType<T> maps a geometry type to its feature type. E.g. FeatueOfType<"A"> would equal IndividualFeature<A>. (This type could potentially be produced from DisplayableFeature directly instead of writing it by hand, but that might get complicated.)

When you then call the typeguard like isFeatueOfType(feature, "A") and the feature.geometry.type === type check succeeds, we tell typescript that the type of feature has to be FeatueOfType<"A">, i.e. IndividualFeature<A>.

(Note that when there is a bug in a type guard, like writing feature.geometry.type !== type above, typescript is not able to catch that. So it's always advisable to properly test them.)

Usage:

const display = (feature: DisplayableFeature) => {
    if (isFeatueOfType(feature, "A")) {
        doSometingWithA(feature) // typechecks
        console.log("type A", feature.geometry.a, feature.indivAttribute);
    }
    else if (isFeatueOfType(feature, "B")) {
        console.log("type B", feature.geometry.b, feature.indivAttribute)
    }
    else if (isFeatueOfType(feature, "C")) {
        console.log("type C", feature.geometry.c, feature.aggAttribute)
    }
    else {
        throw Error()
    }
}

function doSometingWithA(a: IndividualFeature<A>) {}

(Playground link)

like image 101
mihi Avatar answered Oct 06 '22 00:10

mihi


maybe just cast feature ?

switch(feature.geometry.type) {
        case "A": console.log("type A", feature.geometry.a, (feature as IndividualFeature).indivAttribute);
            return;
        case "B":
            console.log("type B", feature.geometry.b, (feature as IndividualFeature).indivAttribute)
            return;
        case "C": 
            console.log("type C", feature.geometry.c, (feature as AggregateFeature).aggAttribute)
        default:
        // should not happen
    }
like image 35
iolo Avatar answered Oct 06 '22 00:10

iolo