There is no subtype of number
in TypeScript which corresponds exactly to those values for which the following function returns true
:
function validMode(mode: number): boolean {
return mode > 0 && mode <= 5;
}
console.log(validMode(1), validMode(5),
validMode(2.5), validMode(Math.PI)); // true, true, true, true
console.log(validMode(0), validMode(6),
validMode(-1), validMode(Infinity)); // false, false, false, false
Note that numbers like 2.5
and Math.PI
are accepted, since values in JavaScript for which typeof mode === "number"
are double precision floating point numbers and are not confined to integers.
In order to support such a type, TypeScript would likely need one or both of the following missing features:
refinement types, where you can place conditions on a type in the form of a predicate; there is a feature request for this at microsoft/TypeScript#7599, but it doesn't look like there's any work being done there.
mathematical operations on numeric literal types, where you can actually write the predicate like ValidMode > 0
and the compiler will understand that this corresponds to either true
or false
depending on the literal type of ValidMode
. There is a feature request for this at microsoft/TypeScript#26382, but again, I don't see any obvious work being done to support this.
So it's not possible in general.
Of course the original mode
type you wrote would not accept arbitrary numbers between 1
and 5
, but only integer values. Something more like this:
function validMode(mode: number): boolean {
return Number.isInteger(mode) && mode > 0 && mode <= 5;
}
console.log(validMode(1), validMode(5)); // true, true
console.log(validMode(2.5), validMode(Math.PI)); // false, false
And, as you know, such a type can be represented, but only as an explicit union of a finite number of numeric literals:
type ValidMode = 1 | 2 | 3 | 4 | 5;
Practically speaking if a union has 25 or fewer members it will behave reasonably; if it has more than 25 but less than, say, 10,000 members, it will behave oddly in some circumstances (see microsoft/TypeScript#40803 for example), and if it has more than 10,000 members you are going to get close to or pass some hard limitations on union size (see this error message).
Missing from TypeScript is the concept of a "range type" where one could easily write
type ValidMode = Range<1, 5>; // or would that be Range<1, 6>? Inclusive/exclusive ♂️
There is an open feature request for this at microsoft/TypeScript#15480. There are workarounds involving recursion or generics and even template literal types, but none of them are both understandable and well-behaved. If you look at the linked issue you can find some recursive implementations that result in unions, which won't work if you want Range<0, 1.0e+6>
or even Range<0, 50>
. And using template literal types to parse strings is not something I'd suggest doing for anything but entertainment purposes.
So for now, this is also not possible.
A different approach entirely is to accept that the compiler cannot both easily and accurately represent your type. Instead, we would like to create a branded subtype of number
called ValidMode
. We can write a type guard function to perform a runtime check on its number
input, and decide whether or not that input is a ValidMode
. If so, we "brand" the number with a phantom property that marks it as valid. If not, we leave it alone. Then later you can write functions that only accept ValidMode
and the compiler will enforce this:
type ValidMode = number & { __validMode: true };
function validMode(mode: number): mode is ValidMode {
return Number.isInteger(mode) && mode > 0 && mode <= 5;
}
function onlyAcceptValidModes(mode: ValidMode) {
console.log(mode.toFixed(2));
}
This makes it a bit harder to use ValidMode
, since the compiler cannot verify that a numeric literal like 4
matches it:
onlyAcceptValidModes(4); // error, number is not assignable to ValidMode
Instead, you will need to perform the runtime validation test to get your value stamped with approval:
const someNumber = Math.random() < 0.5 ? 4 : 8;
onlyAcceptValidModes(someNumber); // error again
if (validMode(someNumber)) {
onlyAcceptValidModes(someNumber) // okay
}
In this way you can simulate refinement types in TypeScript. Since the test only exists at runtime it can be as complicated as you'd like.
Playground link to code