Search code examples
typescript

SortKey<> type doesn't work, despite individual types used by it work just fine


My previous question about TypeScript is what started the topic on this one. In that previous question, I wanted to create a generic sort() method that would work with an array of sort keys, defined by the SortKey type you'll see there (and here, I suppose). The result obtained in said previous question works only for direct properties of the type.

Now I wanted to progress this, and see if I could make it recursive so I could specify keys on nested objects. This is the work I have:

type Comparer<T> = (a: T, b: T) => number;

type KeyPath<T, TParentKey = undefined> = {
    [K in keyof T]: K extends string ? (TParentKey extends undefined ? `${K}` : `${TParentKey & string}.${K}`) : never;
}[keyof T]
    | {
    [K in keyof T]: T[K] extends object ? K extends string ? KeyPath<T[K], TParentKey extends undefined ? `${K}` : `${TParentKey & string}.${K}`> : never : never;
}[keyof T]
;

type ValueAtPath<T, Path> = Path extends keyof T
    ? T[Path]
    : Path extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
        ? ValueAtPath<T[Key], Rest>
        : never
    : never;

type Getter<T, K extends string> = (model: T, key: K) => ValueAtPath<T,K>;

// This type is not working and is the subject for this question:
type SortKey<TModel> = {
    [K in KeyPath<TModel>]: {
        key: K;
        order: 'asc' | 'desc';
        getter: Getter<TModel, K>;
        comparer: Comparer<ValueAtPath<TModel, K>>;
    }
}[KeyPath<TModel>];

Now, how come I know the "individual types" used inside SortKey work? Like this:

// Let's define a couple of data models.
type User = {
    id: number;
    name: {
        first: string;
        last: string;
    };
    emails?: string[];
    isActive: boolean;
    type: 'customer' | 'support'
};

type Domain = {
    id: number;
    value: string;
    user: User;
};

// Now let's start testing the TS types.
type TestKey = KeyPath<Domain>;

// Visual Studio Code shows in Intellisense the following for the above type:
type TestKey = "id" | "value" | "user" | KeyPath<User, "user">

// Although unsure why it did not expand the recursion, Intellisense fully works on a variable:
const k: TestKey = 'user.name'; // <-- See picture 1.

// This also seems OK as per the Intellisense tooltip:
type TestValue = ValueAtPath<Domain, 'user.name.last'>;
// The tooltip reads it is string, which is the data  type of the `last` nested property:
type TestValue = string

// Now testing Comparer:
const comp: Comparer<ValueAtPath<Domain, 'user.name'>> = (a, b) => 0;
// Intellisense shows:
const comp: Comparer<{
    first: string;
    last: string;
}>

// Finally, testing Getter:
const getter: Getter<Domain, 'user.name.first'> = m => m.user.name.first;
// Intellisense works perfectly on the parameter variable "m".

Picture 1:  100% of all keys in Domain

Ok, so one would think that, by extension, type DomainSortKey = SortKey<Domain> would work. Well, it doesn't. TypeScript says that DomainSortKey === unknown.

The question of the million dollars is: What in the Jebus happened??


Solution

  • Whenever you have a mapped type of the form {[K in keyof T]: ⋯}, with in keyof in there directly, it will be a homomorphic mapped type as described in What does "homomorphic mapped type" mean?. That means readonly and optional properties in the input type will become readonly and optional properties in the output type. Optional properties will always include undefined in their domain.

    So that means your KeyPath<T> type will end up including undefined if any of the keys of T (or perhaps even subkeys, given the recursion) are optional. You can test the type to see this (it looks like you tried to do this but didn't fully expand the type. One way to do this is to intersect with {} | null | undefined, a type more or less equivalent to unknown):

    type TestKey = KeyPath<Domain> & ({} | null | undefined);
    //   ^? type TestKey = "id" | "value" | "user" | "user.id" | "user.name" |
    //      "user.emails" | "user.isActive" | "user.type" | "user.name.first" | 
    //      "user.name.last" | undefined
    

    Now, ideally, TypeScript would be smart enough to prevent you from using undefined as a key in an indexed access type. But sometimes if generics are complicated enough, the compiler fails to see the problem:

    type Hmm<T> = { [K in keyof T]: K }[keyof T]
    type Grr<T> = T[Hmm<T>]; // <-- this should be an error but it's not
    type Okay = Grr<{a: string}> // string
    type Bad = Grr<{ a?: string }> // unknown
    

    The unknown is what happens when the compiler is trying to figure out {a?: string}[undefined]. Allowing undefined to sneak in as a key is a known design limitation, according to this comment on microsoft/TypeScript#56515, because trying to fix it was causing way too much breakage in real world code. So they allow it, and recommend that if you're writing a type function to extract keys, you should perform the following fix:


    The recommended fix here is to use the -? mapping modifier to make keys required in the mapping and to prevent undefined from being included in the domain. (The -? modifier is used in the definition of the Required<T> utility type):

    type KeyPath<T, TParentKey = undefined> = {
      [K in keyof T]-?: K extends string ? (
        //          ^^
        TParentKey extends undefined ? `${K}` : `${TParentKey & string}.${K}`
      ) : never; }[keyof T]
      | {
        [K in keyof T]-?: T[K] extends object ? (
          //          ^^
          K extends string ? KeyPath<
            T[K], TParentKey extends undefined ? `${K}` : `${TParentKey & string}.${K}`
          > : never
        ) : never;
      }[keyof T];
    

    which removes undefined from the list of keys:

    type TestKey = KeyPath<Domain> & ({} | null | undefined);
    //   ^? type TestKey = "id" | "value" | "user" | "user.id" | "user.name" |
    //      "user.emails" | "user.isActive" | "user.type" | "user.name.first" | 
    //      "user.name.last" 
    

    which unbreaks SortKey:

    type DomainSortKey = SortKey<Domain>;
    /* type DomainSortKey = {
        key: "id";
        order: 'asc' | 'desc';
        getter: Getter<Domain, "id">;
        comparer: Comparer<number>;
    } | {
        key: "value";
        order: 'asc' | 'desc';
        getter: Getter<Domain, "value">;
        comparer: Comparer<...>;
    } | ... 7 more ... | {
        ...;
    } */
    

    Playground link to code