question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. ItĀ collects links to all the places you might be looking at while hunting down a tough bug.

And, if youā€™re still stuck at the end, weā€™re happy to hop on a call to see how we can help out.

Keyword to permit inferring a union for a type parameter

See original GitHub issue

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]

Issue Analytics

  • State:open
  • Created 2 years ago
  • Reactions:2
  • Comments:6 (1 by maintainers)

github_iconTop GitHub Comments

2reactions
RyanCavanaughcommented, May 28, 2021

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.

0reactions
jamiebuildscommented, Nov 17, 2022

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
Read more comments on GitHub >

github_iconTop Results From Across the Web

Understanding infer in TypeScript - LogRocket Blog
The infer keyword and conditional typing in TypeScript allow us to take a type and isolate any piece of it for later use....
Read more >
Documentation - Advanced Types - TypeScript
Union types are useful for modeling situations when values can overlap in the types they can take on. What happens when we need...
Read more >
generically infer union type member based on a string literal ...
Constructs a type by extracting from Type all union members that are assignable to Union . Share.
Read more >
typing ā€” Support for type hints ā€” Python 3.11.1 documentation
In the function greeting , the argument name is expected to be of type str and the return type str . Subtypes are...
Read more >
Intro to Generics in Go: Type Parameters, Type Inference and ...
One of the benefits of using constraints is they allow you to define a set of allowed types represented by the type parameter....
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found