How to declare types for an array of objects whose properties are interdependent?

Issue

I would like to declare type for object (root), that contains nested array of objects (values), where each object has properties one (any type) and all (array of one‘s type).

Bellow is my attempt to do it, however I don’t know how to obtain types of oneproperty, so there is just Value<any>:

type Value<T> = { one: T, all: T[] }
type Root = { values: Value<any>[] }

const root: Root = {
    values: [
        { one: 1, all: 5 }, // Should be error, 'all' should be an array of numbers.
        { one: true, all: [5] }, // Should be error, 'all' should be array of booleans.
        { one: 'a', all: ['a', 'b', 'c'] }, // Ok.
        { one: true, all: [false, true, true] } // Ok.
    ]
}

Is there any way how to do that? Here is example. It would be amazing, if it could be without naming all possible combinations of types like:

type Value = { one: string, all: string[] } | { one: number, all: number[] } | { one: boolean, all: boolean[] }

because it should be applicable to any type.

Solution

As you note, your Root value is too wide to enforce the constraint you care about, but it does have the advantage of being straightforward to use:

const root: Root = {
    values: [
        { one: true, all: [5] }, // uh oh no error
        { one: 'a', all: ['a', 'b', 'c'] }, // okay
        { one: true, all: [false, true, true] } // okay
    ]
}

const indexes = root.values.map(v => v.all.indexOf(v.one));
console.log(indexes) // [-1, 0, 1]

I’m showing the above for comparison purposes. The following approach will enforce the constraint, but you will end up paying for that with some loss of convenience.


When you find yourself wishing you could write an infinite union type like the invalid

type SomeValue = Value<string> | Value<number> | Value<boolean> 
  | Value<null> | Value<Date> | Value<{a: string, b: number}> | ...

it’s a sign that you are looking for existentially quantified generic types. Most languages with generics, including TypeScript, only directly support universally quantified generic types (which correspond to infinite intersection types). There is a feature request at microsoft/TypeScript#14466 to support existentially quantified generics, but it’s not part of the language yet.

Still, it is possible to emulate these generics. The difference between existential and universal generics has to do with who gets to specify the type parameter. If you switch the role of data supplier and data consumer, then universals become existentials. So we can encode SomeValue like this:

type SomeValue = <R>(cb: <T>(value: Value<T>) => R) => R;

Let’s say you have a value someValue of type SomeValue. If you want to access the underlying Value<T> data, you need to call someValue() with some callback cb that receives the Value<T> and does something with it. It’s like an immediately resolved Promise. The cb callback must be prepared for any possible Value<T> value; whoever supplies value gets to choose what T is. All you can say is that it’s some T.

You can write a helper function to turn any Value<T> into a SomeValue:

const toSomeValue = <T,>(value: Value<T>): SomeValue => cb => cb(value);

And then your Root would be

type Root = { values: SomeValue[] }

Which means you can now create root as follows:

const root: Root = {
    values: [
        toSomeValue({ one: true, all: [5] }), // error!
        toSomeValue({ one: 'a', all: ['a', 'b', 'c'] }), // okay
        toSomeValue({ one: true, all: [false, true, true] }) // okay
    ]
}

Here you’ve got the type checking you wanted (with the penalty that you needed to write toSomeValue() a bunch of times). And now you can make indexes from before by pushing your old v => v.all.indexOf(v.one) callback down into someV:

const indexes = root.values.map(someV => someV(v => v.all.indexOf(v.one)));
console.log(indexes) // [-1, 0, 1]

So it produces the same result, but again, with more complexity.


Now you might want to try to make Root generic itself, where you map an array type to an array of Value<T> for individual T types. And then you could either manually annotate that root is, say, Root<[5, "a" | "b" | "c", boolean]>, or try to get the compiler to infer [5, "a" | "b" | "c", boolean] from the initializer to the values property. These approaches are technically possible, but they add even more complexity than the existential type encoding above, and they are not as type safe. So I won’t go into detail here; although the code is included in the link at the bottom of the answer.


Playground link to code

Answered By – jcalz

This Answer collected from stackoverflow, is licensed under cc by-sa 2.5 , cc by-sa 3.0 and cc by-sa 4.0

Leave a Reply

(*) Required, Your email will not be published