4

I'm trying to force an argument of type number[] to contain at least one element of value 9.

So far I've got:

type MyType<Required> = { 0: Required } | { 1: Required } | { 2: Required };

declare function forceInArray<
    Required extends number,
    Type extends number[] & MyType<Required>
>(
    required: Required,
    input: Type
): void;

// should fail type-checking
forceInArray(9, []);
forceInArray(9, [1, 2]);
forceInArray(9, { 0: 9 });

// should type-check correctly
forceInArray(9, [9]);
forceInArray(9, [9, 9]);
forceInArray(9, [9, 2, 3, 4]);
forceInArray(9, [1, 9, 3, 4]);
forceInArray(9, [1, 2, 9, 4]);
forceInArray(9, [1, 2, 3, 9]);

Link to TS playground

But ofc the type MyType won't include all possible indexes, so I'm trying to write that in some other way. { [index: number]: 9} is not the good way to do that, since it requires all values to be set to 9. I've also tried some combination of mapped types, with no success

How can I write MyType so that it solves this problem?

burtek
  • 2,576
  • 7
  • 29
  • 37

1 Answers1

3

You can indeed use mapped types. Here's how I'd type forceInArray():

declare function forceInArray<
  R extends number,
  T extends number[],
>(required: R, input: [...T] extends { [K in keyof T]: { [P in K]: R } }[number] ?
  readonly [...T] : never): void;

Some of the complexity here has to do with convincing the compiler to infer array literal values as tuple types and number literal values as numeric literal types (having [...T] in there deals with both). There's some black magic involved. Also I'd expect some interesting edge cases to crop up around widened types like number, 0-element tuples, etc. Finally, I used readonly arrays so people can use const assertions if they want (as in forceInArray(9, [1,2,9] as const)).

Okay, the heart of the matter: { [ K in keyof T]: { [P in K]: R } }[number] type is very much like your MyType type alias. If T is [4, 5, 6, 7, 8] and R is 9, then that type becomes [{0: 9}, {1: 9}, {2: 9}, {3: 9}, {4: 9}][number], or {0: 9} | {1: 9} | {2: 9} | {3: 9} | {4: 9}. Notice how it expands to have as many terms as the length of T.

Let's see if it works:

forceInArray(9, []); // error
forceInArray(9, [1, 2]); // error
forceInArray(9, { 0: 9 }); // error

forceInArray(9, [9]); // okay
forceInArray(9, [9, 9]); // okay
forceInArray(9, [9, 2, 3, 4]); // okay
forceInArray(9, [1, 9, 3, 4]); // okay
forceInArray(9, [1, 2, 9, 4]); // okay
forceInArray(9, [1, 2, 3, 9]); // okay
forceInArray(9, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); // okay

Looks good. Hope that helps; good luck!

Link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • A comment about the `| [R]` part: This pattern was [mentioned at least once by Hejlsberg himself here](https://github.com/microsoft/TypeScript/issues/27179#issuecomment-422606990). See also the [answer to a related question](https://stackoverflow.com/a/64891863/2707792). – Andrey Tyukin Nov 18 '20 at 11:39
  • Hey thanks for sharing this awesome solution. How can I define a similar requirement for an array (without a function declaration). I would like to make this work: `const myList: ArrayWithForcedItem<'bar'> = ['foo', 'bar']`. If I try to use your solution I still need to define the generic types explicitly every time: `const y: ArrayForcedItem<'bar', ["bar", "house", "mouse"]> = ["bar", "house", "mouse"]` – Chaoste Dec 14 '21 at 14:22
  • I started using it 10 months ago and it worked like a charm but now with TS v4.8.x it doesn't work anymore (error about circular dependency of `T`). Can you provide an update to this that works with more recent versions? – Chaoste Oct 13 '22 at 14:57
  • Okay, done... it's a little more roundabout since you have to replace the type parameter constraint with a conditional type in the function parameter type but it still works, I think – jcalz Oct 13 '22 at 15:10