본문 바로가기

개발하자

Write typescript type for given shape

반응형

Write typescript type for given shape

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.




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


반응형