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 '.
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
.