0

I am trying to write an interface in typescript for the given shape but using recursion, and I also want leaf node to be of type HTMLInputElement only

const form: Form = {
    _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'
        },
    }
}

I tried this

type Input = (Partial<HTMLInputElement> & { type: string })

type FromObject = ({ _type: 'object' | 'array' } & { [key: string]: FromObject | Input }) | Input

type Form = { _type: 'object' | 'array' } & FromObject

so the idea is a form config can have a key _type with allowed values object | array, if _type is not in the object then it should be of type HTMLInputElement only.

error at key object,

Type '{ _type: "object"; number: { type: string; }; string: { type: string; }; }' is not assignable to type 'FromObject'.
  Type '{ _type: "object"; number: { type: string; }; string: { type: string; }; }' is not assignable to type '{ _type: "object" | "array"; } & { [key: string]: FromObject; }'.
    Type '{ _type: "object"; number: { type: string; }; string: { type: string; }; }' is not assignable to type '{ [key: string]: FromObject; }'.
      Property '_type' is incompatible with index signature.
        Type 'string' is not assignable to type 'FromObject'.

also the leaf nodes are not HTMLInputElement, which I'm trying to avoid.

AbhaY
  • 126
  • 7
  • You haven't mentioned what goes wrong with what you tried. I think your issue has to do with TypeScript's lack of support for hybrid/rest index signatures. If so, I'm inclined to close this as a duplicate of [this question](https://stackoverflow.com/questions/61431397/how-to-define-typescript-type-with-numeric-id-and-string-value). If not, please elaborate in the question about how this differs and how the answer to the other question doesn't address the issue. Good luck! – jcalz Aug 26 '20 at 15:46
  • @jcalz It is not duplicate of question you have referred, I'll add more details. – AbhaY Aug 26 '20 at 16:07
  • That is indeed a duplicate of the issue in the other question; you are trying to mix an index signature and an incompatible property. The exact shape you are describing is not expressible in TS as a concrete type. I suggest either refactoring or loosening your constraint. I will be happy to write up a tailored answer for this, if I get a chance. – jcalz Aug 26 '20 at 17:00
  • sure, thanks @jcalz – AbhaY Aug 26 '20 at 17:03

1 Answers1

0

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

halfer
  • 19,824
  • 17
  • 99
  • 186
jcalz
  • 264,269
  • 27
  • 359
  • 360