Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Tuple with different type for last item (start with rest element first)

I have a type Foo that is an Array that can contain any number of Bar elements, with an optional last Qux element.

Here are some examples of valid data:

[]
[bar]
[qux]
[bar, qux]
[bar, bar, bar, bar, bar]
[bar, bar, bar, qux]

Examples of invalid data:

[qux, qux]
[qux, bar]
[bar, bar, qux, bar]
[bar, bar, qux, qux]

Currently I have it as type Foo = Array<Bar | Qux>, but this does not capture the fact that only a single Qux is allowed, and only as the last item.

I'm not sure if I should even expect Typescript to be able to express this, or if there is any practical upside if it could be achieved.

like image 209
last-child Avatar asked Oct 14 '19 08:10

last-child


3 Answers

TS 4.2

allows leading/middle rest elements in tuple types. You can now write:

type WithLastQux = [...Bar[], Qux]

To allow an optional last Qux (thanks @last-child):

type WithLastQux = [...Bar[], Qux] | Bar[]

Test:

// ok
const t11: WithLastQux = ["qux"]
const t12: WithLastQux = ["bar", "qux"]
const t13: WithLastQux = ["bar", "bar", "bar", "qux"]
const t14: WithLastQux = ["bar"]
const t15: WithLastQux = []

// error
const t16: WithLastQux = ["qux", "qux"] 
const t17: WithLastQux = ["qux", "bar"]

As extension, define a generic function helper to keep narrow, fixed-sized tuple types:

function foo<T extends WithLastQux>(t: T): T { return t }

foo(["bar", "qux"])
// type: function foo<["bar", "qux"]>(t: ["bar", "qux"]): ["bar", "qux"]

Note, that an optional last Qux element via [...Bar[], Qux?] is not directly possible:

The only restriction is that a rest element can be placed anywhere in a tuple, so long as it’s not followed by another optional element or rest element. (docs)

Playground


TS 4.0

In JS, rest parameters always need to be the last function parameter. TS 4.0 comes with a feature called variadic tuple types, which you can think of here as "rest parameters for types".

The neat thing is: variadic tuple types can be placed anywhere in function parameters, not just at the end. We now can define an arbitrary amount of bar items with an optional last element qux.

Example: [bar, bar, <and arbitrary more>, qux] can be interpreted as variadic tuple [...T, qux], where T stands for all the bar items.

Solution 1: Separate Assert type

type Assert<T extends readonly (Bar | Qux)[]> =
    T extends readonly [] ? T :
    T extends readonly [...infer I, infer U] ?
    I extends [] ? T :
    I extends Bar[] ? T : readonly [...{ [K in keyof I]: Bar }, U] :
    never

function assert<T extends readonly (Bar | Qux)[] | readonly [Bar | Qux]>(
  a: Assert<T>): T { return a as T }

// OK
const ok1 = assert([])
const ok2 = assert(["bar"])
const ok3 = assert(["qux"])
const ok4 = assert(["bar", "qux"])
const ok5 = assert(["bar", "bar", "bar"])
const ok6 = assert(["bar", "bar", "bar", "qux"])

// errors
const err1 = assert(["qux", "qux"])
const err2 = assert(["qux", "bar"])
const err3 = assert(["bar", "bar", "qux", "bar"])
const err4 = assert(["bar", "bar", "qux", "qux"])

You might substitute the assert helper function with some kind of "sentinel" type:

type Sentinel<T, U extends T> = U
const arr1 = ["bar", "bar"] as const
const arr2 = ["qux", "bar"] as const
type Arr1Type = Sentinel<Assert<typeof arr1>, typeof arr1> // OK
type Arr2Type = Sentinel<Assert<typeof arr2>, typeof arr2> // error

Playground

Solution 2: Function overloading

type Bar = "bar"
type Qux = "qux"

function assert<T extends Bar[] = []>(
    arg: (Qux | Bar)[] extends [...T, Qux] ? never : [...T, Qux]): [...T, Qux]
function assert<T extends Bar[] = []>(arg: [...T]): [...T]
function assert<T extends Bar[] = []>(arg: [...T, Qux?]) { return arg }

Playground


TS 3.9 and older

If you really need rest parameters followed by a last element (the specification only allows them to be last, see @Nit's answer), here is an alternative:

Assumptions

  • A concrete array value is present and typed (e.g. as const)
  • array size up to 256 elements (StringToNumber hardcoded size limit)
  • You don't want to declare the Bar/Qux asserting type in a recursive manner: while technically possible, recursive types are not officially supported till version 4.1 (Note: updated).

Code

type Bar = { bar: string };
type Qux = { qux: number };

// asserts Qux for the last index of T, other elements have to be Bar
type AssertQuxIsLast<T extends readonly any[]> = {
  [K in keyof T]: StringToNumber[Extract<K, string>] extends LastIndex<T>
    ? T[K] extends Qux
      ? Qux
      : never
    : T[K] extends Bar
    ? Bar
    : never;
};

// = calculates T array length minus 1
type LastIndex<T extends readonly any[]> = ((...t: T) => void) extends ((
  x: any,
  ...u: infer U
) => void)
  ? U["length"]
  : never;

// TS doesn't support string to number type conversions 😥,
// so we support (index) numbers up to 256
export type StringToNumber = {
  [k: string]: number;
  0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, 10: 10, 11: 11, 12: 12, 13: 13, 14: 14, 15: 15, 16: 16, 17: 17, 18: 18, 19: 19, 20: 20, 21: 21, 22: 22, 23: 23, 24: 24, 25: 25, 26: 26, 27: 27, 28: 28, 29: 29, 30: 30, 31: 31, 32: 32, 33: 33, 34: 34, 35: 35, 36: 36, 37: 37, 38: 38, 39: 39, 40: 40, 41: 41, 42: 42, 43: 43, 44: 44, 45: 45, 46: 46, 47: 47, 48: 48, 49: 49, 50: 50, 51: 51, 52: 52, 53: 53, 54: 54, 55: 55, 56: 56, 57: 57, 58: 58, 59: 59, 60: 60, 61: 61, 62: 62, 63: 63, 64: 64, 65: 65, 66: 66, 67: 67, 68: 68, 69: 69, 70: 70, 71: 71, 72: 72, 73: 73, 74: 74, 75: 75, 76: 76, 77: 77, 78: 78, 79: 79, 80: 80, 81: 81, 82: 82, 83: 83, 84: 84, 85: 85, 86: 86, 87: 87, 88: 88, 89: 89, 90: 90, 91: 91, 92: 92, 93: 93, 94: 94, 95: 95, 96: 96, 97: 97, 98: 98, 99: 99, 100: 100, 101: 101, 102: 102, 103: 103, 104: 104, 105: 105, 106: 106, 107: 107, 108: 108, 109: 109, 110: 110, 111: 111, 112: 112, 113: 113, 114: 114, 115: 115, 116: 116, 117: 117, 118: 118, 119: 119, 120: 120, 121: 121, 122: 122, 123: 123, 124: 124, 125: 125, 126: 126, 127: 127, 128: 128, 129: 129, 130: 130, 131: 131, 132: 132, 133: 133, 134: 134, 135: 135, 136: 136, 137: 137, 138: 138, 139: 139, 140: 140, 141: 141, 142: 142, 143: 143, 144: 144, 145: 145, 146: 146, 147: 147, 148: 148, 149: 149, 150: 150, 151: 151, 152: 152, 153: 153, 154: 154, 155: 155, 156: 156, 157: 157, 158: 158, 159: 159, 160: 160, 161: 161, 162: 162, 163: 163, 164: 164, 165: 165, 166: 166, 167: 167, 168: 168, 169: 169, 170: 170, 171: 171, 172: 172, 173: 173, 174: 174, 175: 175, 176: 176, 177: 177, 178: 178, 179: 179, 180: 180, 181: 181, 182: 182, 183: 183, 184: 184, 185: 185, 186: 186, 187: 187, 188: 188, 189: 189, 190: 190, 191: 191, 192: 192, 193: 193, 194: 194, 195: 195, 196: 196, 197: 197, 198: 198, 199: 199, 200: 200, 201: 201, 202: 202, 203: 203, 204: 204, 205: 205, 206: 206, 207: 207, 208: 208, 209: 209, 210: 210, 211: 211, 212: 212, 213: 213, 214: 214, 215: 215, 216: 216, 217: 217, 218: 218, 219: 219, 220: 220, 221: 221, 222: 222, 223: 223, 224: 224, 225: 225, 226: 226, 227: 227, 228: 228, 229: 229, 230: 230, 231: 231, 232: 232, 233: 233, 234: 234, 235: 235, 236: 236, 237: 237, 238: 238, 239: 239, 240: 240, 241: 241, 242: 242, 243: 243, 244: 244, 245: 245, 246: 246, 247: 247, 248: 248, 249: 249, 250: 250, 251: 251, 252: 252, 253: 253, 254: 254, 255: 255
};

Tests

const arr1 = [{ bar: "bar1" }, { bar: "foo1" }, { qux: 42 }] as const
const arr2 = [{ bar: "bar2" }, { bar: "foo2" }] as const
const arr3 = [{ qux: 42 }] as const

const typedArr1: AssertQuxIsLast<typeof arr1> = arr1
const typedArr2: AssertQuxIsLast<typeof arr2> = arr2 // error (OK)
const typedArr3: AssertQuxIsLast<typeof arr3> = arr3

Explanations

AssertQuxIsLast is a mapped tuple type. Each key K in T is an index of the form "0","1","2", etc. . But this type is a string, not a number!

In order to assert Qux for the last index, we need to convert K back to number in order to compare it with T['length'], which returns the array length number. As the compiler doesn't support a dynamic string to number conversion yet, we have created our own converter with StringToNumber.

Playground

like image 85
ford04 Avatar answered Oct 17 '22 20:10

ford04


Since in Javascript the rest parameters are only allowed as the last parameter, the same approach was historically adopted for types in Typescript.
There are longstanding tickets to remove this limitation for types, see here and here, however at the moment, it's still not supported.

Given you can't strictly define specific-followed-by-rest tuples in Typescript, there doesn't seem to be a straightforward way to implement this until the functionality is added to Typescript itself.

Alternatively, you can manually generate a comfortable number of valid type lists in a separate build step, but at that point I doubt the gained value is worth it.

like image 1
Etheryte Avatar answered Oct 17 '22 18:10

Etheryte


This functionality is expected to ship with TS 4.2.

Example from the implementing PR:

function f1(...args: [...string[], number]) {
    const strs = args.slice(0, -1) as string[];
    const num = args[args.length - 1] as number;
    // ...
}

f1(5);
f1('abc', 5);
f1('abc', 'def', 5);
f1('abc', 'def', 5, 6);  // Error

Your type will become

type Foo = [...Bar[], Qux]
like image 1
Tate Thurston Avatar answered Oct 17 '22 19:10

Tate Thurston