Search code examples
typescripttypesrecursive-type

Is there a TypeScript type expression to recursively require fields on an object?


I have a type like

type WeakGuaranteeType = {
  a?: string,
  b?: string,
  c?: string,
  d?: string,
}

But, I want to make a stronger guarantee. I know that a will always exist when b exists, and b will always exist when c exists, etc. So:

{ a: "" }               // valid
{ a: "", b: "" }        // valid
{ a: "", b: "", c: "" } // valid
{ c: "" }               // not valid!!

Is there a way to describe this in TypeScript?

My first attempt was to do:

type BetterType = 
  | {}
  | ({a: string} & (
      | {}
      | ({b: string} & (
          | {}
          | ({c: string} & (
              // etc...
            ))
        ))
    ))

This is bad because:

  1. It is very verbose and has to be written out by hand, and
  2. It produced an "Expression produces a union type that is too complex to represent" error.

I think it would be better to use a recursive approach.

type RecursiveRequire<Keys extends string[]>
  = Keys extends []  // Check if Keys is empty
    ? {}             // Base case. If Keys is empty, "return" {}
    : (              // Recursive case. Add the first key and recurse on the rest
        { [Key in Keys[0]]: string } &
        RecursiveRequire</* What do I put here to say "all of Keys except the first"? */>
      );

type BestType = RecursiveRequire<["a", "b", "c", "d"]>;

Solution

  • You essentially want a type that looks like this:

    type BestType = {
        a: string;
        b: string;
        c: string;
        d: string;
    } | {
        a: string;
        b: string;
        c: string;
    } | {
        a: string;
        b: string;
    } | {
        a: string;
    }
    

    A union consisting of all valid object types.

    We can build this union recursively.

    type RecursiveRequire<Keys extends string[]> = Keys extends [
      ...infer E extends string[],
      infer R extends string
    ]
      ? { [Key in E[number] | R]: string } | RecursiveRequire<E>
      : never;
    
    type BestType = RecursiveRequire<["a", "b", "c", "d"]>;
    

    This will give us the correct behavior on the tests you specified:

    const a: BestType = { a: "" }; // valid
    const b: BestType = { a: "", b: "" }; // valid
    const c: BestType = { a: "", b: "", c: "" }; // valid
    const d: BestType = { c: "" }; // not valid!!
    

    But there is a catch. While our union represents all valid object types, it does not in any way disallow invalid combinations. Remember: excess properties are mostly allowed in TypeScript's structural type system.

    So this is not an error:

    const e = { c: "", a: "" }
    const f: BestType = e
    

    To fix this, we have to modify our union to also forbid excess properties.

    type RecursiveRequire<
      Keys extends string[], 
      N extends string = never
    > = Keys extends [
      ...infer E extends string[],
      infer R extends string
    ]
      ? (({ 
          [Key in E[number] | R]: string 
        } & { 
          [K in N]?: never
        }) | RecursiveRequire<E, R | N>) extends infer U ? { 
          [K in keyof U]: U[K] 
        } : never
      : never;
    
    type BestType = RecursiveRequire<["a", "b", "c", "d"]>;
    

    BestType will now look like this:

    type BestType = {
        a: string;
        b: string;
        c: string;
        d: string;
    } | {
        a: string;
        b: string;
        c: string;
        d?: undefined;
    } | {
        a: string;
        b: string;
        c?: undefined;
        d?: undefined;
    } | {
        a: string;
        b?: undefined;
        c?: undefined;
        d?: undefined;
    }
    

    This now passes our excess property test case.

    const a: BestType = { a: "" }; // valid
    const b: BestType = { a: "", b: "" }; // valid
    const c: BestType = { a: "", b: "", c: "" }; // valid
    const d: BestType = { c: "" }; // not valid!!
    
    const e = { c: "", a: "" }
    const f: BestType = e // not valid!!
    

    Playground