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

Suggestion: Range as Number type #15480

Open
streamich opened this issue Apr 30, 2017 · 71 comments

Comments

@streamich
Copy link

commented Apr 30, 2017

When defining a type one can specify multiple numbers separated by |.

type TTerminalColors = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15;

Allow to specify number types as ranges, instead of listing each number:

type TTerminalColors = 0..15;
type TRgbColorComponent = 0..255;
type TUInt = 0..4294967295;

Maybe use .. for integers and ... for floats.

@panuhorsmalahti

This comment has been minimized.

Copy link

commented Aug 18, 2017

This idea can be expanded to characters, e.g. "b".."d" would be "b" | "c" | "d". It would be easier to specify character sets.

@goodmind

This comment has been minimized.

Copy link

commented Aug 20, 2017

I think this can be expanded and use syntax like and semantics like Haskell ranges.

Syntax Desugared
type U = (e1..e3) type U = | e1 | e1+1 | e1+2 | ...e3 |
The union is never if e1 > e3
type U2 = (e1, e2..e3) type U2 = | e1 | e1+i | e1+2i | ...e3 |,
where the increment, i, is e2-e1.

If the increment is positive or zero, the union terminates when the next element would be greater than e3;
the union is never if e1 > e3.

If the increment is negative, the union terminates when the next element would be less than e3;
the union is never if e1 < e3.
@streamich

This comment has been minimized.

Copy link
Author

commented Aug 20, 2017

@panuhorsmalahti What if you specify "bb".."dd"?

@aluanhaddad

This comment has been minimized.

Copy link
Contributor

commented Aug 20, 2017

@streamich

Maybe use .. for integers and ... for floats.

I really like the idea of generating integral types like this, but I don't see how floating point values could work.

@streamich

This comment has been minimized.

Copy link
Author

commented Aug 20, 2017

@aluanhaddad Say probability:

type TProbability = 0.0...1.0;
@aluanhaddad

This comment has been minimized.

Copy link
Contributor

commented Aug 20, 2017

@streamich so that type has a theoretically infinite number of possible inhabitants?

@jcready

This comment has been minimized.

Copy link

commented Aug 20, 2017

@aluanhaddad actually it would be far from infinite in IEEE floating point. It would have 1,065,353,217 inhabitants by my calculations.

@fatcerberus

This comment has been minimized.

Copy link

commented Aug 20, 2017

0.0...1.0? JS uses IEEE double, that's 53 bits of dynamic range. If that were to be supported, ranges would have to be a first class type, desugaring that to a union would be beyond impractical.

@aluanhaddad

This comment has been minimized.

Copy link
Contributor

commented Aug 20, 2017

@jcready indeed but, as @fatcerberus points out, realizing it as a union type would be prohibitively expansive.

What I was getting at, in a roundabout manner, was that this would introduce some notion of discrete vs. continuous types into the language.

@streamich

This comment has been minimized.

Copy link
Author

commented Aug 21, 2017

realizing it as a union type would be prohibitively expansive.

@aluanhaddad Yes, but even specifying an unsigned integer as a union would be very expensive:

type TUInt = 0..4294967295;
@RyanCavanaugh

This comment has been minimized.

Copy link
Member

commented Aug 22, 2017

This really needs some compelling use cases, because the implementation of unions today is completely unsuited to realizing unions this large. Something that would happen if you wrote something like this

type UInt = 0..4294967295;
var x: UInt = ......;
if (x !== 4) {
  x;
}

would be the instantiation of the union type 0 | 1 | 2 | 3 | 5 | 6 | 7 | ... .

@jcready

This comment has been minimized.

Copy link

commented Aug 22, 2017

Perhaps it could only work against number literals. Any non-literal number values would have to be explicitly refined with greater/less than comparisons before being considered to inhabit the range. Integer ranges would also require an additional Number.isInteger() check. This should eliminate the need to generate actual union types.

@weswigham

This comment has been minimized.

Copy link
Member

commented Aug 22, 2017

@RyanCavanaugh Subtraction types? 🌞

@streamich

This comment has been minimized.

Copy link
Author

commented Aug 23, 2017

Negative types, type negation.

Anything but a string:

type NotAString = !string;

Any number except zero:

type NonZeroNumber = number & !0;
@kitsonk

This comment has been minimized.

Copy link
Contributor

commented Aug 23, 2017

@streamich subtraction types are covered by #4183

@RoyTinker

This comment has been minimized.

Copy link

commented Oct 11, 2017

My use case is: I'd like to type a parameter as 0 or a positive number (it's an array index).

@aluanhaddad

This comment has been minimized.

Copy link
Contributor

commented Oct 12, 2017

@RoyTinker I definitely think this would be cool but I don't know if that use case helps the argument.
An array is just an object and the ascending indexes are just a convention.

let a = [];
for (let i = 0; i > -10; i -= 1) {
  a[i] = Math.random() * 10;
}

so you ultimately still have to perform the same check

function withItem<T>(items: T[], index: number, f: (x: T) => void) {
  if (items[index]) {
    f(items[index]);
  }
}
@Frikki

This comment has been minimized.

Copy link

commented Dec 4, 2017

It would be quite useful for defining types like second, minute, hour, day, month, etc.

@aluanhaddad

This comment has been minimized.

Copy link
Contributor

commented Dec 5, 2017

@Frikki those units are on a confined enough interval that it is practical and prohibitively difficult to write them by hand.

type Hour =
   | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
   | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23;
@streamich

This comment has been minimized.

Copy link
Author

commented Dec 5, 2017

@aluanhaddad But no unsigned int:

type UInt = 0..4294967295;
@zpdDG4gta8XKpMCd

This comment has been minimized.

Copy link

commented Dec 5, 2017

meh, how about a type like this:

type Factorial<N extends number> = N > 2 ? Factorial<N - 1> * N : N;
type F1 = Factorial<1>; // 1
type F2 = Factorial<2>; // 1 | 2
type F3 = Factorial<3>; // 1 | 2 | 6
type FN = Factorial<number>; // 1 | 2 | 6 | ... 
@streamich

This comment has been minimized.

Copy link
Author

commented Dec 5, 2017

Using * operator from @Aleksey-Bykov comment:

type char = 0..255;
type word = char ** 2;
type int = word ** 2;
type bigint = int ** 2;
@gcnew

This comment has been minimized.

Copy link
Contributor

commented Dec 6, 2017

@streamich Doubling the bit count does not correspond to multiplication by two, it's more like exponentation with 2 as the the exponent. It's still not correct, though, as you should not raise the upper bound, but the encodable numbers count. All in all, that's not a good definition strategy.

@RoyTinker

This comment has been minimized.

Copy link

commented Dec 6, 2017

@streamich, some comments:

  • Using the termschar, word, etc. could be confusing since folks from other languages might not realize the difference between static definition and runtime behavior.
  • Your proposed syntax doesn't take the lower bound into account -- what if it is non-zero?
  • I'd be wary about co-opting the exponentiation operator for use in an ambient/type context, since it's already been added to ES2016.
@Thaina

This comment has been minimized.

Copy link

commented Nov 8, 2018

Would it be possible to have compiler did the flow analysis?

Suppose there is this function

function DoSomething(x : int & (>= 0) & (< 10)){
   // DoSomething
}

function WillError(x : int){
    DoSomething(x); // error; x is not >= 0 & < 10
}

function WillNotError(x : int){
    if(x >= 0 && x < 10)
        DoSomething(x); // not error by flow analysis
}
@thisissami

This comment has been minimized.

Copy link

commented Dec 11, 2018

one more use case: i have a number input to a function that represents a percentage. i want to limit values to being between 0 & 1.

@ShadSterling

This comment has been minimized.

Copy link

commented Jan 4, 2019

I just ran [...Array(256)].map((_,i) => i).join("|") to make my ugliest type definition yet

@RyanCavanaugh RyanCavanaugh added this to 𝚜𝚘𝚘𝚗 ™ in Design Meeting Docket Jan 25, 2019

@RyanCavanaugh RyanCavanaugh moved this from 𝚜𝚘𝚘𝚗 ™ to Ready in Design Meeting Docket Jan 25, 2019

@RyanCavanaugh RyanCavanaugh moved this from Ready to This Week in Design Meeting Docket Feb 15, 2019

@RyanCavanaugh RyanCavanaugh moved this from This Week to Ready in Design Meeting Docket Mar 8, 2019

@kgtkr

This comment has been minimized.

Copy link

commented Apr 12, 2019

Nonnegative integers and small numbers are possible:

type ArrayT<T> = T extends (infer P)[] ? P : never;
type A = ArrayT<Range<5, 10>>;//5|6|7|8|9|10

Range:https://github.com/kgtkr/typepark/blob/master/src/list.ts

@Mouvedia

This comment has been minimized.

Copy link

commented Apr 12, 2019

By "small numbers" you mean without using stage 3 BigInt?

@kgtkr

This comment has been minimized.

Copy link

commented Apr 12, 2019

@Mouvedia A number that fits within the compiler's recursion limit

@tregusti

This comment has been minimized.

Copy link

commented May 7, 2019

Maybe use .. for integers and ... for floats.

I would say that .. should mean inclusive range and ... exclusive. Just like in e.g. Ruby. See http://rubylearning.com/satishtalim/ruby_ranges.html

@AnyhowStep

This comment has been minimized.

Copy link
Contributor

commented May 7, 2019

I'm not a fan of using a single period to differentiate between inclusive and exclusive ranges

@SMotaal

This comment has been minimized.

Copy link

commented Jun 3, 2019

Can I jump in? I think that this feature seen as a single enhancement for type specification is not likely the best way to go here.

type range = 1:2:Infinity // 1, 3, 5… Infinity

This is how you define a numeric range in many 4gl platforms — specifically matrix-oriented ones.

You do it this way, because a uniform range is conventionally one of the best modelling approaches.

So a sequential range is this:

type decmials = 1:10 // like 1 .. 10 with all decimals in between

And if just the integers:

type integers = 1:1:10 // 1, 2, 3, 4, 5, 6, 7, 8, 10

// OR

type integers = number.integers<1:10>

If we want to play around with syntax:

type something = 1::10 // whatever use cases need today

If that is not tokenizer friendly, this is not the right blocking priority to focus on here in my opinion. I point this out hoping to not see one solution limit us in getting to the next.

edit: What I fail to account for is the inclusiveness aspect — and here we must wonder if we are doing enough due diligence to understand why that was not an issue when people solved so many problems relying on uniform ranges that are implicitly inclusive of every number except when the increment did not exactly align with the end of the range.

@AnyhowStep

This comment has been minimized.

Copy link
Contributor

commented Jun 4, 2019

Interesting that you bring up being able to define "step" sizes in a range type.

Beyond just "floating point" (no fixed step size) and "integer" (step size of 1, starts from an integer) ranges, I've never encountered a real life use case for ranges with other step sizes.

So, it's interesting to hear about it being a thing elsewhere. Now, I'll need to learn about 4gl because I've never heard of it before.


Being able to define a half-open interval is useful for array-like objects. Something like,

interface SaferArray<T, LengthT extends integer & (>= 0)> {
    length : LengthT;
    [index in integer & (>= 0) & (< LengthT)] : T
}

If we only had inclusive ranges, we'd need (<= LengthT - 1) but that's just less elegant

@Kasahs

This comment has been minimized.

Copy link

commented Jun 4, 2019

Can I jump in? I think that this feature seen as a single enhancement for type specification is not likely the best way to go here.

type range = 1:2:Infinity // 1, 3, 5… Infinity

This is how you define a numeric range in many 4gl platforms — specifically matrix-oriented ones.

You do it this way, because a uniform range is conventionally one of the best modelling approaches.

So a sequential range is this:

type decmials = 1:10 // like 1 .. 10 with all decimals in between

And if just the integers:

type integers = 1:1:10 // 1, 2, 3, 4, 5, 6, 7, 8, 10

// OR

type integers = number.integers<1:10>

If we want to play around with syntax:

type something = 1::10 // whatever use cases need today

If that is not tokenizer friendly, this is not the right blocking priority to focus on here in my opinion. I point this out hoping to not see one solution limit us in getting to the next.

edit: What I fail to account for is the inclusiveness aspect — and here we must wonder if we are doing enough due diligence to understand why that was not an issue when people solved so many problems relying on uniform ranges that are implicitly inclusive of every number except when the increment did not exactly align with the end of the range.

Hmm looks like the way it's handled in Haskell. I think this is good. Generators will allow for lazy evaluation as well.

@AnyhowStep

This comment has been minimized.

Copy link
Contributor

commented Jun 4, 2019

It's not like you can't have lazy evaluation with any other syntax =x

@SMotaal

This comment has been minimized.

Copy link

commented Jun 5, 2019

Interesting that you bring up being able to define "step" sizes in a range type.

I think of a range as start, end and increment. So if the increment rounds off to exactly the end then it is inclusive of that. Array indices and lengths as range functions are can be an array 0:1:9 has 10 index steps (length). So the range here can conveniently be integer & 0:9 for a type system that can more easily infer from combining those two expressions.

4GL is really a generic label, for me that was mostly MatLab

@AnyhowStep

This comment has been minimized.

Copy link
Contributor

commented Jun 5, 2019

My point was that having only inclusive ranges would make using it in generics harder (unless you implement the hack-y type-level math operations).

Because, instead of only having "length" as a type parameter, you now need length and max index. And max index must equal length-1.

And, again, you can't actually check that is the case unless you implement those hack-y type level math operations

@SMotaal

This comment has been minimized.

Copy link

commented Jun 5, 2019

@AnyhowStep I was thinking of the best way to frame your concern — but maybe it helps to first clarify:

If we put the inclusive-or-not as specifically applicable to any n-1 problem (like this index/count scenario) aside, assuming that was independently solved somehow (just humour the thought), are there other scenarios for which numeric ranges would still require less declarative and/or unconventional syntax to properly describe?

I am getting at those being 2 separate aspects, you need numeric ranges that are conventionally aligned with the expectations of that domain and you also need predicates for index/count types... etc.

@AnyhowStep

This comment has been minimized.

Copy link
Contributor

commented Jun 5, 2019

Positive numbers only.

declare let x : (> 0);
x = 0.1; //OK
x = 0.0001; //OK
x = 0.00000001; //OK
x = 0; //Error
x = 1; //OK
x = -1; //Error

With inclusive-only ranges,

declare let x : epsilon:epsilon:Infinity; //Where epsilon is some super-small non-zero, positive number

Positive numbers, excluding Infinity,

declare let x : (> 0) & (< Infinity);

With inclusive-only ranges,

const MAX_FLOAT : 1.7976931348623157e+308 = 1.7976931348623157e+308;
declare let x : epsilon:epsilon:MAX_FLOAT;

Also, unrelated to the current discussion but here's another reason I really want numeric range types.

A lot of my day job is just cobbling a bunch of different software systems together. And a lot of these systems have different requirements for data that have the same meaning.

e.g, (systemA, value between 1 and 255), (systemB, value between 3 and 73), etc.
e.g. (systemC. string length 7-88), (systemD, string length 9-99), (systemE, string length 2-101), etc.

Right now, I need to carefully document all these separate requirements and make sure data from one system can correctly map to another system. If it doesn't map, I need to figure out workarounds.

I am only human. I make mistakes. I don't realize data can't map sometimes. Range checks fail.

With numeric range types, I can finally just declare the ranges each system expects and let the compiler do the checking for me.


For example, I just had a situation where the API I was using had a 10k string length limit for all string values. Well, I had no way to tell TypeScript to check that all strings going to the API are <= 10k string length.

I had a run-time error instead of a nice compile-error where TS could go,

`string` is not assignable to `string & { length : (<= 10000) }`
@SMotaal

This comment has been minimized.

Copy link

commented Jun 7, 2019

@AnyhowStep I hope you can appreciate that my intent is merely to make sure that if something called "Range as Number type" simply aligns with common expectations to the more conventional user (ie someone moving to TS wondering why the range emphasizes inclusivity over interval)

I honestly think that use cases should always drive features, and think that the issues relating to index and length are ones that many of us sometimes really miss. So I simply want to address that problem just under the correct label — is it a number type thing or an indexed type thing? I don't know the answer exactly, but I am hesitant to think that solving it as a number type thing will not inadvertently create far more problems for number type users not sharing the same understanding of these aspects.

So where else would fixing such a problem make sense to everyone is all I am wondering at this point, thoughts?

@bookmoons

This comment has been minimized.

Copy link

commented Jun 21, 2019

I'm dealing with an API that passes byte arrays, so I'd like to define a byte type:

type byte = 0x00..0xFF
type bytes = byte[]

This would also be useful when working with Uint8Array.

@AnyhowStep

This comment has been minimized.

Copy link
Contributor

commented Jul 19, 2019

If you're like me and you're impatient about getting range types you can actually use at the moment, here's a code snippet of range types in action.

TL;DR,
Range types can work now

interface CompileError<_ErrorMessageT> {
    readonly __compileError : never;
}
///////////////////////////////////////////////
type PopFront<TupleT extends any[]> = (
    ((...tuple : TupleT) => void) extends ((head : any, ...tail : infer TailT) => void) ?
    TailT :
    never
);
type PushFront<TailT extends any[], FrontT> = (
    ((front : FrontT, ...tail : TailT) => void) extends ((...tuple : infer TupleT) => void) ?
    TupleT :
    never
);
type LeftPadImpl<TupleT extends any[], ElementT extends any, LengthT extends number> = {
    0 : TupleT,
    1 : LeftPad<PushFront<TupleT, ElementT>, ElementT, LengthT>
}[
    TupleT["length"] extends LengthT ?
    0 :
    1
];
type LeftPad<TupleT extends any[], ElementT extends any, LengthT extends number> = (
    LeftPadImpl<TupleT, ElementT, LengthT> extends infer X ?
    (
        X extends any[] ?
        X :
        never
    ) :
    never
);
type LongerTuple<A extends any[], B extends any[]> = (
    keyof A extends keyof B ?
    B :
    A
);

///////////////////////////////////////////////////////
type Digit = 0|1|2|3|4|5|6|7|8|9;
/**
 * A non-empty tuple of digits
 */
type NaturalNumber = Digit[];

/**
 * 6 - 1 = 5
 */
type SubOne<D extends Digit> = {
    0 : never,
    1 : 0,
    2 : 1,
    3 : 2,
    4 : 3,
    5 : 4,
    6 : 5,
    7 : 6,
    8 : 7,
    9 : 8,
}[D];

type LtDigit<A extends Digit, B extends Digit> = {
    0 : (
        B extends 0 ?
        false :
        true
    ),
    1 : false,
    2 : LtDigit<SubOne<A>, SubOne<B>>
}[
    A extends 0 ?
    0 :
    B extends 0 ?
    1 :
    2
];


//false
type ltDigit_0 = LtDigit<3, 3>;
//true
type ltDigit_1 = LtDigit<3, 4>;
//false
type ltDigit_2 = LtDigit<5, 2>;


/**
 * + Assumes `A` and `B` have the same length.
 * + Assumes `A` and `B` **ARE NOT** reversed.
 *   So, `A[0]` is actually the **FIRST** digit of the number.
 */
type LtEqNaturalNumberImpl<
    A extends NaturalNumber,
    B extends NaturalNumber
> = {
    0 : true,
    1 : (
        LtDigit<A[0], B[0]> extends true ?
        true :
        A[0] extends B[0] ?
        LtEqNaturalNumberImpl<
            PopFront<A>,
            PopFront<B>
        > :
        false
    ),
    2 : never
}[
    A["length"] extends 0 ?
    0 :
    number extends A["length"] ?
    2 :
    1
];
type LtEqNaturalNumber<
    A extends NaturalNumber,
    B extends NaturalNumber
> = (
    LtEqNaturalNumberImpl<
        LeftPad<A, 0, LongerTuple<A, B>["length"]>,
        LeftPad<B, 0, LongerTuple<A, B>["length"]>
    > extends infer X ?
    (
        X extends boolean ?
        X :
        never
    ) :
    never
);

//false
type ltEqNaturalNumber_0 = LtEqNaturalNumber<
    [1],
    [0]
>;
//true
type ltEqNaturalNumber_1 = LtEqNaturalNumber<
    [5,2,3],
    [4,8,9,2,3]
>;
//false
type ltEqNaturalNumber_2 = LtEqNaturalNumber<
    [4,8,9,2,3],
    [5,2,3]
>;
//true
type ltEqNaturalNumber_3 = LtEqNaturalNumber<
    [5,2,3],
    [5,2,3]
>;
//true
type ltEqNaturalNumber_4 = LtEqNaturalNumber<
    [5,2,2],
    [5,2,3]
>;
//false
type ltEqNaturalNumber_5 = LtEqNaturalNumber<
    [5,1],
    [2,5]
>;
//false
type ltEqNaturalNumber_6 = LtEqNaturalNumber<
    [2,5,7],
    [2,5,6]
>;

type RangeLt<N extends NaturalNumber> = (
    number &
    {
        readonly __rangeLt : N|undefined;
    }
);
type StringLengthLt<N extends NaturalNumber> = (
    string & { length : RangeLt<N> }
);

type AssertStringLengthLt<S extends StringLengthLt<NaturalNumber>, N extends NaturalNumber> = (
    LtEqNaturalNumber<
        Exclude<S["length"]["__rangeLt"], undefined>,
        N
    > extends true ?
    S :
    CompileError<[
        "Expected string of length less than",
        N,
        "received",
        Exclude<S["length"]["__rangeLt"], undefined>
    ]>
);
/**
 * String of length less than 256
 */
type StringLt256 = string & { length : RangeLt<[2,5,6]> };
/**
 * String of length less than 512
 */
type StringLt512 = string & { length : RangeLt<[5,1,2]> };

declare function foo<S extends StringLengthLt<NaturalNumber>> (
    s : AssertStringLengthLt<S, [2,5,6]>
) : void;

declare const str256 : StringLt256;
declare const str512 : StringLt512;

foo(str256); //OK!
foo(str512); //Error

declare function makeLengthRangeLtGuard<N extends NaturalNumber> (...n : N) : (
    (x : string) => x is StringLengthLt<N>
);

if (makeLengthRangeLtGuard(2,5,6)(str512)) {
    foo(str512); //OK!
}

declare const blah : string;
foo(blah); //Error

if (makeLengthRangeLtGuard(2,5,5)(blah)) {
    foo(blah); //OK!
}

if (makeLengthRangeLtGuard(2,5,6)(blah)) {
    foo(blah); //OK!
}

if (makeLengthRangeLtGuard(2,5,7)(blah)) {
    foo(blah); //Error
}

Playground

It uses the CompileError<> type from here,
#23689 (comment)

The AssertStringLengthLt<> type is where the magic happens

@AnyhowStep

This comment has been minimized.

Copy link
Contributor

commented Jul 19, 2019

Using type-level addition, you can have, str512 + str512 and get a str1024

#14833 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.