typescriptdiscriminated-unionconditional-types

determine object type from property in object's property


I might be asking too much of Typescript, but I was wondering if something like this is possible:

interface ObjectType {
    type: 'this' | 'that';
}

interface SomeObject {
    objType: ObjectType
}

interface ThisObject extends SomeObject {
    objType: { type: 'this' }
    thisProp: 'anything'
}

interface ThatObject extends SomeObject {
    objType: { type: 'that' }; 
    thatProp: 'something'
}

function getProp(obj: ThisObject | ThatObject) {
    switch (obj.objType.type) {
        case 'this':
            return {
                type: obj.objType.type,
                prop: obj.thisProp
            };
        case 'that':
            return {
                type: obj.objType.type,
                prop: obj.thatProp
            };
    }
}

Typescript is able to correctly narrow obj.objType.type, but the value I'm trying to assign to prop in the returned object doesn't typecheck. Errors are the same for both (obviously with different property names):

TS2339: Property 'thatProp' does not exist on type 'ThisObject | ThatObject'. Property 'thatProp' does not exist on type 'ThisObject'.

Is something like this possible? I also tried something like this:

interface SomeObject {
    objType: ObjectType;
    thisProp: SomeObject['objType'] extends 'this' ? 'anything' : never;
    thatProp: SomeObject['objType'] extends 'that' ? 'something' : never;
}

which results in both props being never, as well as something like this:

type PickObject<T> = T extends 'this' ? ThisObject : ThatObject;

function getProp<T extends 'this' | 'that'>(obj: PickObject<T>) {
    switch (obj.objType.type) {
        case 'this':
            return {
                type: obj.objType.type,
                prop: obj.thisProp
            };
        case 'that':
            return {
                type: obj.objType.type,
                prop: obj.thatProp
            };
    }
}

which results in the same error:

TS2339: Property 'thisProp' does not exist on type 'PickObject '.

Solution

  • Assuming your types are actually like this:

    interface ThisObject extends SomeObject { objType: { type: 'this' }; thisProp: 'anything' }
    interface ThatObject extends SomeObject { objType: { type: 'that' }; thatProp: 'something' }
    

    where the type properties are actually nested inside an objType property, then you run into the issue filed at microsoft/TypeScript#18758 where nested discriminated unions are not really supported in TypeScript. You can’t use a discriminated union as a discriminant in another discriminated union; a discriminant must be a union of singleton types like "this" | "that".


    Instead of waiting around for microsoft/TypeScript#18758 to get addressed, you could write your own user-defined type guard function that behaves like a nested discriminated union discriminator by passing a discriminant object and using the Extract utility type to represent the desired narrowing. Something like this:

    function nestedDiscrim<T extends object | PropertyKey, D extends object | PropertyKey>(
      val: T, discriminant: D): val is Extract<T, D>;
    function nestedDiscrim(val: any, discriminant: any) {
      if ((typeof val === "object") && (typeof discriminant === "object")) {
        for (let k in discriminant) {
          if (!(k in val)) return false;
          if (!nestedDiscrim(val[k], discriminant[k])) return false;
        }
        return true;
      }
      if ((typeof val !== "object") && (typeof discriminant !== "object")) {
        return val === discriminant;
      }
      return false;
    }
    

    And you'd use it like this:

    function getProp(obj: ThisObject | ThatObject) {
      if (nestedDiscrim(obj, { objType: { type: "this" } } as const)) {
        return {
          type: obj.objType.type,
          prop: obj.thisProp
        };
      } else {
        return {
          type: obj.objType.type,
          prop: obj.thatProp
        };
      }
    }
    

    You can see that it works:

    console.log(getProp({ objType: { type: "this" }, thisProp: "anything" })); // {type: "this", prop: "anything"};
    console.log(getProp({ objType: { type: "that" }, thatProp: "something" })); // {type: "that", prop: "something"};
    

    The idea is that nestedDiscrim(obj, { objType: { type: "this" } }) walks down through obj, looking at the obj.objType.type and comparing it against "this". If they are the same, then we narrow obj from ThisObject | ThatObject to just ThisObject. Otherwise, we narrow obj from ThisObject | ThatObject to just ThatObject.


    Playground link to code