Search code examples
typescripttype-conversionmapped-typesdata-transform

Typescript: Type-safe way of transforming data from an object type to union type


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?


Solution

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

    Playground link to code