Your primary issue is that you're trying to represent an object with a shape like "a dictionary where all properties have values of type X
, except for the _type
property, which should have a value of type Y
, where Y
is not assignable to X
." TypeScript does not support such types. If you have an index signature, it means all the properties, even _type
, need to be assignable to its value type. Here's an example:
type Problem = {
_type: 'object' | 'array'; // error!
// Property '_type' of type '"object" | "array"' is
// not assignable to string index type 'Input'
[key: string]: Input;
}
There is an open feature request, microsoft/TypeScript#17687, to support objects like this. You could think of it as a "dictionary with some exceptions", or a "set of known properties with a 'rest' index signature". And it has remained an open feature request for three years as of Aug 2020. For now, there are no obvious plans in the works to address this.
There are workarounds, but none of them are particularly great.
One workaround is to use an intersection as you seem to have done. This allows you to define the problematic type, and even to use objects which are already known to be of that type, but it doesn't allow you to easily create objects of that type. You get the index signature incompatibility error you've shown above. If you want to work around that you can use things like Object.assign()
, but it's so very clunky:
const fm = (t: { _type: 'object' | 'array' }, k: { [k: string]: FromObject | Input }): Form =>
Object.assign(t, k);
const form: Form = fm({
_type: 'object'
}, {
number: { type: 'number' },
string: { type: 'text' },
boolean: { type: 'checkbox' },
object: fm({ _type: 'object' }, {
number: {
type: 'number'
},
string: {
type: 'text'
},
}),
numbers: { type: 'select', value: '12', },
strings: { type: 'select' },
booleans: { type: 'select' },
objects: fm({
_type: 'array'
}, {
number: {
type: 'number'
},
string: {
type: 'text'
},
})
});
Basically you have to force the compiler into performing the intersection explicitly, so it doesn't notice the inconsistency.
The answer to the other question goes on about another workaround involving generics. This is arguably going to be even worse for you because of the nested structure, and I'm not inclined to go through the tedious exercise of coming up with something that will work.
The "right" thing to do, according to TypeScript, would be to refactor your code so as not to need these "mixed" types. Push the dictionary down one level:
type Form2 = ({ _type: 'object' | 'array', props: { [key: string]: FromObject2 | Input } });
type FromObject2 = Form2 | Input
And then make your form2
like this:
const form2: Form2 = {
_type: 'object',
props: {
number: {
type: 'number'
},
string: {
type: 'text'
},
boolean: {
type: 'checkbox'
},
object: {
_type: 'object',
props: {
number: {
type: 'number'
},
string: {
type: 'text'
},
}
},
numbers: {
type: 'select',
value: '12',
},
strings: {
type: 'select'
},
booleans: {
type: 'select'
},
objects: {
_type: 'array',
props: {
number: {
type: 'number'
},
string: {
type: 'text'
}
}
}
}
}
That compiles with no error at all and will be much, much easier to use in TypeScript. If you have an existing JS code base and can't refactor, I understand, in which case there will be a headache no matter what you do.
One final possible workaround is just to loosen the constraint:
type Form3 = ({ _type: 'object' | 'array', [key: string]: FromObject3 | Input | 'object' | 'array' });
type FromObject3 = Form3 | Input
now a Form3
or a FromObject3
will allow "object"
or "array"
for any property. And so _type
satisfies the index signature and gets this to compile:
const form3: Form3 = {
_type: 'object',
number: {
type: 'number'
},
string: {
type: 'text'
},
boolean: {
type: 'checkbox'
},
object: {
_type: 'object',
number: {
type: 'number'
},
string: {
type: 'text'
},
},
numbers: {
type: 'select',
value: '12',
},
strings: {
type: 'select'
},
booleans: {
type: 'select'
},
objects: {
_type: 'array',
number: {
type: 'number'
},
string: {
type: 'text'
},
}
}
but with the looser constraint it also accepts this:
const oops: Form3 = {
_type: 'array',
strings: 'object',
booleans: 'array'
}
so you'd have to be careful.
Playground link