1

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.

1
  • I don't think you can do this without naming all combinaisons. <T> is global to your array, so basically all T types can be anything. Commented Sep 26, 2021 at 19:39

3 Answers 3

1

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

Sign up to request clarification or add additional context in comments.

Comments

0

In Your case T is any because it can be a string, number or boolean. So I would not recommend it.
You can just use something like that:

type Value = number | string | boolean
type ValueObject = { one: Value; all: Value[] }
type Root = { values: ValueObject[] }

Comments

0

The short answer is no; I don't believe there's a way to do what you're trying to do. The longer answer is you probably shouldn't do it.

You could probably store the type information like this:

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

...but, I'm struggling to think of a downstream consumption where you'll loop over root.values and not need conditionals for processing the struct like this:

const process = (root) => {
  switch(typeof root) {
    case "number":
      ...
      break
    case "string":
      ...
      break
    case "boolean":
      ...
      break
  }

}

Instead, you could simplify the whole flow by replacing the root with something like:

const intRoots: Value<number>[]
const boolRoots: Value<boolean>[]
const strRoots: Value<string>[]

then process each type explicitly without type checking: const ***Roots.map(...)

A rationalization I fallback on quite frequently (though there are certainly exceptions) is if I'm struggling to manage types in my processing then it's worth revisiting the data structure

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.