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

Proposal: Interval Types / Inequality Types #43505

Open
5 tasks done
nikeee opened this issue Apr 2, 2021 · 38 comments
Open
5 tasks done

Proposal: Interval Types / Inequality Types #43505

nikeee opened this issue Apr 2, 2021 · 38 comments
Labels
In Discussion Not yet reached consensus Suggestion An idea for TypeScript

Comments

@nikeee
Copy link
Contributor

nikeee commented Apr 2, 2021

Related Proposals:

This proposal is partly based on @AnyhowStep's comment and describes the addition of an IntervalType to TypeScript.

📃 Goals / Motivation

Provide developers with a type system that prevents them from forgetting range checks on numbers similar to how we prevent them from forgetting string validation and numeric constant checks.

An interval primitive defines a range of numbers, limited up to a specific value. For example, less than 10 is an interval boundary (this wording may be subject to change).

Non-Goals

  • Integer types
  • Dependent types
  • NaN or (-)Infinity literal type
  • Distinction between double and floats

Definitions

The idea is to extend type-narrowing for relational comparisons (<, >, <=, >=), in addition to the currently existing mechanism to narrow by equality (== !=, ===, !==).

The emerging type is one that lays between literal types (a type with a single value) and the number type.

Syntax

NonArrayType := ...
                | IntervalType

IntervalType := '(' RelationalOperator  IntervalTypeLimit ')'
RelationalOperator  := '<' | '<=' | '>' | '>='
IntervalTypeLimit   := NumberLiteral

So, in code we'd do something like this:

export interface IntervalType extends Type {
    limit: IntervalTypeLimit;
    constraint: RelationalOperator;
}

export type RelationalOperator =
    | SyntaxKind.LessThanToken
    | SyntaxKind.LessThanEqualsToken
    | SyntaxKind.GreaterThanToken
    | SyntaxKind.GreaterThanEqualsToken
    ;

Side-Note: There is also a suggestion by @btoo to use the new intrinsic type and generics instead of a distinct syntax (like GreaterThan<5>).

Semantics

// a must be a positive number
let a: (>0) = 1;
a = -1; // Error, cannot assign -1 to (>0)

function takePositiveNumber(v: (>0)) {
    // ...
}
takePositiveNumber(a);
takePositiveNumber(-1); // Error, cannot assign -1 to (>0)

Currently, IntervalTypeLimit can only ba a NumberLiteral. Allowing references to other types would make this feature significantly more different. There may be a useful opportunity when it comes to generics (see below).

Assignability

A variable of type A is assignable to one of type B:

let a: A;
let b: B = a; // ok, iff...
A\B (> y) (>= y) (< y) (<= y)
(> x) x >= y x >= y false false
(>= x) x > y x >= y false false
(< x) false false x <= y x <= y
(<= x) false false x < y x <= y

Assignability when constants are involved:

const c: c;
let b: B = c; // ok, iff...
c\B (> x) (>= x) (< x) (<= x)
number literal k x < k x <= k x > k x >= k
NaN false false false false
Infinity true true false false
-Infinity false false true true
null false false false false
undefined false false false false
any non-number type false false false false

Since the IntervalType is NumberLike, one can do everything with it what can be done with number.

Control-Flow

The core idea is that we extend type narrowing and combine that with union and intersection types:

let a: number = 12;

if (a > 10) {
    a; // (>10)

    if (a < 20) {
        a; // (>10) & (<20)
    }

    a; // (>10)
}

if (a > 10 || a < 0) {
    a; // (>10) | (<0)
    if (a > 10) {
        a; // (>10)
    } else {
        a; // (<0)
    }
}

Union and Intersection Types

Interval types can be used in an intersection type:

type Probability = (>=0) & (<=1);
function random(): Probability { /* ... */ }

A union or intersection type may only contain at most two interval boundaries:

  • (>10) | (>20) will be reduced to (>10)
  • (>10) & (>20) will be reduced to (>20)
  • (<10) & (>10) will be reduced to never
  • (<10) | (>10) will not get simplified, it remains (<10) | (>10)
  • ...
  • For (>=1) | (<1), see below

Other cases how interval boundaries interact with existing types:

  • number | (>1) is reduced to number
  • number & (>1) is reduced to (>1)
  • (>1) & <any non-number type> is reduced to never
Subject for discussion: Handling of (>1|2|3)

It may be appropriate to expand this as (>1) | (>2) | (>3) (which would be normalized to (>1)). Or we just prohibit this kind of use.

The case of (>=1) | (<1)

Consider this code:

let a: number = 10;
if (a > 9) {
	a; // (>9)
} else {
	a; // number
}

In the else branch, we widen the type back to number instead of narrowing to (<=9). This is because we'd also branch to else, if a would be NaN. So, number implicitly contains NaN.
If a variable's type is/contains an interval boundary, its value cannot be NaN.

This opens up the question on how we should handle (>=1) | (<1). Semantically, it is equivalent to number \ {NaN}, so number would not be equivalent here.

It would feel more natural to the developer if (>=1) | (<1) would become number (including NaN), since not reducing it to number would look weird.

If we'd have negated types (#29317) as well as a NaN type, we could model this as number & not NaN. This may outweigh practical use and would be against the design rules (see the third entry in "Non-Goals"; this is an opinion).
We also don't have a NaN type and there is currently no intent to introduce it.

If we'd decide against the simplification to number, it would make discriminated unions work more easily.

Discriminated Unions via Interval Boundary Types?

Having discriminated unions based on intervals is not yet evaluated that much. Considering the problem with (>=1) | (<1), this may not be possible. For example:

type A = {
    success: (>0.5)
    data: boolean;
};
type B = {
    success: (<=0.5);
    message: string;
};
type Result = A | B;
Result["success"]; // number

declare let res: Result;
if (res.success > 0.5) {
    res.data; // ok
} else {
    res; // still `Result`, because in the `else` branch, res.success is still `number`
}

Discriminated unions would work if the else branch of the example in (>=1) | (<1) would resolve to (<=9). For that to work, we need to drop NaN.

We could also solve this problem by not simplifying (>=1) | (<1) to number, so Result["success"] would be (>0.5) | (<=0.5) instead of number.

Normalization of Unions and Intersections

When n interval boundaries are unified or intersected, the result will always be a single boundary, exactly two boundaries, a number literal, number or never.
Normalization is commutative, so one half of the table is empty.

Normalizing A | B:
A\B (> y) (>= y) (< y) (<= y)
(> x) (> min(x, y)) y <= x ? (>= y) : (> x) y > x ? number : (> x) or (< y) y < x ? (>= x) or (<= y) : number
(>= x) - (>= min(x, y)) y >= x ? number : (>= x) or (< y) y < x ? (>= x) or (<= y) : number
(< x) - - (< max(x, y)) y < x ? (< x) : (<= y)
(<= x) - - - (<= max(x, y))

(due to a limitation of markdown tables, we use or instead of |)

Normalizing A & B:
A\B (> y) (>= y) (< y) (<= y)
(> x) (> max(x, y)) y <= x ? (> x) : (>= y) y > x ? (> x) & (< y) : never y > x ? (> x) & (<= y) : never
(>= x) - (>= max(x, y)) y > x ? (>= x) & (< y) : never y < x ? never : (y == x ? y : (>= x) & (<= y))
(< x) - - (< min(x, y)) x > y ? (<= y) : (< x)
(<= x) - - - (<= min(x, y))

If an interval is equality-compared to a literal, we either narrow down to the respective literal or never.
Further narrowing a boundary intersection via control flow will not increase the number of interval boundaries present in the type:

let a: number = 10;
if (a > 0) {
    a; // (>0)
    if (a < 10) {
        a; // (>0) & (<10)
        if (a < 5) {
            a; // (>0) & (<5)
            if (a > 4) {
                a; // (>4) & (<5)
                if (a > 5) {
                    a; // never
                }
            }
        }
    }
}

Assertions

  • We can do assertions like a as (>1) (where a is a number or another interval), just like with number literal types.

Widening and Arithmetics

Similar to literal types, type-level arithmetics are not supported and coerce to number. For example:

let a: (>1) = 4;
let b = a + 1; // _not_ (>2), but number

Also, applying operators like ++ and -- let the type widen to number:

let a: 1 = 1;
a++;
a; // number

let b: (>=1) = 1;
b++;
b; // number

However, due to the way interval types are always reduced to a maximum of two interval boundaries, it may be feasable to do type-level arithmetics. This proposal currently does not intent to do this.

Loops

With intervals, we can be more exact about loop variables. For example, narrowing a simple c-style for loop would come "for free" if we have flow-based type-narrowing that works for if-then-else:

for (let i = 0; i < 10; ++i) {
	i; // (<10)
}

Explanation on what happens:

for (
	let i = 0; // number
	i < 10; // i -> (<10)
	++i // i -> number again
) { }

// Equivalent to

let i = 0; // number
while (i < 10) {
	i; // (<10)

	++i; // number
}

With a little more work, it may be possible to let i be (>=initial-value) & (<10). But due to the type coercion that ++ causes, this is not possible out-of-the-box.

Enum Interaction

It may be an interesting addition to allow enum literals as limit value, so we can type something like this:

function isListTerminator(kind: (< ParsingContext.Count)): boolean {
    // ...
}

isListTerminator(ParsingContext.Count); // Error
isListTerminator(ParsingContext.ClassMembers); // Ok

type AssignmentOperator = (>=SyntaxKind.FirstAssignment) & (<=SyntaxKind.LastAssignment);
function isAssignmentOperator(token: SyntaxKind): token is AssignmentOperator {
    // (return type could also be inferred here)
    return token >= SyntaxKind.FirstAssignment && token <= SyntaxKind.LastAssignment;
}

It should also be possible to pass interval types to functions that take enums (just like numbers).

Generics

Generics may be valuable to support in this proposal, when they resolve to a literal type.

Consider this implementation of clamp:

function clamp<TMin extends number, TMax extends number>(min: TMin, max: TMax, value: number): (>=TMin) & (<=TMax) {
    if (value < min)
        return min;
    if (value > max)
        return max;
    return value;
}

const v = clamp(1, 2, 3); // (>=1) & (<=2)

const a = 1;
const b = 2;
const c = clamp(a, b, 3); // (>=1) & (<=2)

// if one of the parameters is a `number`, the result would be widened and normalized:
let d: number = 1;
let e: 2 = 2;
let f = clamp(d, e, 3); // (>=number) & (<=2) -> number & (<=2) -> (<=2)

let h: number = 1;
let i: number = 2;
let j = clamp(h, i, 3); // (>=number) & (<=number) -> number & number -> number

💻 Use-Cases

We could statically type/document some APIs more explicitly, for example:

  • Math.random
  • Math.abs
  • clamp
  • Number.range
  • Math.sin/cos/...
  • a % b if a is assignable to (>=0) and b resolves to a number literal (falling back to number otherwise)
  • [your example here]

Functions could express their assumptions about parameters.
They would be useful to force the developer to check if they are in range before passing (similar to literal types):

declare let a: number;

function takeProbability(p: (>=0) & (<=1)) { /* ... */ }

takeProbability(a); // Error, cannot assign number to (>=0) & (<=1)
if (a <= 1) {
    takeProbability(a); // Error, cannot assign number to (>=0)
    if (a >= 0) {
        takeProbability(a); // ok
    }
    takeProbability(a); // Error, cannot assign number to (>=0)
}

function takePercentage(p: (>=0) & (<=1)) { /* ... */ }
// in the case of takePercentage, the developer will notice that it expects something in the range of 0-1 instead of 0-100 because he will receive an error.
type SafeIntegerRange = (<= Number.MAX_SAFE_INTEGER) & (>= Number.MIN_SAFE_INTEGER);

Feel free to add more use-cases!

✅ Viability Checklist

  • This wouldn't be a breaking change in existing TypeScript/JavaScript code
    • When interval types are not used, TS behaves the same
  • 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.
    • Subject for discussion

🔍 Search Terms / Keywords

  • interval type
  • interval primitives, boundaries
@nikeee
Copy link
Contributor Author

nikeee commented Apr 2, 2021

I wanted to get into the TS compiler, so I've implemented a simple prototype of this type system extension. You can play around with it here:
https://nikeee.github.io/typescript-intervals

These features are not implement / have bugs:

  • Discriminated unions (see above for reason why)
  • Enum support
  • Generics
  • else branch sometimes does not receive the correct flow type

@DanielRosenwasser DanielRosenwasser added In Discussion Not yet reached consensus Suggestion An idea for TypeScript labels Apr 2, 2021
@captain-kark
Copy link

captain-kark commented Apr 5, 2021

Some other things to explicitly disregard as goals, but may help steer design and discussion:

  • positive vs. negative positional vectors
  • periodic vs. aperiodic series
  • convergent vs. divergent series

These features, if they existed, would allow for compile-time detection of changes to a value in a "direction" that is not supported. For instance, a model of a closed system with some variable representing a range of values for Entropy. Not only is it (>=0) & (<=1), it must always trend in one direction only.

From there, a periodic revolution allows for a "reversing" of the positional vector, but only if they exceed the threshold of the upper limit, and immediately returns to the lower limit. Support for this is not obvious to me, and would be difficult.

The third point is similar in that it's not obvious, but in my opinion would be even more difficult to do correctly. Given a positional vector and a limit, how can brief violations of this constraint be tolerated in order to facilitate convergent series for transcendental computed values, e.g., the approximation of φ from the Fibonacci series?

@RubenVerg
Copy link

I'm not sure I can just do this, but I'd like to suggest the % type where x extends (% y) (assuming both x and y are number literals) iff x % y == 0. This would effectively make type integer = (% 1) possible, as well. Interaction with <, > etc should probably be left untouched except maybe (> 0) & (< 5) & (% 3) should become 3, and (> 0) & (< 2) & (% 3) becomes never

Might've also been proposed already, but I couldn't find anything

@Stevemoretz
Copy link

So did it ever get implemeneted?

@Shinigami92
Copy link

Regarding to the roadmap, no.

@nikeee
Copy link
Contributor Author

nikeee commented Apr 22, 2021

In its current state, it's just a proposal that is subject for discussion. So feel free to post some questions, use-cases and enhancements! :)

@benrbray
Copy link

This would be nifty! I ran into a use case while building a Markdown parser, that shows some interaction with template literal types as well. Here's a very simplified version:

type HeadingLevel = 1|2|3|4|5|6;

function makeHeading(level: HeadingLevel): HTMLHeadingElement {
	// already correctly inferred as a HTMLHeadingElement
	return document.createElement(`h${level}` as const);
}

function getHeadingLevel(text: string): HeadingLevel|null {
	let match = /(#{1,6}) (\w+)/.exec(text);
	if(match === null) { return null; }

	let pounds: string = match[1];
	// would be nice if this were inferred to be (>=0)
	let level = pounds.length;
	
	// interval types could help to avoid this cast!
	if(level > 0 && level <= 6) { return level as HeadingLevel; }

	return null;
}

let n = getHeadingLevel("#### heading");
if(n !== null) { makeHeading(n); }

@tamias
Copy link

tamias commented Jun 4, 2021

Here's my use case. I want to define a union type for an API result, something like this:

type ApiResult<T> =
  | {
      total_results: 0;
    }
  | {
      total_results: 1;
      data: T;
    }
  | {
      total_results: number; // integer >= 2
      data: T[];
    };

so that I can later narrow the type by checking the value of total_results, e.g.:

let apiResult: ApiResult<string>;

if (apiResult.total_results > 1) {
  // apiResult.data is string[]
} else if (apiResult.total_results === 1) {
  // apiResult.data is string
} else {
  // apiResult.data is not present
}

@nikeee
Copy link
Contributor Author

nikeee commented Jun 18, 2021

Maybe another wording could be something like "Inequality Types" originating from inequalities

@nikeee nikeee changed the title Proposal: Interval Types Proposal: Interval Types / Inequality Types Jun 18, 2021
@eczn
Copy link

eczn commented Jul 15, 2021

is it inferable in extends clause ?

type MinRange<I extends number> = I extends (>= infer T) ? T : never;
type T0 = MinRange<(>= 100)>; // T0 will be 100

@nikeee
Copy link
Contributor Author

nikeee commented Jul 15, 2021

That should be possible aswell (when generic support is implemented, the playground does not support that).

@eczn
Copy link

eczn commented Jul 16, 2021

can it works well with template type ?

type Hex = (>=0) & (<= 255)
type HexStr = `0x${Hex}`

@eczn
Copy link

eczn commented Jul 16, 2021

can control flow analysis be able to infer the final types in an addition between two vars typed with Inequality Types ?

function add(a: (>= 1), b: (>= 2)) {
    return a + b;
}
type T0 = ReturnType<typeof add>; // T0 should be infered to (>= 3) by tsc

@RubenVerg
Copy link

that would probably be considered type level arithmetic so no

@nikeee
Copy link
Contributor Author

nikeee commented Jul 16, 2021

can it works well with template type ?

type Hex = (>=0) & (<= 255)
type HexStr = `0x${Hex}`

I don't think that this would work as expected, since numbers are floats in the end and 0x0.30000000001 would be valid as well.

@eczn
Copy link

eczn commented Aug 24, 2021

how can i use Interval Types to create an integer range ?

type MyRange1 = 1 | 2 | 3 | 4 | 5;
type MyRange2 = (>=1) & (<=5); // how to get 1 | 2 | 3 | 4 | 5 from Interval Types
// or infinite integer range
type MyRange3 = (>=1); // how to get integer range more than 1 ?
type Int = (>= 0) | (<= 0); // whole integer set

@Shinigami92
Copy link

type MyRange2 = (>=1) & (<=5);

Why would something like that deny e.g. const x: MyRange2 = 2.54;?

@nikeee
Copy link
Contributor Author

nikeee commented Aug 24, 2021

This proposal does not define any kind of integer semantics and it's currently not the goal. Integer types which have number as a base type seem to be out-of-scope.

However, if interval types are a thing, they could also be implemented for bigints (this proposal currently only defines them with number as a base-type), just like number/bigint literal types.
In fact, they would be much simpler and more consistent using bigint as a base type, because there is no NaN.

One thing that would also be possible is that iff some kind of int exists, we could do stuff like (>0) & (<10) & int, which would be equivalent to 0 | ... | 10. But as int types aren't a thing, this likely won't be possible.

@daiplusplus
Copy link

daiplusplus commented Sep 10, 2021

Rather than introduce a new syntax to express an interval-type with the existing type T = declaration, couldn't a more general-solution, that uses arbitrary expressions or a single-parameter pure-function to express the constraints on the type, be used? This way:

  • No new syntax needs to be introduced and supported by the compiler .
  • The exact same code in TypeScript's internals that are used to detect type-narrowing can be re-used to define type-narrowing.
  • The end-result is a general precondition/postcondition checker, which is basically the same thing as Code Contracts.
  • The same (pure) functions to define the narrowed-type can be used as type-guards.

So rather than this:

type Probability = (>=0) & (<=1);

Have:

type Probability = number & isUnitInterval;
function isUnitInterval( value: number ): boolean // or `value is Probability`
    return 0 <= value && value <= 1;
}

Note that isUnitInterval must be a boolean-returning pure function (no global/closure/static state, except literal constants and other pure-functions) that accepts a single argument value. The type of the argument doesn't need to be unknown provided the rest of the type definition is compatible with the parameter-type (in this case the number & part allows isUnitInterval to have a number-typed parameter.

A succinct syntax could exist by allowing the type statement itself to define a function (with a bool expression as the body):

type Probability(value) = typeof value === 'number' & 0 <= value && value <= 1;

But this would work not just for ranges/intervals, but any constraint that can be expressed as a pure function, for example:

type IsE164NanpPhoneNumber(value) = typeof value === 'string' && value.length == 11 && value[0] == '+' && value[4] !== '1' && Array.from(value.substring(1)).filter(ch => ch >= '0' && ch <= '9').length == 10;

...though I'm aware of the difficulty of having the type-narrower be able to use the Array.from sub-expression - but I'm hoping all of ES' built-in functions would be annotated to allow this to work.


So rather than handle ranges and intervals as a special-case in the type-system (as TypeScript currently handles equality, as far as I know), this approach should be inherently more general - at the cost of making the flow-analysis keep track of every potentially type/value-narrowing operation though a function (though at least this way the in operator would actually be useful).

In the case of the cascading if statements, the flow-analysis logic would be similar to what's suggested, except it doesn't absolutely require that ranges/intervals be narrowed, instead each extra condition simply appends a new intersection to the subject variable's type, which can then be compared to the constraints imposed by the pure function - which, if my CS education is correct, can be reduced to a boolean satisfiability problem.

@extremeheat
Copy link

extremeheat commented Sep 17, 2021

Alternative syntax would be to do it Python style or via set-interval notation:

type radix = 2 <= i32 <= 36

It would read as "type radix is an i32 between 2 and 36".

For bounds to +/- Infinity, they can be implied and omitted:

type radix = i32 >= 2
type radix = i32 <= 2

(using assemblyscript types above)

@AndrewOttavianoAbb
Copy link

As a react dev, I'd really appreciate this. My current use case is allowing the user to specify the width of a cell in a css grid which has a max of 12 columns (at least in the framework I'm using). With your proposed syntax, I think it would be something like this:

interface CellProps {
  content: React.ReactNode;
  width: (>0) | (<=12);
}

const CellThing = ({ content, width }: CellProps) => ....

I'd also suggest some syntax sugar for ranges like other languages. Something like: (0..12)

@jjhiggz
Copy link

jjhiggz commented Jun 7, 2022

This would be awesome! A few use cases I could think of

type Key = NestedKeyOf<SomeObject, Depth> // no more infinite recursion warning or hard coding this

type ProcessPaymentAction = {
     type: "process-small-payment",
     payload: (<100)
} | {
    type: "process-large-payment",
    payload: (>=100)
}

type MatrixIndex<Matrix extends any[][] as const> = 
     [
       ( <Matrix["length"] ),
       ( <Matrix[number]["length"] )
     ] // I don't know if this is possible but eventually would be possibly?

@derekstavis
Copy link

derekstavis commented Jun 29, 2022

Very interesting proposal. My use case is that I have an object that represents a device memory. The memory addresses goes from 0 to 0xFFFF (65535), and the values are 16 bit numbers that can go from 0 to 0xFFFF (65535). I would love to be able to declare the type like this:

type Memory = { [key: (>= 0) & (<= 0xFFFF)]: (>= 0) & (<= 0xFFFF) }

instead of having to type every number, and likely forget one, causing an error for the future myself.

@MaxGraey
Copy link

MaxGraey commented Jun 29, 2022

Well, this good proposal which propose to finally complete Sigma-type (Σ type) and Π type from Dependent Types theory (TypeScript supports a limited form where you can only make enumerations without constrains).

@sebinsua
Copy link

sebinsua commented Oct 21, 2022

One thing I'd like to request is that conditional types be improved with regards to number literals, number intervals and numbers.

e.g.

type Test1 = 5 extends NumberInterval<0, 10> ? true : false; // -> true
type Test2 = NumberInterval<0, 10> extends number ? true : false; // -> true
type Test3 = number extends NumberInterval<-Infinity, Infinity> ? true : false; // -> false
type Test4 = (typeof NaN) extends number ? true : false; // true
type Test5 = (typeof NaN) extends NumberInterval<-Infinity, Infinity> ? true : false; // false

The benefit would be that we could discern between a number literal and a number when writing a conditional type and treat them differently.

@hyoretsu
Copy link

hyoretsu commented Nov 1, 2022

Imo creating a generic type is better than simply throwing a >= between parenthesis. Like this answer which supposedly only works for small numbers.

@nikeee
Copy link
Contributor Author

nikeee commented Nov 1, 2022

Yeah, maybe an intrinsic implementation for generic types like GreaterThan<T extends number | bigint> would be more DX-friendly. That also wouldn't require an extension to the grammar.

@klavs
Copy link

klavs commented Nov 3, 2022

I support the proposal. It can be implemented via intrinsic types or via a new syntax. This playground shows how the intrinsic generic types could behave by outputting the constrained types as strings for manual evaluation.
Instead of limiting the syntax to (> 0) I propose (number > 0). I am not sure about the full syntax implications, but it might look something like this in real world:

type Fill = {
  value: string
  repeat: number > 0
};

It would make this feature more open for extension, like putting any union of number literals or constrained number types on the left of the operator while the union on the right side is a bit meaningless, but still possible and still yields the expected result if the intrinsic generic types are distributive.

type PositiveNumbers = GreaterThan<number, 0>;// `(${number} > 0)`
type NegativeNumbers = LessThan<number, 0>; // `(${number} < 0)`

type Probability = GreaterThanOrEqual<number, 0> & LessThanOrEqual<number, 1>;
// resolves to `(${number} >= 0)` & `(${number} <= 1)`


// Subject for discussion: Handling of (>1|2|3)
type SubjectForDiscussion = GreaterThan<number, 1 | 2 | 3>;
// resolves to `(${number} > 1)` | `(${number} > 2)` | `(${number} > 3)`
// would be reduced to `(${number} > 1)`


// (>10) | (>20)
type Reduction1 = GreaterThan<number, 10> | GreaterThan<number, 20>;
// resolves to `(${number} > 10)` | `(${number} > 20)`
// would be reduced to `(${number} > 10)`

// The same, but rely on the distributive property
// (>10) | (>20)
type Reduction1_ = GreaterThan<number, 10 | 20>
// distributes to `(${number} > 10)` | `(${number} > 20)`
// would be reduced to `(${number} > 10)`


// (>10) & (>20)
type Reduction2 = GreaterThan<number, 10> & GreaterThan<number, 20>;
// resolves to `(${number} > 10)` & `(${number} > 20)`
// would be reduced to `(${number} > 20)`

// The same, but rely on the distributive property
// (>10) & (>20)
type Reduction2_ = GreaterThan<number, 10 | 20>;
// distributes to `(${number} > 10)` & `(${number} > 20)`
// would be reduced to `(${number} > 20)`


//
// Now apply the same operation on subsets of number
//
type Stars = 0 | 1 | 2 | 3 | 4 | 5;

type PositiveReviews = GreaterThan<Stars, 2>;
// distributes to
//   "(0 > 2)" | "(1 > 2)" | "(2 > 2)" | "(3 > 2)" | "(4 > 2)" | "(5 > 2)" 
//   "(never)" | "(never)" | "(never)" | "(3)"     | "(4)"     | "(5)    "
//                                     
//   3 | 4 | 5 

type PositiveButNotExcellentReviews = LessThan<PositiveReviews, 5>;
// distributes to
//   "((0 > 2) < 5)" | "((1 > 2) < 5)" | "((2 > 2) < 5)" | "((3 > 2) < 5)" | "((4 > 2) < 5)" | "((5 > 2) < 5)"
//   "((never) < 5)" | "((never) < 5)" | "((never) < 5)" | "(3 < 5)      " | "(4 < 5)      " | "(4 < 5)      "
//   "(never)      " | "(never)      " | "(never)      " | "(3)          " | "(4)          " | "(never)      "
//                                     
//   3 | 4

@sebinsua
Copy link

sebinsua commented May 2, 2023

If you were just wanting to detect whether a value was a number or a numeric literal (e.g. 4), then this will works:

type IsNumericLiteral<T> = number extends T ? false : T extends number ? true : false;

type T = IsNumericLiteral<5>; // true
type U = IsNumericLiteral<number>; // false

You might also want to detect whether you are creating a union of types, too, which can be done like so:

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I
) => void
  ? I
  : never;
type IsUnion<T> = [T] extends [UnionToIntersection<T>] ? false : true;
type Not<A> = A extends true ? false : true;

type IsNotUnion<T> = Not<IsUnion<T>>;

type T = IsNotUnion<5>; // true
type U = IsNotUnion<5 | 3>; // false

@Gusarovv
Copy link

As a temporary solution, you can use the following type:

type NumericRange<
  start extends number,
  end extends number,
  arr extends unknown[] = [],
  acc extends number = never,
> = arr['length'] extends end
  ? acc | start | end
  : NumericRange<start, end, [...arr, 1], arr[start] extends undefined ? acc : acc | arr['length']>;
  
type MyRange = NumericRange<1, 50>;
  

@goastler
Copy link

goastler commented Sep 4, 2023

I support this proposal too.

It's a much needed feature when noUncheckedIndexedAccess tsconfig compiler option is turned on. Arrays constantly need undefined checks otherwise, like so:

const a = [1,2,3]
if(a[1] === undefined) {
	throw new Error('undefined')
}
const b: number = a[10] // ts can infer that there's been an undefined check, so the type of b can only be number now

It would be nice to have an method of guaranteeing the length and that the elements are therefore defined. Something like this type:

type Len<T extends readonly any[], L extends number> = Readonly<Pick<Required<T>, 0..L>>

The 0..L is where I need an interval or range type. I can use a union at the moment, but this is a very finite solution (I don't want to be typing out a union with 10's of thousands of entries) and inflexible. I can use a recursive type to build the union type automatically, but these fall down after ~3000 entries on my machine (due to stack overflow of recursion).

@vtgn
Copy link

vtgn commented May 7, 2024

As a temporary solution, you can use the following type:

type NumericRange<
  start extends number,
  end extends number,
  arr extends unknown[] = [],
  acc extends number = never,
> = arr['length'] extends end
  ? acc | start | end
  : NumericRange<start, end, [...arr, 1], arr[start] extends undefined ? acc : acc | arr['length']>;
  
type MyRange = NumericRange<1, 50>;
  

Very nice !! That's exactly what I need, but there is a problem with negative values :/ :

  • if start = -a and end > 0, there is no values in the generated type for the interval ]-a; 0].
  • if start = -a and end = -b with -a < -b, there is an error

I will see if it can be fixed.

@FrameMuse
Copy link

FrameMuse commented Sep 17, 2024

My use-case is simple - User roles. The names are ambiguated

export enum UserRole {
  Guest = -1, CompanyManager, CompanyOwner, SuperManager, SuperOwner
}

export interface GeneralUser {
  id: string
  email: string
  firstName: string
  lastName: string
  avatar: string
}

export interface SuperUser extends GeneralUser {
  role: UserRole.SuperOwner | UserRole.SuperManager
  companyId?: string
  signed: true
}

export interface CompanyUser extends GeneralUser {
  role: UserRole.CompanyOwner | UserRole.CompanyManager
  companyId: string
  signed: true
}

export interface GuestUser extends GeneralUser {
  role: UserRole.Guest
  signed: false
}

export type User = SuperUser | CompanyUser | GuestUser

And currently I'm receiving error when I try to use User type by narrowing it with <= comparison (it only works with ===):

	if (user.role === UserRole.Guest) return
    if (user.role <= UserRole.CompanyOwner) {
      // `CompanyUser` cast is used since TS doesn't handle `<=` properly.
      MyScript.set("COMPANY_ID", { companyId: (user as CompanyUser).companyId })
    }

So I'm putting the comment above to signalize it, I will also reference this issue, so maybe someone will refactor it in future 😘


BTW, I really like this proposal, I think it will be very handy.

@vtgn
Copy link

vtgn commented Sep 17, 2024

@FrameMuse
I suppose your user variable is typed by User.
So inside the if block, user type is CompanyUser | GuestUser, but companyId doesn't exist for the GuestUser type, so this is normal to have a type error.
You can see this error by adding this line inside the if block: const companyId: string = user.companyId;
You have the error :
Property 'companyId' does not exist on type 'User'. Property 'companyId' does not exist on type 'GuestUser'.

The problem is not the Typescript parser, but your code. And this is wrong to cast user as CompanyUser, because it could be a GuestUser for which the companyId property doesn't exist.

Regards.

@FrameMuse
Copy link

FrameMuse commented Sep 17, 2024

@vtgn

@FrameMuse I suppose your user variable is typed by User. So inside the if block, user type is CompanyUser | GuestUser, but companyId doesn't exist for the GuestUser type, so this is normal to have a type error

I also noticed that in the code so I introduced another if, but it's not narrowing properly anyway. Thanks for the notice.

@vtgn
Copy link

vtgn commented Sep 17, 2024

@vtgn

@FrameMuse I suppose your user variable is typed by User. So inside the if block, user type is CompanyUser | GuestUser, but companyId doesn't exist for the GuestUser type, so this is normal to have a type error

I also noticed that in the code so I introduced another if, but it's not narrowing properly anyway. Thanks for the notice.

Indeed, it is strange...
If you replace your if expression by this one:
user.role !== UserRole.Guest && user.role <= UserRole.CompanyOwner)

user is now seen as SuperUser | CompanyUser inside the if block, which is wrong because SuperUser should have been excluded by the second criterium of the if expression. =/

But you should not report this bug here, because this issue is about to add new types definition operators, not the types resolution implementation. Watch if an existing issue talks about your bug, otherwise create a new one.

Regards.

@FrameMuse
Copy link

FrameMuse commented Sep 18, 2024

@vtgn

But you should not report this bug here, because this issue is about to add new types definition operators, not the types resolution implementation. Watch if an existing issue talks about your bug, otherwise create a new one.

I couldn't find one and now I'm too lazy to create a new one, so I leave it here... Thanks anyway

@vtgn
Copy link

vtgn commented Sep 18, 2024

@vtgn

But you should not report this bug here, because this issue is about to add new types definition operators, not the types resolution implementation. Watch if an existing issue talks about your bug, otherwise create a new one.

I couldn't find one and now I'm too lazy to create a new one, so I leave it here... Thanks anyway

You can be sure it will be ignored here... -_-°...

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