I have a type like this:
enum Type {
A = 'A',
B = 'B',
C = 'C'
}
type Union =
| {
type: Type.A | Type.B;
key1: string
}
| {
type: Type.C;
key2: string
}
type EnumToUnionMap = {
[T in Type]: {
[k in keyof Extract<Union, {type: T}>]: string
}
}
The issue I'm having is that typeof EnumToUnionMap[Type.A]
is never
(in reality, it's a generic key signature like [x: string]: string
but that's because Extract<Union, {type: T}>
returns type never
when T
is Type.A
or Type.B
) while typeof EnumToUnionMap[Type.C]
is
{
type: Type.C,
key2: string
}
as expected.
This all makes sense because the type
in EnumToUnionMap[Type.A]
is Type.A | Type.B
and Type.A != (Type.A | Type.B)
so they don't match and we get never
.
Essentially what I need to do something like this:
type EnumToUnionMap = {
[T in Type]: {
[k in keyof Extract<Union, T in Union['type']>]: string
}
}
Why I need to do this:
I receive a response from an alerts endpoint that has this shape:
{
type: Type,
key1: string,
key2: string
}
Both alerts of Type.A
and Type.B
provide key1
while those of Type.C
provide key2
.
I need to map the keys in the response to column names in a grid (where some alert types share a common set of keys but have different display names for the columns):
const columnMap: EnumToUnionMap = {
[Type.A]: {
key1: 'Column name'
// Note that in actuality this object will contain
// multiple keys (i.e. multiple columns) for a
// given `Type` so creating a map
// between `Type -> column name` is not possible.
},
[Type.B]: {
key1: 'Different column name'
},
[Type.C]: {
key2: 'Another column name'
}
}
This way, I can do something like the following:
const toColumnText = (alert) => columnMap[alert.type]
...
if (alert.type === Type.A) {
const key1ColumnName = toColumnText(alert).key1 // typed as string
const key2ColumnName = toColumnText(alert).key2 // Typescript warns of undefined key
}
As you note, you can't really use the Extract
utility type here, because the relationship between the members of the union Union
and the candidate types {type: T}
that you have is not simple assignability. Instead, you want to find the member U
of the union Union
such that T extends U["type"]
. You'll probably have to forget about the utility types that TypeScript provides and instead perform the type manipulation yourself.
One possible definition for EnumToUnionMap
is this:
type EnumToUnionMap = { [T in Type]: Union extends infer U ? U extends { type: any } ? (
T extends U["type"] ? { [K in keyof U as Exclude<K, "type">]: U[K] } : never
) : never : never }
It might look a bit daunting; let's make sure it at least does what you want. IntelliSense shows that it evaluates to:
/* type EnumToUnionMap = {
A: { key1: string; };
B: { key1: string; };
C: { key2: string; };
} */
Looks good.
Now that we know it does what you want, how does it do it? Let's break the definition into pieces and analyze each piece:
type EnumToUnionMap = { [T in Type]: Union extends infer U ? U extends { type: any } ? ( T extends U["type"] ? { [K in keyof U as Exclude]: U[K] } : never ) : never : never }
As in your version, we are mapping over the enum values, Type.A
, Type.B
, and Type.C
. For each such enum value T
, we need to split Union
into its union members, and collect the one we care about. Writing Union extends infer U ? U extends { type: any } ? ...
uses conditional type inference to copy Union
into a new type parameter U
, which can then be used to create a distributive conditional type where U
represents individual union members of Union
. So from here on, whenever we see U
, we are dealing with just pieces of Union
, not the whole thing.
type EnumToUnionMap = { [T in Type]: Union extends infer U ? U extends { type: any } ? ( T extends U["type"] ? { [K in keyof U as Exclude]: U[K] } : never ) : never : never }
To determine which union member U
of the union Union
to select, we evaluate the conditional type T extends U["type"] ? ... : never
. The check T extends U["type"]
is true if and only if U
's type
property is a supertype of T
. If T
is Type.A
and U
is {type: Type.A | Type.B, ...}
, then this is true. But if T
is Type.A
and U
is {type: Type.C, ...}
, then this is false. By returning never
when the check is false, we are only going to process the "right" member of Union
.
type EnumToUnionMap = { [T in Type]: Union extends infer U ? U extends { type: any } ? ( T extends U["type"] ? { [K in keyof U as Exclude]: U[K] } : never ) : never : never }
So we've found the right union member U
, but we don't want to just return it, because it still contains the undesirable type
property. We could return Omit<U, "type">
using the Omit
utility type, but this unfortunately results in verbose IntelliSense like Omit<{type: Type.A | Type.B; key1: string}, "type">
instead of the desirable {key1: string}
. So here I'm writing my own Omit
type using key remapping in mapped types (the documentation explains how this alternative to Omit
works).
There you go; the EnumToUnionMap
type walks through each member of Type
, extracts the member of Union
whose type
property is a supertype of it, and omits the type
property from that member.
Playground link to code
TypeScript has the natural ability to narrow types using &
so I wanted to give a solution a shot that doesn't need too much trickery. Below is the full code to achieve your expected result. Note the typing of the toColumnText
function as well, as that's also part of the solution.
enum Type {
A = 'A',
B = 'B',
C = 'C'
}
type Union =
| {
type: Type.A | Type.B;
key1: string;
}
| {
type: Type.C;
key2: string
}
type FindByType<TWhere, T extends Type> = TWhere extends { type: infer InferredT }
? (InferredT extends T ? (TWhere & { type: T }) : never)
: never;
type EnumToUnionMap = {
[T in Type]: {
// Change `FindByType<Union, T>[k]` to `string` to force all extracted properties to be strings intead of their original type
[k in Exclude<keyof FindByType<Union, T>, 'type'>]: FindByType<Union, T>[k];
}
};
const columnMap: EnumToUnionMap = {
[Type.A]: { key1: 'Column name' },
[Type.B]: { key1: 'Different column name' },
[Type.C]: { key2: 'Another column name' }
}
const toColumnText = <T extends Type>(alert: { type: T }): EnumToUnionMap[T] => columnMap[alert.type];
const alertObj: Union = { type: Type.A, key1: 'test' };
if (alertObj.type === Type.A) {
const key1ColumnName = toColumnText(alertObj).key1 // typed as string
const key2ColumnName = toColumnText(alertObj).key2 // Typescript warns of undefined key
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With