I'm trying to construct a type that ensures that everything registered in a requiredFields
array is set within defaults
.
I want defaults
to throw type errors if it is missing any of the requiredFields
’ names, and if its value doesn't align to the options
for the corresponding item.
requiredFields
is meant to be flexible. The entire object will be known statically—not evaluated at runtime, but I want to configure several different versions with the same overall type-logic that ensures I don't miss registering defaults
.
const configuration: FormConfiguration = {
defaults: { // should throw error for not having `numbers`
letters: 'f', // should throw error because 'f' isn't an option
},
requiredFields: [
{
name: 'letters',
options: ['a', 'b', 'c'],
},
{
name: 'numbers',
options: [1, 2, 3],
},
],
}
I know I could construct defaults for form items in other ways, but this is merely an example for a much more complex type I'm attempting to create.
This is my attempt at creating types:
type Names = FormConfiguration['requiredFields'][number]['name']
// returning:
// type Names = string
type Options =
FormConfiguration['requiredFields'][number]['options'][number]
// returning:
// type Options = string | number
type FormConfiguration = {
requiredFields: Array<{
name: string
options: (string | number)[]
}>
defaults: Record<Names, Options>
}
Clearly, based on the output, there's no sense of self-referencing happening, nor an ability to get correct options per item.
This is doable somehow, right?
Specific TypeScript types like FormConfiguration
can't really be "self-referencing" in the way you mean. If you want a type that depends on things (even itself) then you want the type to be generic like FormConfiguration<F>
. (Well, there's also the polymorphic this
type, which lets a type reference itself in a way similar to what you mean, but it's not really going to be helpful to you, since then you'd need to write explicit subtypes of FormConfiguration
and... I won't digress further, it's not the way you want to go.)
Anyway, let's write FormConfiguration<F>
where F
is the intended element type of the requiredFields
array:
interface FormConfiguration<F extends { name: string, options: any[] }> {
defaults: { [T in F as T["name"]]: T["options"][number] };
requiredFields: F[]
}
Here the defaults
type is a key-remapped mapped type where each key is from the name
property of F
, and each value is one of the elements of the options
property.
Now, that type works, but it's annoying to use directly. Like, you could write this:
const configuration: FormConfiguration<
{ name: "letters"; options: ["a", "b", "c"] } |
{ name: "numbers"; options: [1, 2, 3] }
> = {
defaults: {
numbers: 1,
letters: "b"
},
requiredFields: [
{
name: 'letters',
options: ['a', 'b', 'c'],
},
{
name: 'numbers',
options: [1, 2, 3],
},
],
};
and it would definitely catch the errors you care about, but it's redundant. It requires you write out requiredFields
essentially twice; once as a type, and again as a value. It would be wonderful if you could get the compiler to infer the type argument for you, perhaps by writing something like
const configuration: FormConfiguration<infer> = { ⋯ }
but TypeScript doesn't support type argument inference in generic types, only in generic function calls. There's an open feature request at microsoft/TypeScript#32794 to support something like infer
as a type argument, but until and unless it's adopted, we need to work around it.
The standard workaround here is to provide a helper function which is generic and lets you infer the type argument you care about, and it returns its input. So instead of const configuration: FormConfiguration<infer> = { ⋯ };
, you write const configuration = formConfiguration({ ⋯ });
. It's basically the same thing, and it's not even all that different in terms of developer effort, but it's still a workaround.
Anyway, here's the helper function:
const formConfiguration = <const F extends { name: string, options: any[] }>(
f: FormConfiguration<F>) => f;
Note that I use a const
type parameter so that F
will be inferred with literal types for the name
and options
fields. Normally TypeScript would look at {name: "xyz"}
and infer the type {name: string}
for it. But you actually care about that "xyz"
, so a const
type parameter helps us.
So now, finally, let's test it:
const configuration = formConfiguration({
defaults: {
numbers: 1,
letters: "b"
},
requiredFields: [
{
name: 'letters',
options: ['a', 'b', 'c'],
},
{
name: 'numbers',
options: [1, 2, 3],
},
],
}); // okay
That compiles as desired, and the type of configuration
is essentially the same FormConfiguration<{ ⋯ } | { ⋯ }>
I wrote out manually above (although the const
type parameter adds some readonly
in there). And if you make mistakes, you get errors where you expect them:
const configuration = formConfiguration({
defaults: { // error! numbers is missing
letters: "b"
},
requiredFields: [
{
name: 'letters',
options: ['a', 'b', 'c'],
},
{
name: 'numbers',
options: [1, 2, 3],
},
],
});
or
const configuration = formConfiguration({
defaults: {
numbers: 1,
letters: "f" // error! "f" is not assignable to "a"|"b"|"c"
},
requiredFields: [
{
name: 'letters',
options: ['a', 'b', 'c'],
},
{
name: 'numbers',
options: [1, 2, 3],
},
],
});
Looks good!