Skip to content

Numeric Range Types (Feature Update) #54925

Description

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.

Activity

  1. fatcerberus commented on Jul 7, 2023

    @fatcerberus

    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.

    Counterpoint: EPC has been so successful that people are regularly surprised when it turns out to not be part of the type system proper, as evidenced by many, many issues where people treat it as a de facto implementation of exact types and report it as a bug when that use case falls over.

  2. voxpelli commented on Jul 7, 2023

    @voxpelli

    Actual code used in fastify/fastify#4823 to generate a union of 500 numbers. Could easily generate a union of a million or more numbers:

    type StringAsNumber<T extends string> = T extends `${infer N extends number}` ? N : never;
    type CodeClasses = 1 | 2 | 3 | 4 | 5;
    type Digit = 0 |1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
    type HttpCodes = StringAsNumber<`${CodeClasses}${Digit}${Digit}`>;

    That same Fastify issue also shows the usefulness for HTTP Status Codes: To type the expected value sent by a router depending on the status code set by that router and enabling setting a response format for not just specific HTTP codes but for eg all 2xx, 4xx or 5xx errors and thus have 4xx match against any value between 400 and 499

  3. RReverser commented on Jul 7, 2023

    @RReverser
    Contributor
    doProbability(Math.random() * 200); // Well, we tried.

    FWIW I'd expect this to fail to be useful for uint8 / ... / uint32 scenarios.

    As in, fail not because we can prove it's outside the 0..1 range, but because it has type number which should be considered not compatible with any integer type ever unless it's either a 1) constant (in which case it will have a specific literal type which is compatible with range) or 2) explicitly casted away from number by user.

    I know this will be inconvenient for doing any kind of arithmetic operations on such numbers - e.g. x * 2 is obviously outside the range and, unless TS will want to do the complexity of encoding all possible even numbers, the result will have type number instead of a range value. But in a sense that's the point - values should be in range only if they were explicitly casted to said range (or if TS can prove for ops like (x | 0): i32).

  4. icecream17 commented on Jul 8, 2023

    @icecream17

    FWIW I'd expect Exclude to be equivalent to the union (for example Exclude<0..32767, 5> = 1..4 | 6..32767 (inclusive ranges))

    This seems much better than the particular option 1 | 2 | 3 | 4 | 6 | 7 | ... | 32765 | 32766 | 32767

    Theoretically a union of ranges is never larger than the equivalent union of each number represented.


    Edit:

    An additional note is that TS currently errors here:

    type u1 = 0 | 1
    let check1: u1 = Math.round(Math.random()) // error
    let check2: u1 = 0 + 1 // error
    let check3: u1 = 1 * 1 // error

    Since 0 | 1 is equivalent to the range, that means some implementation of numeric operators can happen before implementing ranges, and it would still affect code. (I can see check2 and check3 validating, although check1 seems like a stretch.)

    This divides the "operators" part of the problem into two smaller parts.

  5. fatcerberus commented on Jul 8, 2023

    @fatcerberus
  6. HolgerJeromin commented on Jul 10, 2023

    @HolgerJeromin
    Contributor

    We could protect against mistakes like if (Math.random() > 1), for sure, though in 150ish comments this wasn't brought up.

    Brad Zacher (@bradzacher) reported my ts-eslint wish over there for such similar checks:

    • indexOf() >= -1 - "error - expression is always true"
  7. deleted a comment from Thaina on Jul 17, 2023
  8. deleted a comment from Thaina on Jul 17, 2023
  9. Zarel commented on Nov 10, 2023

    @Zarel

    I'm really happy that this is being considered; this is a really really important feature to me. API documentation is a big reason why, and checking literal inputs by itself would be a big improvement for me, but I urge you to make ranges a full type system.

    You talk about the complexity of type AnyUInt16But5 = Exclude<0..32767, 5>;

    But would it be worth it to just... not support that? Simplify it to 0..32767? Maybe give an error message?

    TypeScript already does this:

    image

    I also wouldn't mind 0..3 being treated slightly differently from 0 | 1 | 2 | 3. Simply having the feature at all would be a huge boon to me.

    There are just a lot of advantages for having this be more fully a part of the type system. For instance, passing an untrusted input number to a function expecting an integer within a range would be a useful opportunity for a reminder to validate the input.

  10. phpcoder2022 commented on Mar 22, 2024

    @phpcoder2022

    Good day. I red fastify example and completed trim type challenge and discover, that wide IntRange type can done in vanilla typescript, playground.

  11. CamJN commented on Mar 23, 2024

    @CamJN

    phpcoder2022 I do not think your example works the way people would like this feature to: for example.

  12. phpcoder2022 commented on Mar 25, 2024

    @phpcoder2022

    Camden Narzt (@CamJN) Hello. I completed your problem. May be my solution is some verbose, but these types and funcs were should write only once.

  13. fredrare commented on Dec 6, 2024

    @fredrare

    This could prove useful for a number of use-cases, as a union type is beyond impractical for large ranges. My guess is that this could be implemented as a first-class type.

    For very small ones and in a reasonably readable manner, I have made a NumberRange type that I believe should be ok: gist.

  14. HansBrende commented on Dec 18, 2024

    @HansBrende
    Contributor
    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.

    Ryan Cavanaugh (@RyanCavanaugh) there is a 4th option: the resulting type could be (0..4) | (6..32767)

  15. KrystilizeNevaDies commented on May 19, 2025

    @KrystilizeNevaDies

    for the case Exclude<0..32767, 5>;
    type Exclude<T, U> = T extends U ? never : T;
    so
    Exclude<0..32767, 5> = 0..32767 extends 5 ? never : 0..32767
    Why can we not leave this type as-is?
    It seems like we only need to support the new range type.

  16. mkantor commented on Dec 9, 2025

    @mkantor
    Contributor

    Tyreece Rozycki (@KrystilizeNevaDies) since 0..32767 is not a subtype of 5 then Exclude<0..32767, 5> a.k.a. 0..32767 extends 5 ? never : 0..32767 would just be 0..32767, which I don't think is what most people would expect. (Then again some people also expect Exclude<string, "some specific string"> to do something useful.)

    Or if you're implying that numeric range types should trigger distributivity like union types do then I think that means the result would be "b) a union of 32766 members", which as that part of the issue description says would "cause TypeScript to run out of memory".

  17. snarbles2 commented on Dec 9, 2025

    @snarbles2

    b) a union of 32766 members

    Would we not want to treat 1..5 as equivalent to 1 | 2 | 3 | 4 | 5? It would be surprising to me if a range wasn't treated as an optimized representation of a large union.

    In that case Exclude<0..32767, 5> distributes over the "union" and the theoretical result is 0 | 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 ... 32767, but as an optimization you would get the reduced form of 0..4 | 6..32767. So yes, it has 32766 members, and yet it doesn't break the bank.

  18. mkantor commented on Dec 9, 2025

    @mkantor
    Contributor

    snarbles2 I agree with all of that (though I might use terminology other than "it has 32766 members" to describe such a type). I was merely answering Tyreece Rozycki (@KrystilizeNevaDies)'s "Why can we not leave this type as-is?" question.

  19. minecrawler commented on Dec 10, 2025

    @minecrawler

    A string is not an array of numbers representing ASCII values, nor an array of a union of characters, and will never be handled as an array. Maybe this new data type needs a new type of handling as well, and cannot "just depend on the union algorithm." For example, the Range type could be a bounds-checking type.


    For example:

    const range: 0..100_000_000_000 = 42;

    In order to check if 42 is inside the range, the range would not become a union type, but be of the new type "Range" with its own kind of handlind. Tsc would check the type by checking the bounds 42 >= 0 && 42 =< 100_000_000_000 (inclusion of limits to be discussed).


    const range2: Exclude<0..100_000_000_000, 5> = 42;

    This would then become a bounds-check with one exception: 42 >= 0 && 42 =< 100_000_000_000 && 42 !== 5. This means a lot of work and diligence to evaluate existing types and how the new Range type would interact with them, but memory and compute-wise it's dirt-cheap - especially compared to a union in this case.


    Of course, excluding a number from a Range is only one possibility

    const excludeRange: Exclude<0..100_000_000_000, 80..90> = 42;

    Ranges should also be able to exclude other ranges 42 >= 0 && 42 =< 100_000_000_000 && !(42 >= 80 && 42 =< 90).


    Furthermore, we should then think about these questions:

    • Special numbers as bounds inside a range, e.g. Number.MAX_SAFE_INTEGER and Number.MIN_SAFE_INTEGER
    • What about BigInt?
    • Handling integers vs floating point numbers. Is 42.1 inside 0..100 or is it not? Do we need a differentiation? TS doesn't have an integer type, and JS is a chewing gum. For integers, we'd have to use BigInts or possibly open another can of worms...
    • Slices of ranges? Is 1..10 a sub-type of 0..100, meaning it can be assigned or passed successfully?
    • Can we learn from other languages, like Rust, Python, Kotlin, Swift, etc.?
  20. KrystilizeNevaDies commented on Dec 10, 2025

    @KrystilizeNevaDies

    Matt Kantor (@mkantor) I'm not aware of a lot of the typescript internals yet (reading the codebase is hard), but I thought that the type itself could be kept as a conditional. Why does 0..32767 being a subtype of 5 matter?
    It sounds like if we expand the conditionals implementation and let that handle the logic instead of splitting up the types into multiple ranges, it simplifies everything.

  21. shaedrich commented on Dec 10, 2025

    @shaedrich
    • Handling integers vs floating point numbers. Is 42.1 inside 0..100 or is it not?

    This might require different syntax like

    0..100 // integer range -> 42.1 is not inside range
    0...100 // float range (note the additional dot) -> 42.1 is inside range

    ℹ️ Other languages might already use this differentiation for something else (e.g. Ruby denotes inclusive vs exclusive ranges with this syntax)

  22. KrystilizeNevaDies commented on Dec 10, 2025

    @KrystilizeNevaDies

    Sebastian Hädrich (@shaedrich) I feel like if a float implementation is possible, an int implementation is pretty low priority since we should be able to model all int operations as types.

  23. mostpinkest commented on Dec 11, 2025

    @mostpinkest

    Whilst you can currently model int ranges with types, they quickly run up against performance impacts and built-in limits with larger ranges.

  24. KrystilizeNevaDies commented on Dec 11, 2025

    @KrystilizeNevaDies

    Another idea I have been experimenting with is using bounds as eager floating point intervals.

    // Basic arithmetic is inferred immediately, with no "history tree"
    let x: Range<0.0, 10.0> = 5;
    let y = x + 1; // Inferred as Range<1.0, 11.0>
    let z = y * 2; // Inferred as Range<2.0, 22.0>
    
    // The compiler warns you if your logic assumes infinite precision
    let a: Range<0.1, 0.1> = 0.1;
    let b: Range<0.2, 0.2> = 0.2;
    let c = a + b; // Inferred as Range<0.30000000000000004, 0.30000000000000004>
    
    const target: Range<0.3, 0.3> = c; 
    // ^ Error: Type 'Range<0.30...04>' is not assignable to 'Range<0.3>'
    
    // Because evaluation is eager, we don't need complex return types like Sum<T, 1> to support generics
    function double<T extends Range<number, number>>(val: T) {
        // Compiler calculates: Min = T.Min * 2, Max = T.Max * 2
        return val * 2; 
    }
    
    let val: Range<10.0, 20.0> = 15;
    let result = double(val); // Inferred as Range<20.0, 40.0>

    Not sure how viable this is, especially with recomputing the ranges for a specific generic (although we allow super complex generic computations anyhow).

    Note that Range-Range, Range-Const, and Const-Const operations would be supported, and anything with the regular number type would coerce the result to a number regardless of the other operand.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Feature UpdateSome issues deserve a fresh startSuggestionAn idea for TypeScript

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions