How to discriminate unions with generics?

Viewed 314

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?

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)

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
    }
Related