Advanced TypeScript: Number Typing Tricks
TypeScript's type system is notoriously powerful and even Turing complete, in fact. While most developers use it to shape basic data, you can push it to even run DOOM, purely at the type level.
In this post, we'll dive into advanced number typing tricks. We'll start with practical, everyday techniques like branded types and string literals, and then descend deeper into performing actual arithmetic purely at the type level.
Level 1: Practical Bounds and Formats
Before we get into recursive type math, let's look at lightweight techniques for enforcing constraints on numbers.
Nominal Typing with Branded Types
TypeScript's type system is structural, meaning any number is compatible with any other number. However, sometimes you want strict boundaries for example, preventing a USD amount from being passed to a function expecting EUR.
We can achieve this using "branded" (or opaque) types:
type Brand<K, T> = K & { readonly __brand: T };
type USD = Brand<number, "USD">;
type EUR = Brand<number, "EUR">;
const dollars = 100 as USD;
const euros = 100 as EUR;
function processPayment(amount: USD) { /* ... */ }
processPayment(dollars); // OK
processPayment(euros); // Type error!Enforcing Min/Max Bounds: You can extend this concept to enforce explicit runtime boundaries:
type Min<N extends number> = { readonly __min: N };
type Max<N extends number> = { readonly __max: N };
// A number guaranteed to be between 0 and 100
type Percentage = number & Min<0> & Max<100>;
function assertPercentage(value: number): asserts value is Percentage {
if (value < 0 || value > 100) throw new Error("Out of bounds");
}
let volume = 75;
assertPercentage(volume);
// volume is now correctly narrowed to `Percentage`!This trick adds zero runtime overhead because the __brand, __min, and __max properties only exist at the type level.
Format Validation with Template Literals
Template literal types allow you to manipulate numbers at the string level. This is incredibly useful for validating formats or combining numbers with units.
type PixelValue = `${number}px`;
const margin: PixelValue = "16px"; // OK
const invalidMargin: PixelValue = 16; // Type errorYou can even extract numbers from strings using infer:
type ExtractNumber<T extends string> = T extends `${infer N extends number}px` ? N : never;
type MarginValue = ExtractNumber<"24px">; // 24Enforcing Bounds with Strings: Template literals can also enforce numeric constraints purely via string pattern matching. This is perfect for structural limits like single-byte integers:
type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
// Accepts only numbers from 0 to 99 (1 or 2 digits)
type Max99<T extends number> = `${T}` extends `${Digit}${Digit}` | `${Digit}` ? T : never;
const okVolume: Max99<85> = 85; // OK
const loudVolume: Max99<100> = 100; // Type ErrorLevel 2: Type-Level Math
Now, let's step into the wild side. TypeScript doesn't have native type-level arithmetic operators like + or -. Instead, we rely on the length of tuple types. By constructing tuples of specific lengths, we can represent numbers and perform operations.
The Foundation: Tuples as Numbers
First, we need a helper to generate a tuple of a specific length:
type BuildTuple<L extends number, T extends any[] = []> =
T['length'] extends L ? T : BuildTuple<L, [...T, any]>;
type Three = BuildTuple<3>; // [any, any, any]
type Length = Three['length']; // 3Addition and Subtraction
With our BuildTuple helper, we can implement basic arithmetic. To add two numbers, we build two tuples, concatenate them, and extract the resulting length.
type Add<A extends number, B extends number> =
[...BuildTuple<A>, ...BuildTuple<B>]['length'];
type Sum = Add<3, 5>; // 8Subtraction uses infer to match the subtrahend within the minuend, extracting the remaining elements.
type Subtract<A extends number, B extends number> =
BuildTuple<A> extends [...BuildTuple<B>, ...infer Rest]
? Rest['length']
: never;
type Difference = Subtract<10, 4>; // 6[!NOTE] These operations only work for non-negative integers. Dealing with negative numbers or floats requires significantly more complex type structures.
Comparisons: Min and Max
To implement min() and max(), we compare two numbers by building a tuple element by element. Whichever target number is reached first is the smaller one.
type LessThanOrEqual<A extends number, B extends number, T extends any[] = []> =
T['length'] extends A ? true :
T['length'] extends B ? false :
LessThanOrEqual<A, B, [...T, any]>;Now, Min and Max are straightforward ternary logic:
type Min<A extends number, B extends number> =
LessThanOrEqual<A, B> extends true ? A : B;
type Max<A extends number, B extends number> =
LessThanOrEqual<A, B> extends true ? B : A;
type Minimum = Min<15, 42>; // 15Generating a Range
If you need a type that restricts a value to a specific range (e.g., 0 to 255 for RGB), we can create a Range type by accumulating numbers until we hit our target.
type Range<From extends number, To extends number, T extends any[] = BuildTuple<From>, Acc = never> =
T['length'] extends To
? Acc | To
: Range<From, To, [...T, any], Acc | T['length']>;
type RGB = Range<0, 255>;
const color: RGB = 128; // OK
const invalid: RGB = 300; // Type errorPerformance Considerations
While type-level computations are incredible, they come with caveats:
- Compilation Time: Heavy use of recursive types significantly slows down compilation.
- Recursion Limits: TypeScript imposes a hard limit on recursion depth (typically around 1000). Trying to compute
Add<900, 200>will result in a "Type instantiation is excessively deep and possibly infinite" error.
Conclusion
Type-level arithmetic showcases the sheer flexibility of TypeScript. While you might not need to perform subtraction in your types every day, understanding these techniques deepens your mental model of TypeScript's type system, preparing you for complex generic constraints and library development.