Search code examples
typescripttypescript-genericsconditional-typesnested-generics

How exactly do recursive conditional types work?


  export type Parser = NumberParser | StringParser;

  type NumberParser = (input: string) => number | DiplomacyError;
  type StringParser = (input: string) => string | DiplomacyError;

  export interface Schema {
    [key: string]: Parser | Schema;
  }

  export type RawType<T extends Schema> = {
    [Property in keyof T]: T[Property] extends Schema
      ? RawType<T[Property]>
      : ReturnType<Exclude<T[Property], Schema>>;
  };


  // PersonSchema is compliant the Schema interface, as well as the address property
  const PersonSchema = {
    age: DT.Integer(DT.isNonNegative),
    address: {
      street: DT.String(),
    },
  };

  type Person = DT.RawType<typeof PersonSchema>;

Sadly type Person is inferred as:

type Person = {
    age: number | DT.DiplomacyError;
    address: DT.RawType<{
        street: StringParser;
    }>;
}

Instead I would have liked to get:

type Person = {
    age: number | DT.DiplomacyError;
    address: {
        street: string | DT.DiplomacyError;
    };
}

What am I missing?


Solution

  • The difference between the Person displayed and the type you expected is pretty much just cosmetic. The compiler has a set of heuristic rules it follows when evaluating and displaying types. These rules have changed over time and are occasionally tweaked, such as the "smarter type alias preservation" support introduced in TypeScript 4.2.

    One way to see that the types are more or less equivalent is to create both of them:

    type Person = RawType<PersonSchema>;
    /*type Person = {
        age: number | DiplomacyError;
        address: RawType<{
            street: StringParser;
        }>;
    }*/
    
    type DesiredPerson = {
        age: number | DiplomacyError;
        address: {
            street: string | DiplomacyError;
        };
    }
    

    And then see that the compiler considers them mutually assignable:

    declare let p: Person;
    let d: DesiredPerson = p; // okay
    p = d; // okay
    

    The fact that those lines did not result in a warning means that, according to the compiler, any value of type Person is also a value of type DesiredPerson, and vice versa.

    So maybe that's enough for you.


    If you really care about how the type is represented, you can use techniques such as described in this answer:

    // expands object types recursively
    type ExpandRecursively<T> = T extends object
        ? T extends infer O ? { [K in keyof O]: ExpandRecursively<O[K]> } : never
        : T;
    

    If I compute ExpandRecursively<Person>, it walks down through Person and explicitly writes out each of the properties. Assuming that DiplomacyError is this (for want of a minimal reproducible example in the question):

    interface DiplomacyError {
        whatIsADiplomacyError: string;
    }
    

    Then ExpandRecurively<Person> is:

    type ExpandedPerson = ExpandRecursively<Person>;
    /* type ExpandedPerson = {
        age: number | {
            whatIsADiplomacyError: string;
        };
        address: {
            street: string | {
                whatIsADiplomacyError: string;
            };
        };
    } */
    

    which is closer to what you want. In fact, you could rewrite RawType to use this technique, like:

    type ExpandedRawType<T extends Schema> = T extends infer O ? {
        [K in keyof O]: O[K] extends Schema
        ? ExpandedRawType<O[K]>
        : O[K] extends (...args: any) => infer R ? R : never;
    } : never;
    
    type Person = ExpandedRawType<PersonSchema>
    /* type Person = {
        age: number | DiplomacyError;
        address: {
            street: string | DiplomacyError;
        };
    } */
    

    which is exactly the form you wanted.

    (Side note: there is a naming convention for type parameters, as mentioned in this answer. Single capital letters are preferred over whole words, so as to distinguish them from specific types. Hence, I have replaced Property in your examples with K for "key". It might seem paradoxical, but because of this convention, K is more likely to be immediately understood by TypeScript developers to be a generic property key than Property is. You are, of course, free to continue using Property or anything else you like; it's just a convention, after all, and not some sort of commandment. But I just wanted to point out that the convention exists.)

    Playground link to code