6

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?

Hugo Gresse
  • 17,195
  • 9
  • 77
  • 119
Eric Burel
  • 3,790
  • 2
  • 35
  • 56
  • 2
    Is there a reason why you would not want to have flatter types? ` type A = {type: "A", a: string, indivAttribute: string}; type B = {type: "B", b: string, indivAttribute: string}; type C = {type: "C", c: string, aggAttribute: string}; type IndividualFeature = A | B; type AggregateFeature = C; type DisplayableFeature = IndividualFeature | AggregateFeature; ` – Johann-Michael Thiebaut May 20 '21 at 11:32
  • Yes, that would make sense and I am investigating that as well. But when you start working with Geojson features, this type is imposed by geojson spec. So that means more work for my backend team to reshape the items with a friendlier structure, I'd like to avoid that for now. – Eric Burel May 20 '21 at 12:04
  • 3
    It's definitely the nesting that's causing you trouble. Your type guard *does* refine the type of `feature.geometry`, but not of `feature`. – Linda Paiste May 24 '21 at 18:12
  • 2
    Related question: https://stackoverflow.com/questions/62987786/typescript-determine-object-type-from-property-in-objects-property – mihi May 26 '21 at 21:53

2 Answers2

3

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)

mihi
  • 3,097
  • 16
  • 26
  • Haha you end up with the exact same approach that was proposed by one of our teammates. I think I'll accept this answer since it's fairly well documented. So basically, we must go the typeguard way. – Eric Burel May 27 '21 at 07:15
  • 1
    Actually you skipped the generic part of the question, that's what prevented me to easily implement guards. `feature` might be of type `IndivFeature`. If I do the typeguard like this, it tells that it's an IndivFeature but "forgets" that it has geometry Polygon. (for instance if you try to pass `feature` to a function that explicitely expects type `IndivFeature` TypeScript will be unhappy) – Eric Burel May 27 '21 at 07:19
  • Oh, you are right, I totally missed that. I've updated my answer to try to address that. – mihi May 27 '21 at 13:52
  • Awesome! I'll accept that for the moment, it seems to be the best possible approach without touching the underlying structure. I'll probably go for adding a "virtual" field in the graphql API, so we get a clearer type. It's probably also the sign that we lack a few fields to properly discriminates each possibility. – Eric Burel May 27 '21 at 14:36
  • It's not that much powerful compared to an explicit cast however, I wish it was possible to get a typeguard that returns the type including the generic, like "isFeature(foobar): foobar is Feature" where T is inferred within the typeguard. But that's probably unrealistic. – Eric Burel May 27 '21 at 14:38
  • unfortunately, [ms/TS#38839 will not be merged](https://github.com/microsoft/TypeScript/pull/38839#issuecomment-1160929515) – jcalz Aug 13 '22 at 17:03
1

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
    }
iolo
  • 11
  • 1
  • Yep, that's what I am doing right now, but that defeats the purpose of typing. It simply seems that 2 level of nesting is too much for TypeScript. – Eric Burel May 26 '21 at 10:03
  • Upvoting still, because it seems that there aren't much alternatives, a typeguard is just slightly cleaner (because with a cast you need to cast `feature` every time you use it). – Eric Burel May 27 '21 at 14:39