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.
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!