Skip to content

Keyword to permit inferring a union for a type parameter #44312

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

Open
5 tasks done
jamiebuilds opened this issue May 27, 2021 · 7 comments
Open
5 tasks done

Keyword to permit inferring a union for a type parameter #44312

jamiebuilds opened this issue May 27, 2021 · 7 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@jamiebuilds
Copy link

Suggestion

πŸ” Search Terms

  • Widen
  • Generics
  • Open

βœ… Viability Checklist

My suggestion meets these guidelines:

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
  • 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, new syntax sugar for JS, etc.)
  • This feature would agree with the rest of TypeScript's Design Goals.

⭐ Suggestion

Right now TypeScript has some rules about when it decides to widen a generic type or not, which can produce confusing results for relatively similar function signatures:

declare function fn1<T>(items: Array<T>): T
declare function fn2<T>(items: Array<() => T>): T
declare function fn3<T>(items: Array<{ prop: T }>): T

let res1: number | string = fn1([42, "hi"])
let res2: number | string = fn2([() => 42, () => "hi"]) // string ("hi") is not a number
let res3: number | string = fn3([{ prop: 42 }, { prop: "hi" }]) // string ("hi") is not a number

Right now its entirely outside of your control what TypeScript will decide to do when a type parameter is not provided and what TypeScript does has changed a few times over the years.

It would help a lot if TypeScript gave you some more control over this behavior, to specify if you want your generics to widen or not.

Please ignore the syntax, just demonstrating where I'd expect it to go

// widen
declare function fn<widen T>(items: Array<() => T>): T;
fn([() => 1, () => 2, () => "three"]) // => number | string

// do not widen
declare function fn<donotwiden T>(items: Array<T>): T;
fn([1, 2, "three"]) // ERR

πŸ“ƒ Motivating Example

An increasingly popular way to use TypeScript is to produce types from runtime values, there are already lots of TypeScript features dedicated to making it easier to infer precise types from values, and there are even libraries like zod and io-ts to do things like:

let type = union(string(), number())

let value: unknown = ...
assert(value, type)
value // >> string | number

Libraries written in TypeScript are increasingly relying on TypeScript's value-to-type inference as part of their public API. So when TypeScript changes its rules about things like when to widen vs not widen, it can be much more dramatic of a breaking change that sometimes requires redesigning these libraries.

type Check<T> = (value: unknown) => value is T

function string(): Check<string> {...}
function number(): Check<number> {...}
function union<T>(...members: Assertion<T>[]): Check<T> {...}

let assert = union(string(), number()) // ERR: number is not a string
let assert = union<string | number>(string(), number())

Right now there are a couple hacks you can do to trick TypeScript into having the desired widening behavior (by lying about the actual types). But there is no guarantee that this behavior will stick around between TypeScript versions.

πŸ’» Use Cases

type Check<T> = (value: unknown) => value is T

function string(): Check<string> {...}
function number(): Check<number> {...}
function union<widen T>(...members: Check<T>[]): Check<T> {...}

let assert1 = union(string(), number())
let assert2 = union<string | number>(string(), number())
type SomeUnionToStayInSyncWith = string | number
let assert3 = union<SomeUnionToStayInSyncWith>(string(), number())

[Playground]

@whzx5byb
Copy link

whzx5byb commented May 28, 2021

See Distributive Conditional Types

And this will make your examples work.

Example1

declare function fn1<T extends unknown[]>(items: T): T[number]
declare function fn2<T extends (() => unknown)[]>(items: T): ReturnType<T[number]>
declare function fn3<T extends {prop: unknown}[]>(items: T): T[number]['prop']

let res1: number | string = fn1([42, "hi"])
let res2: number | string = fn2([() => 42, () => "hi"]) // OK
let res3: number | string = fn3([{ prop: 42 }, { prop: "hi" }]) // OK

Example2

type Check<T> = (value: unknown) => value is T
type CheckedType<T extends Check<unknown>> = T extends Check<infer U> ? U : never;

function string(): Check<string> {
  return (value): value is string => typeof value === "string"
}

function number(): Check<number> {
  return (value): value is number => typeof value === "number"
}

function union<T extends Check<unknown>[]>(...members: T): Check<CheckedType<T[number]>> {
  return (value): value is CheckedType<T[number]> => {
    for (let member of members) {
      if (member(value)) {
        return true
      }
    }
    return false
  }
}

let check1 = union(string(), number())
let x = '???' as string | number | {};
if (check1(x)) {
  x // OK! x is string | number
}

@jamiebuilds
Copy link
Author

Sure but you've changed the signature of union()'s type parameters. The point is that the type parameters are part of the public API

@RyanCavanaugh
Copy link
Member

Aside: For clarity, this isn't "widening" (that process is only about converting literal types to their corresponding primitives, and some others). I don't think we have a name for the particular process of deciding when it's OK / not OK to infer a union for a type argument in the presence of multiple candidates. But it's clear in context what you mean here.

For nowiden, I'm not sure how to reason about the behavior being described here. Presumably all three of these lines either have an error or don't have an error, but it's IMO very difficult to make an argument that noWiden(arr1) should be an error, and nothing meaningful about the code should change when we inline that expression.

declare function noWiden<nowiden T>(arr: T[]): void;
const arr1 = [Math.random() > 0.5 ? 1 : "two"];
const arr2 = [1, "two"];
noWiden(arr1);
noWiden(arr2);
noWiden([1, "two"]);

There's a ton of subtlety here around the inference candidate collection process - while the examples in the OP look similar, from a type system perspective the inference algorithm sees very different things. I wouldn't defend it as unsurprising but AFAIK we haven't actually broken much about this in the past (counterexamples welcomed for my own learning).

I think the intuition here is something like "don't infer a union unless one of the inference candidates is a union"... but literally one of the inference candidates is a union in these cases. There'd have to be some stronger theoretical principle to rely on to make this work in a way that was at least somewhat consistent in the presence of refactoring an argument to a local.

widen is much clearer IMO. There's a step after candidate collection that roughly says, if we can't produce a unifying type except by making a union (in the absence of a constraint to say otherwise), then issue an error and fall back to the constraint type. That could trivially be made dependent on the type parameter declaration and everything would pretty much work from there.

Some more real-world use cases would be useful to help consider.

@jamiebuilds
Copy link
Author

jamiebuilds commented Jun 1, 2021

I didn't really have a use case for nowiden anyways, I was making assumptions about how this process worked internally, so if that's nonsensical I'm happy to drop it.

There's a step after candidate collection that roughly says, if we can't produce a unifying type except by making a union (in the absence of a constraint to say otherwise), then issue an error and fall back to the constraint type. That could trivially be made dependent on the type parameter declaration and everything would pretty much work from there.

I guess you could rename widen to prefer-union for the sake of discussion:

declare function oneOf<prefer-union T>(a: T, b: T): T

Which operates like this:

oneOf("hello", "world") // string
oneOf("hello", 42) // string | number

There's actually a number of ways TypeScript already has the desired prefer-union behavior:

oneOf(["hello"], [42]) // string[] | number[]
oneOf({ a: "hello" }, [42]) // { a: string } | number[]

Then there are places where TypeScript is producing a union but possibly not ideal for prefer-union:

// what it is today (without prefer-union):
oneOf({ a: "hello" }, { b: 42 }) // { a: string, b?: undefined } | { a?: undefined, b: number }
// should probably be:
oneOf({ a: "hello" }, { b: 42 }) // { a: string } | { b: number }

And finally places where TypeScript bails out instead of producing a union:

// what it is today (without prefer-union):
oneOf(() => "hello", () => 42) // Type 'number' (42) is not assignable to type 'string'
// should probably be:
oneOf(() => "hello", () => 42) // (() => string) | (() => number)

@RyanCavanaugh RyanCavanaugh added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Jun 7, 2021
@RyanCavanaugh RyanCavanaugh changed the title Intentionally widen generics Keyword to permit inferring a union for a type parameter Jun 7, 2021
@wardds
Copy link

wardds commented Nov 11, 2022

This is our use case for inferring a union type. I didn't manage to work around having to manually declare the union type in the generic when calling our isOneOf type guard. A new keyword seems like an elegant way to fix that, so you have my upvote.

export function isOneOf<T>(
  value: unknown,
  guards: Array<(value: unknown) => value is T>
): value is T {
  return guards.some((guard) => guard(value));
}

let a!: string | number | boolean;
const isString = (v: unknown): v is string => typeof v === "string";
const isNumber = (v: unknown): v is number => typeof v === "number";

if (isOneOf(a, [isString])) {
  a; // string
}
if (isOneOf<string | number>(a, [isString, isNumber])) {
  a; // string | number
}

// Type 'number' is not assignable to type 'string'.ts(2322)
if (isOneOf(a, [isString, isNumber])) {} 

@jamiebuilds
Copy link
Author

Question: Which place makes more sense for TypeScript internally?

function oneOf<widen T>(...guards: Array<(value: unknown) => value is T): value is T
//            ^^^^^ declaration
function oneOf<T>(...guards: Array<(value: unknown) => value is widen T): value is T
//                                                              ^^^^^ reference

@craigphicks
Copy link

craigphicks commented Dec 17, 2023

If the definition for oneOf is changed to

declare function oneOf<T,U>(a: T, b: U): T | U

it does not produce an error for

const f = oneOf(() => "hello", () => 42) // (() => string) | (() => number)  NO ERROR

If instead you really wanted to constraint a and b to be the same type (which you don't) then you need something else altogether, so there doesn't seem a use case where current behavior of declare function oneOf<T,U>(a: T, b: T): T is sound.

As for this oneOf example, maybe the question should be, are

declare function oneOf<T,U>(a: T, b: T): T

and

declare function oneOf<T,U>(a: T, b: T): T | U

intended to express the same semantics? (I think so).
Then why don't they behave the same?
The former is 4 character shorter.
Maybe it could be called a bug.

Here is another example of what seem to be semantically identical generics where one has trouble.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

5 participants