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

Time:01-24

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"]>;

CodePudding user response:

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

  • Related