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

Contextually type array literals as tuples when used in readonly contexts #38727

Closed
5 tasks done
Nathan-Fenner opened this issue May 22, 2020 · 5 comments
Closed
5 tasks done
Assignees
Labels
Needs Investigation This issue needs a team member to investigate its status.

Comments

@Nathan-Fenner
Copy link
Contributor

Nathan-Fenner commented May 22, 2020

Search Terms

  • array tuple argument inference
  • readonly array contextual tuple inference

Suggestion

The following example currently produces an error, since the type of ["abc", 100] is inferred as (string | number)[], which means that TS is inferred as readonly (string | number)[].

function call<TS extends readonly unknown[]>(
  sources: TS,
  func: (...args: TS) => number,
): number {
  return func(...sources);
}

call(["abc", 100], (str, num) => {
    // ERROR: Property 'length' does not exist on type 'string | number'.
    return str.length + num;
    //         ^^^^^^
})

Instead, we can type ["abc", 100] as [string, number] automatically by observing that it's being used in a readonly context (parameter sources: TS where TS: readonly unknown[]).

Use Cases

Well-typed tuples in contexts like the call function above.

Some workarounds already exist, but they aren't ideal.

Unsatisfying Workarounds

Add Typing Annotations

With an explicit type annotation, call<[string, number]>(["abc", 100], func) can be made to work, but this can be tedious or difficult if the values in the tuple have complex types.

Add as const

With an explicit as const call(["abc", 100] as const, func) can be made to work, but this can sometimes do "too much" - it now causes "abc" and 100 to be literally-typed. If they were object values, their fields would become readonly even if that wasn't desired. It also requires extra effort on the part of a library's users, which could cause libraries to shun this approach due to an apparent lack of ergonomics.

Add lots of overloads

Manually adding overloads

function call<T1>(sources: [T1], func: (a1: T1) => number): number;
function call<T1, T2>(sources: [T1, T2], func: (a1: T1, a2: T2) => number): number;
function call<T1, T2, T3>(sources: [T1, T2, T3], func: (a1: T1, a2: T2, a3: T3) => number): number;
function call<T1, T2, T3, T4>(sources: [T1, T2, T3, T4], func: (a1: T1, a2: T2, a3: T3, a4: T4) => number): number;

This approach is repetitive, incomplete (always could need more tuple arguments), and deals poorly with highly-generic code (essentially, requiring the same piecemeal approach for all generic callers).

Examples

With this feature, all of the following can type-check, without needing any annotations at use-sites:

function computeArrAny<TS extends readonly unknown[]>(
  sources: TS,
  func: (...args: TS) => number,
): number {
  return func(...sources);
}
computeArrAny(["abc", 123], (str, num) => {
  return str.length + num;
});

function computeArrStr<TS extends readonly string[]>(
  sources: TS,
  func: (...args: TS) => number,
): number {
  return func(...sources);
}
computeArrStr(["abc", "def"], (str1, str2) => {
  return str1.length + str2.length;
});


function computeArrTup<TS extends readonly [string, number]>(
  sources: TS,
  func: (...args: TS) => number,
): number {
  return func(...sources);
}
computeArrTup(["abc", 123], (str, num) => {
  return str.length + num;
});

Breaking Change?

By restricting to readonly contexts (i.e. where the contextual type extends readonly unknown[]), the number of breaking changes is minimal. In non-generic cases, the tuple type will immediately "decay" to its current array type, with no user-facing change.

In generic contexts, tuples will generally only become more-precise. There are a few cases that may behave differently:

function ditto<T extends readonly unknown[]>(arr: T): T {
    return arr;
}

// OLD x: number[]
// NEW x: [number, number, number]
const x = ditto([1, 2, 3]);

It's unclear whether this pattern appears in real-world code, or if the new typing is problematic (if the array is used mutably, it may behave incorrectly; if the array is used in a read-only manner, there is no change in behavior). My prototype in the TypeScript codebase didn't find any regressions in the compiler tests.

Requiring that the array constraint be readonly drastically limits the fallout of this change, since it's uncommon (even for generic functions) to use it.

Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code (see caveats above)
  • This wouldn't change the runtime behavior of existing JavaScript code
  • This could be implemented without emitting different JS based on the types of the expressions
  • This isn't a runtime feature (e.g. library functionality, non-ECMAScript syntax with JavaScript output, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

I'm also interested in contributing this change. I have a working prototype implementation.

@RyanCavanaugh
Copy link
Member

@ahejlsberg thoughts on doing this as part of your current tuple work? Seems compelling

@awerlogus
Copy link

@RyanCavanaugh

@ahejlsberg thoughts on doing this as part of your current tuple work? Seems compelling

This may be a breaking change if somebody uses existing inferred array type in computation of other types, for example:

const a = [1, 2, 3]

function b (param: typeof a) { ... }

// Works now, but will produce an error after this change
const c = b([1, 2, 3, 4])

So, the better way is to add a new compiler option that will make type checker infer types as narrow as possible: #38831

@Nathan-Fenner
Copy link
Contributor Author

@awerlogus you seem to be misunderstanding this proposal- your example will continue to work exactly the same. This only affects tuples which are contextually typed in a readonly context. Since typeof a is still number[], the criteria for the new behavior don't apply.

See the section on breaking changes for the places that are affected. Non-generic code will never show any difference in behavior.

@ahejlsberg
Copy link
Member

We already support inferring tuple types when the contextual type is a tuple type or a union that includes at least one tuple type. The latter means that you can generally just union with [] to get the effect you want:

function call<TS extends readonly unknown[] | []>(
  sources: TS,
  func: (...args: TS) => number,
): number {
  return func(...sources);
}

So, I think we already have what you need.

@Nathan-Fenner
Copy link
Contributor Author

That's a bit "hacky" but it does solve this particular problem!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Needs Investigation This issue needs a team member to investigate its status.
Projects
None yet
Development

No branches or pull requests

4 participants