I'm looking for a type-safe way of performing a data transformation of the following type:
// original data type
const nameToDaysAsWWEChamp = {
"Stone Cold Steve Austin": 529,
"The Rock": 378,
"Hulk Hogan": 2188,
"Ric Flair": 118,
} as const;
// target data type
type TargetType =
| { name: "Stone Cold Steve Austin"; daysAsWWEChamp: 529 }
| { name: "The Rock"; daysAsWWEChamp: 378 }
| { name: "Hulk Hogan"; daysAsWWEChamp: 2188 }
| { name: "Ric Flair"; daysAsWWEChamp: 118 };
// attempted transformation
function f(name: keyof typeof nameToDaysAsWWEChamp): TargetType {
return { name, daysAsWWEChamp: nameToDaysAsWWEChamp[name] }; // error!
}
The function f
wants to return an object of the following type:
{
name: "Stone Cold Steve Austin" | "The Rock" | "Hulk Hogan" | "Ric Flair";
daysAsWWEChamp: 529 | 378 | 2188 | 118;
}
How can I convince typescript to "respect the mapping" and narrow the return type of f
to TargetType
?
TypeScript can't abstract over the body of f
to understand that every possible narrowing of name
to a particular member of the union type keyof typeof nameToDaysAsWWEChamp
will result in a valid TargetType
when output as {name, daysAsWWEChamp: nameToDaysAsWWEChamp[name]}
. It doesn't try to analyze it like that at all; instead it just takes the type of name
as the union, and therefore that daysAsWWEChamp
is also a union, and results in the too-wide type you mentioned. It doesn't understand that the union type of name
is correlated with the union type of daysAsWWEChamp
.
This is the subject of microsoft/TypeScript#30581; TypeScript doesn't really support correlated union types. The suggested approach in cases like this is described at microsoft/TypeScript#47109... it involves using generics instead of unions. You have to refactor your types so that operations are seen as generic indexed accesses into mapped types. You can read the issue for a detailed description of this. For your example it looks like this:
// this just makes it easier to refer to the type
type NameToDaysAsWWEChamp = typeof nameToDaysAsWWEChamp;
type TargetType<K extends keyof NameToDaysAsWWEChamp = keyof NameToDaysAsWWEChamp> =
{ [P in K]: { name: P, daysAsWWEChamp: NameToDaysAsWWEChamp[P] } }[K];
Here TargetType
is now a generic distributive object type. If you specify the generic type argument as some particular key of NameToDaysAsWWEChamp
, you get the corresponding member of your original TargetType
union. And if you specify a union, you get the corresponding union out also. If you don't specify it at all, you get the default of the full keyof NameToDaysAsWWEChamp
, and therefore it's the same as your old TargetType
. That means in some sense TargetType
is the same as it was before:
type Same = TargetType;
/* type Same = {
name: "Stone Cold Steve Austin";
daysAsWWEChamp: 529;
} | {
name: "The Rock";
daysAsWWEChamp: 378;
} | {
name: "Hulk Hogan";
daysAsWWEChamp: 2188;
} | {
name: "Ric Flair";
daysAsWWEChamp: 118;
} */
But now it has the ability to be given a restricted type argument. And now, finally, we can write f
as a generic function that takes a name
argument of type K
and returns an output of type TargetType<K>
:
function f<K extends keyof NameToDaysAsWWEChamp>(name: K): TargetType<K> {
return { name, daysAsWWEChamp: nameToDaysAsWWEChamp[name] }; // okay
}
This works because the compiler sees the output as { name: K, daysAsWWEChamp: NameToSaysAsWWEChamp[K] }
, which is the same as the definition TargetType<K>
(at least for K
that is a single literal type; it's a bit more complicated for unions, and the compiler does some technically unsafe things, see microsoft/TypeScript#48730, but that's intentional and the behavior here is what you want).
And as an extra bonus, your f()
function output will be more specific and narrower than just TargetType
, and you can use these narrower types to your advantage if you want:
const hh = f("Hulk Hogan");
// const hh: { name: "Hulk Hogan"; daysAsWWEChamp: 2188; }
const mineral = f(Math.random() < 0.5 ? "The Rock" : "Stone Cold Steve Austin");
// const mineral: TargetType<"Stone Cold Steve Austin" | "The Rock">
if (mineral.daysAsWWEChamp === 378) {
mineral.name;
// ^? (property) name: "The Rock"
} else {
mineral.name;
// ^? (property) name: "Stone Cold Steve Austin"
}
The compiler knows that hh
is a particular TargetType
member, while mineral
is one of a particular pair of members.