Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Suggestion: Infer tuple type for literal arrays passed to generic functions #22679

Closed
kpdonn opened this issue Mar 18, 2018 · 8 comments
Closed
Labels
Declined The issue was declined as something which matches the TypeScript vision

Comments

@kpdonn
Copy link
Contributor

kpdonn commented Mar 18, 2018

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

@kpdonn
Copy link
Contributor Author

kpdonn commented Mar 18, 2018

Actually thinking about it #16656 is just asking for a way to get tuple types without manually annotating everything, so this would basically fix that too via a function like:

function tuple<T extends any[]>(array: T): T { return array }
const myTuple = tuple(["str", 10]) // myTuple would have type [string, number]

The downside is that meaningless function call would actually exist at runtime but that seems pretty inconsequential.

Edit: That also means that

function tuple<T extends any[] & {"0": any}>(array: T): T { return array }
declare function needsTuple(arg: [string, number]): void

const regularArray = ["str", 10]
needsTuple(regularArray) // error

const myTuple = tuple(["str", 10])
needsTuple(myTuple) // no error

technically works today. Playground link

Maybe we could just have that behavior documented as something that will continue to work in the future?

@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Mar 19, 2018

The proposal here would have us giving a completely wrong type to arr:

// Free version of Array#filter
declare function filter<T extends any[]>(arr: T[], cond: (arg: T) => {})

// Inferred type [number, string, number]
// Actual type [string]
const arr = filter([0, "x", 0], s => !!s);

@kpdonn
Copy link
Contributor Author

kpdonn commented Mar 19, 2018

Your example isn't what I'm proposing. What I'm proposing would be more like

type ArrayKeys = keyof any[]
type Indices<T> = Exclude<keyof T, ArrayKeys>

// notice how arr declared to have type T instead of T[]
declare function filter<T extends any[], U extends T[Indices<T>]>(arr: T, cond: (value: U ) => {}): U[];

const filtered = filter([0, "x", 0], s => !!s);

but it wouldn't add any value(but also wouldn't hurt anything as far as I can tell) in that example so it'd be simpler to keep the definition something like

declare function filter<T>(arr: T[], cond: (value: T) => {}): T[];

Which I'm not proposing to change at all.

@RyanCavanaugh
Copy link
Member

What would be the type of numVal in your proposal?

declare function trivialExample<T extends any[]>(arr: T): T;
const numVal = trivialExample([1, "test"]);

@kpdonn
Copy link
Contributor Author

kpdonn commented Mar 19, 2018

In that example the type would be [number, string]

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Declined The issue was declined as something which matches the TypeScript vision labels Mar 20, 2018
@RyanCavanaugh
Copy link
Member

RyanCavanaugh commented Mar 20, 2018

That'd be a substantial breaking change; not something we can really do. There are plenty of functions declared like this

declare function reverse<T extends any[]>(arr: T): T;
const numVal = reverse([1, "test"]); // oops type is completely wrong now

and we can't just say "Well you should have written the declaration differently because now it means something completely different".

@kpdonn
Copy link
Contributor Author

kpdonn commented Mar 21, 2018

Yeah, I have seen the many discussions of the problems going from arrays to tuple and knew you don't want to generally infer tuples everywhere. I was mainly thinking about cases where you weren't going to return the array from the function and figured it wouldn't be a breaking change in those cases, and I only thought about the implications of actually returning the inferred type afterwards.

I can see that it'd be a breaking change, but for the sake of argument is there any real difference today in writing

declare function reverse<T extends any[]>(arr: T): T;

instead of

declare function reverse<T>(arr: T[]): T[];

meaning that if you hypothetically made this breaking change, would there be any cases where people who need the old behavior couldn't just write it as the T[] form? I could easily be missing something but I can't think of a case where that wouldn't be equivalent.

Just my opinion but I think there would be such a large amount of value from providing a way to infer tuple types that it might be worth a breaking change, assuming I'm right that just converting to T[] would be a workaround for anyone affected.

Edit: Also want to emphasize that I'd only suggest inferring a tuple in cases like T extends any[] and not a plain T without any constraints. In that sense I think it'd feel similar to the breaking change back in TS 2.1. Where

declare function arr<T extends string>(...args: T[]): T[]
const x = arr('a', 'b') // type ('a' | 'b')[] because of T extends string

but

declare function arr2<T>(...args: T[]): T[]
const y = arr2('a', 'b') // type string[] because T has no constraint

@typescript-bot
Copy link
Collaborator

Automatically closing this issue for housekeeping purposes. The issue labels indicate that it is unactionable at the moment or has already been addressed.

@microsoft microsoft locked and limited conversation to collaborators Jul 25, 2018
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Declined The issue was declined as something which matches the TypeScript vision
Projects
None yet
Development

No branches or pull requests

4 participants