microsoft / microsoft/TypeScript

Numeric Range Types (Feature Update)

オープン
#54,925 コメント 22 件 リアクション 156 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

Feature Update Suggestion
主要言語
Go
スター
111k
フォーク
14.4k
平均マージ
1日 19時間
マージ済み PR(30日)
117

説明

Feature Update: Numeric Ranges

This is a Feature Update for #15480.

What's the Proposal?

The general idea is that you'd be able to write a type which represents a range of numbers, e.g. 0..15 to represent an integer in the range [0, 15], or 0...1 to represent a real number in the range [0, 1), or even characters as well, e.g. "a".."d" would be "a" | "b" | "c" | "d"

Scenarios

Listed scenarios in the original thread

  • Terminal colors, integer 0-15 inclusive
  • RGB color components, integer 0-255 inclusive
  • 32-bit unsigned ints, integer 0-2^32-1 inclusive
  • Math.random() result, floating point 0 (inclusive) to 1 (exclusive)
  • "b" | "c" | "d" (no scenario given)
  • Probabilities, floating point 0 (inclusive) to 1 (inclusive)
  • Any non-negative integer
  • Seconds/minutes/hours/days/months
  • Correctly bounding UInt8 and friends
  • Some API that takes an integer between 5 and 30
  • Opacity, floating point 0 (inclusive) to 1 (inclusive)
  • Status code ranges (e.g. 200-399 is success, 400-600 is error)
  • Latitude/longitude, floating point -90 to 90, -180 to 180, inclusive/exclusive unclear
  • Tuples (seems like this is done?)
  • Some API that takes an integer between 1 and 1000
  • Percentage, floating point 0 (inclusive) to 1 (inclusive)
  • Sudoku values
Scenario Discussion

First, we need to make a distinction between static (i.e. hardcoded) and dynamic (i.e. computed) data.

Validation of certain static inputs can make a lot of sense here. I think every programmer has encountered an API that they thought took an integer 0-255 value, but actually took a 0-1.0, or vice versa. Scenarios like "opacity is specified as a percentage, not an alpha channel value" make a ton of sense, especially since those sorts of values are reasonably likely to be hardcoded, and mistakes here can be subtle.

Same goes for something like JavaScript's Date's infamous month behavior, where 0 is January and 11 is December. Unfortunately, even in an ideal world, unless you happened to be specifying something like 2024, 12, 25, it's unlikely to be caught by a range-based checking approach.

Some other scenarios seem a bit overclever, or don't seem to correspond to a scenario where the checker is even active. HTTP Status Codes have ranges, to be sure, but the use case is what, exactly? Either your transport library is sorting these things out ahead of time for you, or it's not, in which case it's not going to neatly correspond to a discriminated union. Sudoku values are opaque tokens, not numbers in any meaningful sense.

Many of these scenarios don't seem to correspond to any static scenario. For example, it seems unlikely that you have a nontrivial number of geographic coordinates hardcoded into your program.

Validation of dynamic inputs is where things get a bit weird here.

In an output position, e.g. Math.random() returning a number 0 <= n < 1, this seems like information of somewhat limited utility which might be better presented as a documentation comment on the function. We could protect against mistakes like if (Math.random() > 1), for sure, though in 150ish comments this wasn't brought up.

In a dynamic input position, the feature implies new kinds of narrowing, as well as some higher-order reasoning that doesn't exist right now. For example, here, we'd need to bound n first to [0, Infinity), then [0, 2), then have some suitable representation of what Math.floor does to its input (further implying that it's generic).

declare function takesBit(n: 0 | 1): void;
function fn(n: number) {
    if (n >= 0 && n < 2) {
        // OK by construction
        takesBit(Math.floor(n));
    }
}

People will of course want to use Math.max / Math.min to create these bounds:

declare function takeCent(n: 0..100): void;

function fn(n: number) {
    takeCent(Math.max(0, Math.min(n, 99)));
}

So there's also need of a representation of what max and min do.

This implies type constructors for:

  • maximum
  • minimum
  • bitwise integer truncation (-4.5 | 0 is -4)
  • mathematical floor/ceil (Math.floor(-4.5) is -5)
  • All other arithmatic operations

Other Problems

Floating vs Integer, Inclusive vs Exclusive Bounds

There was a fair amount of discussion in the original issue basically saying, this should obviously just be for integers, because floats are too hard to deal with, and also, this should obviously just be for floats, and then there should be an intersectable int & type to cause any float-based range to become integral.

Suggestions here have hinted that that

  • 0..1 (a percentage) is a float between 0 and 1, end-inclusive
  • 0..1 (Math.random result) is a float between 0 and 1, end-exclusive
  • 0..255 is an int between 0 and 255

But these can't all be true at once; either 0..1 is 0, 0 | 1, or [0, 1], or [0, 1). It can't be all of them. Similarly, 0..255 is either 0 | ... | 255, 0 | ... | 254, [0, 255], or [0, 255).

There have also been proposals for separate float/integral/arbitrary-step syntax, as well as separate endpoint syntax. It doesn't seem likely, at all, that someone seeing 0..16 will immediately know which is happening.

Union vs Non-finite Representation

Many comments in the original issue stipulated that this should "just" be sugar for expanding into a union type, e.g. 0..2 is 0 | 1 | 2, or Range<0, 30, 10> should "just" be sugar for 0 | 10 | 20 | 30.

This points to a number of very difficult problems which I think render this feature nearly completely intractable given how unions work today.

Today, you can already write 0 | 1 | 2. You can, in fact, write very large unions, and we've done a ton of work to optimize those unions as best we can. The impracticality of writing out the union from 0 to 255 is a good counterweight to the performance implications of doing so.

But the scenarios in the original issue are often around places where that union expansion would instantly cause TypeScript to run out of memory. This isn't something trivially avoidable, either. If you write something like

type AnyUInt16But5 = Exclude<0..32767, 5>;

the resulting type is necessarily a) an intersection of a range and a negated type, neither of which we have yet, b) a union of 32766 members, or c) a new kind of type constructor in addition to negated types.

The implied confusion by 0 | 1 | 2 being a completely different beast from 0..2, even though by necessity you would only be writing the latter if the former didn't work, seems nearly insurmountable. Why shouldn't they be identical? It's very, very hard to justify. A feature that seems to want to work one way for small numbers and another way for large numbers sets off a lot of red flags.

Complexity

New type constructors are, by far, the most expensive thing we can do in TypeScript in terms of implementation complexity. Each new way to construct a type adds to the matrix of higher-order reasoning we have to implement in order to make generics and type relationships work. The scenarios as described require somewhere between 2 and 6. Mapped types was 1, conditional types was 1, string enums was 1, etc. -- the complexity spend here is quite high just in rough terms.

Possible Next Step?

It broadly seems like 90%+ of the value in this feature lies in the checking of static values, but 90%+ of the complexity, if not more, lies in the checking of dynamic values, plus the complexity of how this feature would work with existing type concepts like literal unions.

The logical outcome of "Well just check static values, then" of this is unsatisfying, to say the least:

declare function doProbability(n: 0..1): void;
doProbability(3); // Error detected
doProbability(Math.random() * 200); // 🤷‍♂️

Excess property checking has been a fairly successful effort to date, able to catch a huge percentage of typos and misapprehensions while not implying a new kind of type. I'm curious if we could support JSDoc-based range checking which only fires on literal inputs:

/** @param n {0..1} */
function doProbability(n: number) { }
doProbability(3); // Error detected
doProbability(Math.random() * 200); // Well, we tried.

This would cleanly position the feature outside the type system, possibly even as an editor-only feature like @deprecated. This also fits in well with regex types (#41160), where an entire type system feature seems like overkill for something where the primary use case is to notify you right away if you just typed an out-bounds literal value.

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

まず、リンク先の元の issue #15480 とこの提案を読んでください。ファイル、テスト、実装のエントリポイント、受け入れ基準は何も示されておらず、JSDoc ベースで進める可能性についても決まっていないため、コーディングの前に maintainer の指針が必要です。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
typescript
領域
compilers
issue の種類
機能追加
難易度
5/5
見積もり時間
1週間以上
活発さ
停滞
明瞭さ
説明が足りない
初心者へのやさしさ
20/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。