I am looking for a way to discriminate a union type as a generic parameter and have the same type on multiple fields of an object
What I mean with that:
type A = {
type: 'A',
someA: string
}
type B = {
type: 'B',
someB: string
}
type Union = A | B
type DatabaseUpdate<T extends Union> = {
before: T, // <--
after: T // <-- Those should be the same type, either both A, or both B
}
function test(update: DatabaseUpdate<Union>) {
if (update.after.type === 'A') {
console.log(update.before.someA) // Error
}
}
I tried extracting the value based on the type
because I figured that
type Test = Exclude<Union, { type: 'A' }> // this is B
works fine. But it still does not seem to be able to figure it out.
type $ElementType<T extends {
[P in K & any]: any;
}, K extends keyof T | number> = T[K];
type DatabaseUpdate<
T extends Union,
K = $ElementType<T, 'type'>,
X = Extract<Union, { type: K }>
> = {
before: X,
after: X
}
function test(update: DatabaseUpdate<Union>) {
if (update.after.type === 'A') {
console.log(update.before.someA) // Error
}
}
Ideally I would even be able to have a third field e.g. type
type DatabaseUpdate<
T extends Union,
K = $ElementType<T, 'type'>,
X = Extract<Union, { type: K }>
> = {
type: K,
before: X,
after: X
}
// So that I could do
function test(update: DatabaseUpdate<Union>) {
if (update.type === 'A') {
console.log(update.before.someA)
console.log(update.before.someA)
}
if (update.type === 'B') {
console.log(update.before.someB)
console.log(update.before.someB)
}
}
CodePudding user response:
In TypeScript, discriminated unions are union types with a discriminant property; each member of the union must have a property with the same key name, and in at least some member the discriminant property value must be of a literal type or other "singleton" type (a type inhabited by exactly one value) like null
or undefined
.
If you have a value u
of a discriminated union type with a discriminant key of k
, then checking the value of the discriminant property u.k
will cause the compiler to narrow the apparent type of u
.
Now, your first approach won't work directly because TypeScript doesn't currently support "nested" discriminated unions. You can only use singleton types to discriminate a union; you cannot use other discriminated union types to discriminate a union.
A union type like {before: A, after: A} | {before: B, after: B}
is therefore not a discriminated union. Neither before
nor after
are singleton types; they are discriminated unions. So if you check update.after.type
, this can only serve to narrow the type of update.after
. It does nothing to the apparent type of update
itself.
There is a feature request at microsoft/TypeScript#18758 to support nested discriminated unions, but it's not clear when or whether this will be implemented.
There are ways to write a user-defined type guard function which can give a similar result, but it will not be as simple as writing if (update.after.type === "A")
; it would end up looking something like like if (nestedDiscriminate(update, ["after", "type"], "A"))
. I won't go into this because there is another way to proceed here.
If you define DatabaseUpdate<Union>
so that the resulting type is a union with a discriminant property, then it will be a discriminated union. So adding a type
field is a good approach.
Unfortunately, you ran into trouble, because your DatabaseUpdate<Union>
evaluates to this:
type DUU = DatabaseUpdate<Union>;
/* type DUU = {
type: "A" | "B";
before: A | B;
after: A | B;
}
*/
which is not a union at all, and therefore not a discriminated union. Each property is a union, but an object-of-unions is not the same as a union-of-objects. You can see that the difference, because your type allows assignments like this:
const oops: DUU = {
type: "A",
before: { type: "A", someA: "okay" },
after: { type: "B", someB: "uh oh" }
}
That's valid; the type
property is "A"
, which is a valid "A" | "B"
, and the after
property is a B
, which is a valid A | B
. Each property of this object type is an independent union type; knowing the value of the type
property implies nothing about the before
or after
properties.
We want DatabaseUpdate<Union>
to itself be a union. One way to do this is to make DatabaseUpdate<T>
a distributive conditional type, which means DatabaseUpdate<A | B>
will evaluate as DatabaseUpdate<A> | DatabaseUpdate<B>
(we are distributing the DatabaseUpdate<T>
type across unions in T
):
type DatabaseUpdate<T> = T extends Union ? {
type: T['type']
before: T,
after: T
} : never;
The T extends Union ? ... : never
construction causes T
to be split up into its union constituents before evaluating the type, and then the output is unioned back together.
(I also use simplified DatabaseUpdate
to use a direct indexed access type T['type']
instead of the ultimately equivalent $ElementType<T, 'type'>
).
Now let's evaluate DatabaseUpdate<Union>
:
type DUU = DatabaseUpdate<Union>
/* type DUU = {
type: "A";
before: A;
after: A;
} | {
type: "B";
before: B;
after: B;
} */
Now this is a union type, and type
is a discriminant property. Suddenly everything works:
function test(update: DatabaseUpdate<Union>) {
if (update.type === 'A') {
console.log(update.before.someA) // okay
console.log(update.after.someA) // okay
} else {
console.log(update.before.someB) // okay
console.log(update.after.someB) // okay
}
}