typescripttypesmapped-typestypesafe

Type like `Record<string, keyof T>` that guarantees each key in `keyof T` is used?


I make a simple example just to show a problem. There it is

class Person{
    public Name: string = '';
    public Age: number = 0;
}

class Some {
    public StringToKey: Record<string, keyof Person> = {
        "N": "Name",
        "A": "Age"
    }
    public KeyToVal: {[Key in keyof Person]: (value:string)=>Person[Key]} = {
        Name: (value: string) => {return value;},
        Age: (value: string) => {return Number(value);}
    }
}

All looks good and compile. If i add new property to Person, i want to get 2 errors: for StringToKey and KeyToVal, but i only have error in KeyToVal situation.

So, for StringToKey declaration i can't be sure that i will get error if something will be changed in Person class, which is bad for me.

Is there any magic in type declaration i could do to get error in StringToKey declaration when Person class has been changed?


Solution

  • TypeScript really doesn't have a way to represent an "exhaustive" type that ensures that each member of some union is known to be present as a property value. So no specific type like type ExhaustivePersonKeyRecord = ExhaustiveRecord<string, keyof T> exists, since there's no way to implement ExhaustiveRecord directly.


    Instead the best we can do is write a generic type ExhaustivePersonKeyRecord<T> that acts as a constraint on T so that T extends ExhaustivePersonKeyRecord<T> if and only if T has a property value for every key of Person.

    Once we have that, we'd write a generic helper identity function that uses such a type, like this:

    function exhaustivePersonKeyRecord<T extends Record<keyof T, keyof Person>>(
      t: ExhaustivePersonKeyRecord<T>
    ) { return t }
    

    That will make it so that exhaustivePersonKeyRecord({a: "Name", b: "Age"}) will succeed but exhaustivePersonKeyRecord({a: "Name"}) will fail.


    All we have to do is define an appropriate ExhaustivePersonKeyRecord<T> type. Here's one possible way to do it:

    declare const __some_property__: unique symbol;
    type ExhaustivePersonKeyRecord<T> = T & (keyof Person extends T[keyof T] ? unknown :
      { [__some_property__]: Exclude<keyof Person, T[keyof T]> }
    )
    

    That's T intersected with a conditional type that compares keyof Person to T[keyof T]. The type T[keyof T] is the union of all property types included in T (as discussed in Is there a `valueof` similar to `keyof` in TypeScript? ). If keyof Person extends T[keyof T] it means that T[keyof T] is wider than keyof Person and thus contains every member. That's what you want.

    If that check is true and you used all the keys of Person in the properties of T, then the conditional type evaluates to the unknown type and T & unknown is just T. And T extends T, so it will succeed.

    If that check is false, and you missed some key or keys of Person, then the conditional type evaluates to { [__some_property__]: Exclude<keyof Person, T[keyof T]> }. This is an object type with the key of type __some_property__ (a fake symbol type I declared just to have some "key" to say we missed) and a value of type Exclude<keyof Person, T[keyof T]>, using the Exclude utility type to determine the missing keys. If T is, say, {a: "Name"}, then Exclude<keyof Person, T[keyof T]> will be "Age", and the whole type will evaluate to {a: "Name"} & {[__some_property__]: "Age"}, and so T extends ExhaustivePersonKeyRecord<T> will be false, and it will fail.


    Let's test it out:

    class Some {
      public StringToKey = exhaustivePersonKeyRecord({
        "N": "Name",
        "A": "Age",
      }); // okay
    }
    

    That works until we augment Person:

    class Person {
      public Name: string = '';
      public Age: number = 0;
      public Height: number = 0; // <-- do this
    }
    

    Then we get the desired error:

    class Some {
      public StringToKey = exhaustivePersonKeyRecord({
        "N": "Name",
        "A": "Age"
      }); // error
    }
    //  Property '[__some_property__]' is missing in type '{ N: "Name"; A: "Age"; }'
    // but required in type '{ [__some_property__]: "Height"; }'.
    

    which hopefully gives us enough information to fix it:

    class Some {
      public StringToKey = exhaustivePersonKeyRecord({
        "N": "Name",
        "A": "Age",
        "H": "Height"
      }); // okay
    }
    

    Looks good!

    Playground link to code