Reputation: 109
A tricky TS challenge.
Intersecting multiple discriminated union types together where there is a "type"-"sub-type" relationship.
Suppose one uses discriminated unions to have type-dependent properties (the allowed properties of an object depend on the value of one of the properties of the object). We will declare such a type, A
:
enum AType {
FOO,
BAR
}
type ATypeToPropsMap = {
[AType.FOO]: { foo: string }
[AType.BAR]: { bar: string }
}
type AUnion = {
[K in AType]: { type: K } & ATypeToPropsMap[K]
}[AType]
type A<TAType extends AType = AType> = AUnion & { type: TAType }
In this case, A
can only have type
= FOO
and the property foo
, or type
= BAR
and the property bar
. One can use this A
type like so:
const a1: AType = {
type: AType.FOO,
foo: "123" // <-- Allowed
}
const a2: AType = {
type: AType.FOO,
bar: "123" // <-- Not allowed,
}
const a3: AType = {
type: AType.BAR,
bar: "123" // <-- Allowed,
}
It's helpful to create a "helper" type that generalizes this discriminated union behavior:
type TypeDependantBase<
TType extends string|number,
TMap extends { [k in TType]: any },
TTypePropertyName extends string,
> = {
[K in TType]: { [k in TTypePropertyName]: K } & TMap[K]
}[TType] & { [k in TTypePropertyName]: TType }
This means that we can now succinctly define A like so:
type A<TAType extends AType = AType> = TypeDependantBase<TAType , {
[AType.FOO]: { foo: string }
[AType.BAR]: { bar: string }
}, "type">
We will create two new types, AFoo
and ABar
, that are also discriminated unions. Each of them are discriminated by their own new "sub-types", AFooSubType
and ABarSubType
respectively, each of which, as implied, pertain to each AType
value:
enum AFooSubType {
WIZZ,
BANG
}
enum ABarSubType {
CRASH,
WOOSH
}
type AFoo<TAFooSubType extends AFooSubType = AFooSubType> = TypeDependantBase<TAFooSubType, {
[AFooSubType.WIZZ]: { wizz: string }
[AFooSubType.BANG]: { bang: string }
}, "subType">
type ABar<TABarSubType extends ABarSubType = ABarSubType> = TypeDependantBase<TABarSubType, {
[ABarSubType.CRASH]: { crash: string }
[ABarSubType.WOOSH]: { woosh: string }
}, "subType">
With A
, AFoo
, and ABar
defined, one can then specify a type that maps AType
to either AFoo or ABar
type ATypeToSubTypeMap = {
[AType.FOO]: AFoo
[AType.BAR]: ABar
}
And then, at last, one can create an intersection of A
and AFoo
/ABar
like so:
type AWithSubType<TAType extends AType = AType> = A<TAType> & ATypeToSubTypeMap[TAType]
Now, the goal with this type, is to be able to define an object with the following type constraints:
const a: AWithSubType = {
type: AType.FOO,
foo: "123", // <-- This is still forced to be "foo" just like before, because type = FOO
subType: AFooSubType.WIZZ, // <-- This is forced to be AFooSubType because type = FOO
wizz: "456" // <-- This is forced to be "wizz" and not "bang" because of the definition of AFoo
}
Unfortunately, and being the impetus for this question, is that this doesn't work! It doesn't force the subType
property to be the correct type.
The ultimate question, is how does one do that? Thanks!
As @jcalz showed, one can define all the type-sub-type mapping and props in one monolithic object to get the correct props enforcement:
type ADataMapping = {
type: {
[AType.FOO]: {
foo: string,
subType: {
[AFooSubType.BANG]: { bang: string },
[AFooSubType.WIZZ]: { wizz: string }
}
},
[AType.BAR]: {
bar: string,
subType: {
[ABarSubType.CRASH]: { crash: string },
[ABarSubType.WOOSH]: { woosh: string }
}
}
}
}
type ToDiscrimUnion<T, K extends keyof T> = T extends unknown ? {
[P in keyof T[K]]: (
Omit<T, K> & Record<K, P> & T[K][P]
) extends infer O ? { [Q in keyof O]: O[Q] } : never }[keyof T[K]
] : never
type AWithSubType = ToDiscrimUnion<ToDiscrimUnion<ADataMapping, "type">, "subType">
This answers the question, however it does come with the limitation of AWithSubType
not having a generic which means one cannot explicitly specify the AType
of an AWithSubType
, i.e. AWithSubType<TAType>
. This means one cannot do things like object spreading, for example:
const aTemplate = { type: AType.FOO, foo: "123" }
const a = { ...aTemplate, foo: "not 123" } // <-- Fails as `aTemplate` could be anything, so "foo" is not recognised.
However this is out of scope, of course.
Upvotes: 4
Views: 656
Reputation: 329773
The main problem with your original type
type AWithSubType<TAType extends AType = AType> = A<TAType> & ATypeToSubTypeMap[TAType]
is that it does not distribute across unions in TAType
. Presumably you want AWithSubType<AType.FOO | AType.BAR>
to be equivalent to AWithSubType<AType.FOO> | AWithSubType<AType.BAR>
. But it isn't. Intersecting A<AType>
with ATypeToSubTypeMap[AType]
is going to give you "cross-terms" in your union which allow things you don't want:
const bad: AWithSubType = {
type: AType.FOO,
foo: "123",
subType: ABarSubType.CRASH,
crash: "456"
} // no error
The easiest way to fix this is to explicitly turn it into a distributive conditional type where TAType
is the checked type:
type AWithSubType<TAType extends AType = AType> =
TAType extends unknown ? (
A<TAType> & ATypeToSubTypeMap[TAType]
) : never
This might look like a no-op: after all, TAType
always extends the unknown
type. But it serves the purpose of splitting TAType
apart into union members before evaluating the intersection, and then putting the results of those into a new union. And that fixes the problem:
const a: AWithSubType = {
type: AType.FOO,
foo: "123",
subType: AFooSubType.WIZZ,
wizz: "456"
} // okay
const bad: AWithSubType = {
type: AType.FOO,
foo: "123",
subType: ABarSubType.CRASH,
crash: "456"
} // error!
As mentioned though, this entire approach is complex and produces fairly hard to understand types. The type AWithSubType
here evaluates to:
type AWithSubTypeEquivalent = (
{ type: AType.FOO; } & { foo: string; } &
{ type: AType.FOO; } & AFoo<AFooSubType>
) | (
{ type: AType.BAR; } & { bar: string; } &
{ type: AType.BAR; } & ABar<ABarSubType>
)
which is redundant (multiple copies of the type
property in there) as well as confusing.
Personally, in this situation, I'd back way up and try to express everything as a single data structure which could then be programmatically transformed into the right sort of discriminated union. Something like this:
type ADataMapping = {
type: {
[AType.FOO]: {
foo: string,
subType: {
[AFooSubType.BANG]: { bang: string },
[AFooSubType.WIZZ]: { wizz: string }
}
},
[AType.BAR]: {
bar: string,
subType: {
[ABarSubType.CRASH]: { crash: string },
[ABarSubType.WOOSH]: { woosh: string }
}
}
}
}
And then we can automate the idea of "given an object type where one of the properties is itself a mapping from discriminant keys to value types, produce a discriminated union where this property name points to a single key, and the value type for that key is lifted up on level". Maybe that's weird, but here is how it could implemented:
type ToDiscrimUnion<T, K extends keyof T> = T extends unknown ? {
[P in keyof T[K]]: (
Omit<T, K> & Record<K, P> & T[K][P]
) extends infer O ? { [Q in keyof O]: O[Q] } : never }[keyof T[K]
] : never
And you can see it in action:
type FirstStep = ToDiscrimUnion<ADataMapping, "type">;
/*
type FirstStep = {
type: AType.FOO;
foo: string;
subType: {
[AFooSubType.BANG]: { bang: string; };
[AFooSubType.WIZZ]: { wizz: string; };
};
} | {
type: AType.BAR;
bar: string;
subType: {
[ABarSubType.CRASH]: { crash: string; };
[ABarSubType.WOOSH]: { woosh: string; };
};
}
*/
That makes a single discriminated union on the type
discriminant, which can now be discriminated further on subType
:
type AWithSubType = ToDiscrimUnion<FirstStep, "subType">
/* type AWithSubType = {
type: AType.FOO;
foo: string;
subType: AFooSubType.WIZZ;
wizz: string;
} | {
type: AType.FOO;
foo: string;
subType: AFooSubType.BANG;
bang: string;
} | {
type: AType.BAR;
bar: string;
subType: ABarSubType.CRASH;
crash: string;
} | {
type: AType.BAR;
bar: string;
subType: ABarSubType.WOOSH;
woosh: string;
} */
That's exactly the same type except it's fairly obvious from looking at it what the types mean. If you want to make it generic there are undoubtedly ways of doing so, such as just performing an Extract
:
type AWithSubTypeGen<T extends AType> =
Extract<AWithSubType, { type: T }>;
type AFoo = AWithSubTypeGen<AType.FOO>;
/*
type AFoo = {
type: AType.FOO;
foo: string;
subType: AFooSubType.WIZZ;
wizz: string;
} | {
type: AType.FOO;
foo: string;
subType: AFooSubType.BANG;
bang: string;
}
*/
Most of this is drifting pretty far from the scope of the question as asked, though, so I should stop now.
Upvotes: 2