ts-reset icon indicating copy to clipboard operation
ts-reset copied to clipboard

Array `.at()` on readonly tuples

Open Sheraff opened this issue 1 year ago • 3 comments

Unless there is a good reason for typescript not to return the correct type when accessing a tuple with .at it feels like a good candidate for this library.

This is how it is currently:

const a = [false, 1, '2'] as const

const b = a.at(0)
//    ^? const b: false | 1 | "2" | undefined

const c = a.at(-1)
//    ^? const c: false | 1 | "2" | undefined

But with this library it could be:

const a = [false, 1, '2'] as const

const b = a.at(0)
//    ^? const b: false

const c = a.at(-1)
//    ^? const c: "2"

Sheraff avatar Mar 06 '23 19:03 Sheraff

As long as the index is positive, the following works pretty well:

interface ReadonlyArray<T> {
	at<I extends number>(index: I): this[I];
}

We can detect (and extract from) negative numbers with something like:

interface ReadonlyArray<T> {
	at<I extends number>(index: I): `${I}` extends `-${infer J extends number}` ? T | undefined : this[I];
}

But at that point I'm only typing it as T | undefined (which is the default implementation, meaning "the type of any member of the tuple"). I wish we could do something with J.

We can bring in a classic bit of array length maths:

type Length<T extends any[]> = T extends { length: infer L } ? L : never

type BuildTuple<L extends number, T extends any[] = []> = T extends { length: L } ? T : BuildTuple<L, [...T, any]>

type Subtract<A extends number, B extends number> = BuildTuple<A> extends [...(infer U), ...BuildTuple<B>]
	? Length<U>
	: never

interface ReadonlyArray<T> {
	at<I extends number> (index: I): `${I}` extends `-${infer J extends number}`
		? this[Subtract<this["length"], J>]
		: this[I]
}

Now we're able to type the negative indexes too, but only as long as they're smaller than the length, so we need to detect a negative number again:

interface ReadonlyArray<T> {
	at<I extends number> (index: I): `${I}` extends `-${infer J extends number}`
		? `${Subtract<this["length"], J>}` extends `-${number}`
			? undefined
			: this[Subtract<this["length"], J>]
		: this[I]
}

And here's the final result:

const a = [false, 1, '2'] as const

const b = a.at(0)
//    ^? const b: false

const c = a.at(-1)
//    ^? const c: "2"

const d = a.at(-4)
//    ^? const d: undefined

Sheraff avatar Mar 06 '23 19:03 Sheraff

Hmmmm, I think I'm happy with the current workaround, which is to just use a[0] - I think the overhead isn't necessarily worth it here. I'll leave this open in case others disagree.

mattpocock avatar Mar 07 '23 14:03 mattpocock

FYI here is the implementation in type-plus: https://github.com/unional/type-plus/blob/main/ts/array/array.ts#L17

unional avatar May 14 '23 01:05 unional