Recursively patch type definition

54 Views Asked by At

I'm trying to write a generic type that takes a type as a parameter (which could be a plain object, an array, a primitive, etc.) and remaps the value types when it is a plain object or an array to add some configuration directives described by a Configuration type.

Let's call that hypothetic modifier Configurable<T>. T could be any complexly nested entity. Book could be a value for T for example :

type Configuration = {
  $test: {
    option1: boolean;
    option2: string;
  }
};

type Book = {
  id: string;
  title: string;
  author: string;
  related: Array<string>;
};

type Result = Configurable<Book>;

I then want Configurable<Book> to correctly type-check the following expressions where the values could be the actual values or the configuration object :

const expr1: Configurable<Book> = {
  id: "1",
  title: "Harry Potter",
  author: "J.K. Rowling",
  related: ["2", "3"]
}

const expr2: Configurable<Book> = {
  id: "2",
  title: "Harry Potter",
  author: {
    $test: {
      option1: true,
      option2: "something"
    }
  },
  related: []
}

const expr3: Configurable<Book> = {
  id: "3",
  title: "Harry Potter",
  author: "J.K. Rowling",
  related: ["2", {
    $test: {
      option1: true,
      option2: "something"
    }
  }]
}

const expr4: Configurable<Book> = {
  id: "4",
  title: true, // ERROR: should be string or Configuration
  author: "J.K. Rowling",
  related: ["2", "3"]
}

const expr5: Configurable<Book> = {
  id: "5",
  title: "Harry Potter",
  author: "J.K. Rowling",
  related: {
    $test: {
      option1: true,
      option2: "something"
    }
  } // ERROR: should be an array of (string | Configuration)
}

Nested object or arrays should not be replaceable by Configuration, only where a primitive value is expected (see expr5). Here is what I tried :

type Configuration = {
  $test: {
    option1: boolean;
    option2: string;
  };
};

type Configurable<T> = Record<string, any> extends T
  ? {
      [K in keyof T]: Configurable<T[K]> | Configuration;
    }
  : T extends Array<infer U>
  ? Array<Configurable<U>>
  : T;

But this makes expr2 and expr3 fail.

Link to Playground

1

There are 1 best solutions below

0
On BEST ANSWER

If I understand your requirements correctly, you could use this definition of Configurable:

type Configurable<T> = T extends object ?
  { [K in keyof T]: Configurable<T[K]> } :
  T | Configuration;

The object type corresponds to any non-primitive, including arrays. If T extends object is not true, then T is a primitive and you want to accept T | Configuration. If T extends object is true, then you map over its properties with Configurable. This should automatically do the right thing with arrays and tuples, since mapped tuples and arrays produce tuples and arrays.

Let's try it out:

const expr1: Configurable<Book> = {
  id: "1",
  title: "Harry Potter",
  author: "J.K. Rowling",
  related: ["2", "3"]
}

const expr2: Configurable<Book> = {
  id: "2",
  title: "Harry Potter",
  author: {
    $test: {
      option1: true,
      option2: "something"
    }
  },
  related: []
}

const expr3: Configurable<Book> = {
  id: "3",
  title: "Harry Potter",
  author: "J.K. Rowling",
  related: ["2", {
    $test: {
      option1: true,
      option2: "something"
    }
  }]
}

The above examples compile with no error as desired. Let's check the errors:

const expr4: Configurable<Book> = {
  id: "4",
  title: true, // ERROR: should be string or Configuration
  author: "J.K. Rowling",
  related: ["2", "3"]
}

const expr5: Configurable<Book> = {
  id: "5",
  title: "Harry Potter",
  author: "J.K. Rowling",
  related: {
    $test: {
      option1: true,
      option2: "something"
    }
  } // ERROR: should be an array of (string | Configuration)
}

The errors you want are indeed produced. Looks good!

Playground link to code