Search code examples
typescriptmappingkeyof

TS: object type where every possible value must be taken


Given

const domain: string[] = ["A", "B", "C"];

how can I declare an object type with keys from domain and values covering whole domain?

Some examples for clarification:

const obj1 = {    //     correct type: values { "B", "C", "A" } of obj1 are all the values of domain
    A: "B",
    B: "C",
    C: "A",
};
const obj2 = {    // NOT correct type: values { "B", "C" } of obj2 are missing "A" from domain
    A: "B",
    B: "C",
    C: "B",
};

In other words, my object needs to be an onto/surjective map.


Solution

  • There is no specific type that is both scalable and meets your requirements. For the example as given, you could generate a union of all the acceptable permutations, which would be equivalent to:

    type Acceptable =
        { A: "A", B: "B", C: "C" } |
        { A: "A", B: "C", C: "B" } |
        { A: "B", B: "A", C: "C" } |
        { A: "B", B: "C", C: "A" } |
        { A: "C", B: "A", C: "B" } |
        { A: "C", B: "B", C: "A" };
    
    const obj1: Acceptable = { // okay
        A: "B",
        B: "C",
        C: "A",
    }; 
    
    const obj2: Acceptable = { // error!
        A: "B",
        B: "C",
        C: "B",
    }; 
    

    That's a specific type and it will work for your example. We could write a type utility that generates that automatically but before we bother, let's consider the scalability.

    For 𝑛 elements in your domain, that union would have 𝑛! (𝑛 factorial) members. Unions in TypeScript can only hold about 100,000 members at most, meaning you can't have more than eight elements in your domain. The compiler starts slowing down if you are calculating large unions anyway, so even with six or seven elements you're likely to be unhappy.

    So let's forget this, as it doesn't scale.


    Without a specific type, all we can do is write a generic constraint. Instead of Acceptable, we can write a type function Acceptable<T> such that T extends Acceptable<T> if and only if T would be acceptable. Then we can write a generic helper function like acceptable() that returns its input, but produces an error if its input isn't acceptable. So instead of const obj1: Acceptable = {...} you'd write const obj1 = acceptable({...}) to much the same effect.

    Here's one approach to writing it:

    type NoDuplicates<T> =
        { [K in keyof T]: Exclude<T[K], T[Exclude<keyof T, K>]> }
    
    const noDuplicates = <D extends PropertyKey,>(d: readonly D[]) =>
        <T extends Record<D, D> & NoDuplicates<T>>(x: T) => x;
    

    My type function is actually NoDuplicates<T>, which will take an object type T and map it to a type where any property values that overlap with other properties are removed. It uses the Exclude<T, U> utility type twice; once to say "remove this key and look up the properties from every other key", and "remove any pieces of this property that in those other places". So NoDuplicates<{x: 1, y: 2, z: 3}> is just {x: 1, y: 2, z: 3}, but NoDuplicates<{x: 1, y: 2, z: 1}> is {x: never, y: 2, z: never}.

    Since the key set and the value set are the same size, then eliminating duplicates is a similar operation to guaranteeing a surjection. (There are edge cases, I'm sure; this is meant to give a flavor of how such constraints can be built... it can be tweaked or even completely refactored if use cases warrant it, but I won't digress about that further.)

    Then noDuplicates() is a curried function that accepts a domain as an array of key-like elements. This is actually completely ignored at runtime, but the compiler uses it to generate the acceptable() function:

    const domain = ["A", "B", "C"] as const;
    const acceptable = noDuplicates(domain);
    /* const acceptable: <T extends 
         Record<"A" | "B" | "C", "A" | "B" | "C"> & NoDuplicates<T>
    >(x: T) => T */
    

    Okay, let's try it:

    const obj1 = acceptable({
        A: "B",
        B: "C",
        C: "A",
    }); // okay
    /* const obj1: { A: "B"; B: "C"; C: "A"; } */
    
    const obj2 = acceptable({
        A: "B", // error!
        B: "C",
        C: "B", // error!
    });
    /* const obj2: { A: never; B: "C"; C: never; }*/
    
    const obj3 = acceptable({
        A: "B",
        B: "A",
    }) // error! Property C is missing
    

    The compiler is happy about obj1 but complains about obj2 (duplicate values) and obj3 (missing keys).

    Playground link to code