Description
Code
declare function trivialExample<T extends any[], K extends keyof T>(arr: T, index: K): T[K]
const numVal = trivialExample([1, "test"], "0") // numVal would have type number
const strVal = trivialExample([1, "test"], "1") // strVal would have type string
Current behavior:
Compile error because typescript infers a regular array and doesn't know that "0"
is a keyof T
.
Desired behavior:
Typescript would infer a tuple type for T
and compile without errors.
Reason
Since the addition of conditional types I've come across a couple real situations where I've wanted it to work like this. Conditional types make it easy to filter out the uninteresting keys that exist on all arrays so you can just work with the indices. A simplified example of something I've actually tried to do is the following code which declares a function that takes an array of any number of objects and returns a single combined object, and would have a compile time error if you accidentally pass it the same property twice:
type ArrayKeys = keyof any[]
type Indices<T> = Exclude<keyof T, ArrayKeys>
type GetUnionKeys<U> = U extends Record<infer K, any> ? K : never
type CombineUnion<U> = { [K in GetUnionKeys<U>]: U extends Record<K, infer T> ? T : never }
type Combine<T> = CombineUnion<T[Indices<T>]>
declare function combine<
T extends object[] &
{
[K in Indices<T>]: {
[K2 in keyof T[K]]: K2 extends GetUnionKeys<T[Exclude<Indices<T>, K>]> ? never : any
}
}
>(objectsToCombine: T): Combine<T>
const result1 = combine([{ foo: 534 }, { bar: "test" }])
// result1 would have type {foo: number, bar: string}
const error1 = combine([{ foo: 534, dupKey: "dup1" }, { bar: "test", dupKey: "dup2" }])
// Want a compile error here because user unexpectedly listed dupKey twice.
Hacky Workaround
By digging around in the typescript codebase I figured out that you can actually trick the compiler into making this work already. The trick is to add an intersection with { "0": any }
declare function combine2<
T extends object[] &
{
[K in Indices<T>]: {
[K2 in keyof T[K]]: K2 extends GetUnionKeys<T[Exclude<Indices<T>, K>]> ? never : any
}
} & { "0": any }
>(objectsToCombine: T): Combine<T>
const result2 = combine2([{ foo: 534 }, { bar: "test" }])
// in TS 2.8 result2 has type {foo: number, bar: string}
const error2 = combine2([{ foo: 534, dupKey: "dup1" }, { bar: "test", dupKey: "dup2" }])
// Actually has an error here as intended because of dupKey in both objects
The checker.ts
file is too long for Github to let me link to the line but the reason it works is because checkArrayLiteral
calls contextualTypeIsTupleLikeType
to decide if it should make the type a tuple or an array, and that ends up deciding it is a tuple type if the contextual type has a property named "0"
. That seems like an implementation detail that might stop working at any point though so I don't feel like it's something we could safely use in real projects.
Drawbacks
The big drawback I can see is that this would result in a non-intuitive difference in behavior between
const works = combine([{ foo: 534 }, { bar: "test" }])
const arrayVar = [{ foo: 534 }, { bar: "test" }]
const wouldNotWork = combine(arrayVar)
Typescript already has the "no excess properties on literal objects" case where it behaves differently based on passing a literal or a variable, but admittedly the reason for the difference in that case is pretty intuitive and actually helps the user while in this case the difference in behavior would be much harder to explain.
Related Issues:
#16656 is similar but is about the more general behavior of literal arrays like ["foo", 12]
being given the type (string | number)[]
instead of [string, number]
. This issue is just about the specific case of passing literal arrays directly to a function.
Search Terms:
tuple, literal array, generic function, type inference, keyof array