numtypes
v0.1.0
Published
Zero-runtime numeric type coercion symbols and a TypeScript transformer.
Maintainers
Readme
numtypes
numtypes is a zero-runtime TypeScript numeric-domain transformer.
User code imports a small set of phantom symbols, writes ordinary TypeScript with branded numeric types, and the transformer erases those symbols into the JavaScript coercion idioms engines already understand:
| Type | Runtime closure shape | Intended domain |
| --- | --- | --- |
| i32 | x \| 0, Math.imul(a, b) | signed int32 |
| u32 | x >>> 0, Math.imul(a, b) >>> 0 | unsigned int32 |
| f32 | Math.fround(x) | float32 value rounded back to JS number |
| f64 | +x | JavaScript binary64 number |
The imported functions are compile-time markers, not runtime utilities. A
successful build should not keep calls to i32, u32, f32, or f64 in the
emitted JavaScript unless you intentionally preserve phantom imports for
debugging.
Why This Exists
JavaScript exposes one ordinary numeric value type, number, but engines have
many internal numeric representations. Hot code may be optimized as small
integers, int32, uint32, float64, or sometimes float32 when the source shape is
clear enough. Historically, hand-written JavaScript used coercion idioms such as
| 0, >>> 0, Math.imul, and Math.fround to make those shapes visible to
JIT compilers.
numtypes moves that idiom into TypeScript:
- TypeScript gets branded domain names (
i32,u32,f32,f64). - The transformer inserts the required coercions at arithmetic and storage boundaries.
- Runtime JavaScript stays dependency-free.
- The generated code is benchmarked against plain JavaScript on current engines.
This is not a replacement for WebAssembly or SIMD. It is a source-to-source lowering pass for JavaScript projects that want explicit numeric domains without manually writing coercions after every operation.
Status
The project is still early. The transformer is the main artifact; the public runtime package exists mostly so TypeScript source can name the marker symbols.
Expect sharp semantics:
- arithmetic over branded values must stay inside a branded domain, or escape
explicitly with
as number,as unknown, oras any; - mixed domains such as
i32 + f32are rejected unless the user casts one side; as i32is unchecked and emits no coercion by itself;i32(x)is checked and emitsx | 0;- declaration output can preserve branded types or erase them to
number.
See docs/transform-spec.md for the full transform rules and docs/lowering-optimization-spec.md for the coercion optimization pass.
Install
npm install numtypes
npm install --save-dev typescript ts-patchnumtypes has a peer dependency on TypeScript. It also requires a build
pipeline that can install TypeScript custom transformers. The recommended path
is ts-patch, configured through compilerOptions.plugins and run with tspc.
The stock tsc CLI does not load third-party transformers from tsconfig.json
unless TypeScript has been patched.
Quick Example
import { i32 } from "numtypes";
export function hashStep(hash: i32, byte: i32): i32 {
return i32((hash ^ byte) * 0x01000193);
}The source type checks as branded TypeScript. The emitted JavaScript is ordinary number code:
export function hashStep(hash, byte) {
return Math.imul(hash ^ byte, 0x01000193);
}The package import disappears because the marker call has been consumed by the transformer.
Checked Casts And Unchecked Assertions
The call form performs the coercion:
import { i32 } from "numtypes";
const checked = i32(0.9); // emits 0.9 | 0The TypeScript assertion form is an unchecked promise:
import type { i32 } from "numtypes";
const unchecked = 0.9 as i32; // emits 0.9Assertions are useful at trusted boundaries, but they do not retroactively coerce the asserted expression. If a checked runtime conversion is required, use the function marker.
Transformer Usage
numtypes is intended to be used through
ts-patch. ts-patch lets TypeScript
load custom transformers from tsconfig.json and run them through a patched
compiler command.
Install ts-patch next to TypeScript:
npm install --save-dev ts-patch typescriptts-patch v4 targets TypeScript 6 and later. If your project is still on
TypeScript 5, use ts-patch v3.
Add the transformer to tsconfig.json:
{
"compilerOptions": {
"plugins": [
{
"transform": "numtypes/transformer"
}
]
}
}Then compile with tspc instead of tsc:
npx tspc -p tsconfig.jsonYou can also install a persistent patch and keep using tsc:
npx ts-patch install
npx tsc -p tsconfig.jsonFor declaration emit, the default is to preserve branded declaration types:
export declare function read(value: i32): f32;If you want generated .d.ts files to expose only the runtime JavaScript
contract, add the declaration transformer as an afterDeclarations plugin:
{
"compilerOptions": {
"declaration": true,
"plugins": [
{
"transform": "numtypes/transformer"
},
{
"transform": "numtypes/transformer",
"import": "createDeclarationTransformer",
"afterDeclarations": true,
"declarationTypes": "erase"
}
]
}
}That declaration plugin rewrites branded declaration positions to number and
removes declaration-only numtypes imports that become unused.
Options
| Option | Default | Meaning |
| --- | --- | --- |
| declarationTypes | "preserve" | Preserve i32/u32/f32/f64 in .d.ts, or erase them to number when passed to the createDeclarationTransformer afterDeclarations plugin. |
| collectDiagnostics | true | Emit transformer diagnostics for domain leaks, mixed domains, ambiguous unions, and marker misuse. |
| preservePhantomImports | false | Keep otherwise-erased imports from numtypes; mainly useful for debugging transformer output. |
| optimizeTypedArrayElementAccess | false | Allow proof-based removal of generated coercions around numeric TypedArray element reads when loop bounds prove in-bounds access. |
optimizeTypedArrayElementAccess is off by default because it trusts runtime
objects to match their TypeScript annotations. If a value is typed as
Int32Array, the optimized region assumes it is actually an intrinsic
Int32Array, not a monkey-patched or structurally similar object.
Declaration Surface
By default, declarations preserve branded APIs:
export declare function read(value: i32): f32;With declarationTypes: "erase", declaration emit exposes the runtime contract:
export declare function read(value: number): number;Use "preserve" for libraries that want downstream TypeScript users to keep the
same branded constraints. Use "erase" for libraries that treat numtypes as a
private implementation detail and publish a plain JavaScript numeric API.
Domain Guidance
The practical takeaway from current benchmarks is conservative:
| Domain | Recommendation |
| --- | --- |
| i32 | Useful for real performance-sensitive integer code, especially TypedArray-heavy kernels and multiply-heavy loops. Also useful for correctness when 32-bit overflow is intended. |
| u32 | Useful for unsigned range semantics and hashing/bitwise correctness. Do not assume a broad speedup over good hand-written JavaScript. |
| f32 | Useful as a precision contract. On current V8 it is slower; on SpiderMonkey the same shape can keep near parity when the engine lowers the pattern to float32. |
| f64 | Useful mostly as a proof and documentation domain. JavaScript already uses binary64 number; extra unary + coercions rarely improve performance and can be neutral or slightly negative. |
In short: i32 is the only domain that currently looks broadly practical as a
performance tool. u32, f32, and f64 are still useful when the value domain
itself matters, but they should be treated as semantic constraints first and
performance hints second.
Benchmark Methodology
Benchmarks are opt-in:
npm run bench
npm run bench:mdThe algebra benchmarks compile a source string containing both:
- a plain JavaScript
numberbaseline; and - a numtypes-authored implementation.
The numtypes implementation is lowered by the real transformer at benchmark load time. The benchmark therefore measures actual transformer output, including the coercion optimization pass, not hand-written approximations.
Each comparison warms both implementations, alternates baseline/typed measurement order, discards the first measured sample rounds, and reports the median of the retained steady-state rounds. This matters because the first optimized runs after dynamic import can still include speculation, tiering, and recompilation outliers on V8.
Rel speed is:
baseline runtime / transformed runtimeHigher than 1.00x means the transformed version is faster. Lower than 1.00x
means the transformed version is slower.
Benchmark Snapshot
Measured on:
Node v24.4.1
V8 13.6.233.10-node.17
Windows x64
2026-06-21These numbers are not contractual. They are a snapshot of one engine, one CPU, one OS, and one set of hot-loop shapes.
f32: Precision Contract, Not a V8 Speedup
The f32 suite compares transformed Math.fround kernels against plain f64
number kernels. Results differ by design because f32 has less precision.
| Benchmark | Rel speed | Precision | | --- | ---: | --- | | vec3 dot | 0.39x | ~8e-8 | | vec3 cross | 0.18x | ~8e-8 | | vec3 normalize | 0.54x | ~9e-8 | | mat4 x mat4 | 0.36x | ~1e-7 | | mat4 x vec4 | 0.15x | ~9e-8 | | quat multiply | 0.15x | ~8e-8 | | vec3 lerp | 0.18x | ~9e-8 | | ray-triangle | 0.17x | ~1e-5 |
Current V8 treats Math.fround as real rounding work in these scalar kernels.
That means f32 source can be 2x to 5x slower than the f64 baseline even though
the emitted shape is the standard JavaScript way to request binary32 rounding.
SpiderMonkey is different. Mozilla's IonMonkey work added an optimization pass
that recognizes Float32Array/Math.fround patterns and can emit float32
operations when the required identities hold. In a local scalar dot-product
probe, SpiderMonkey 153 kept a Float32Array + Math.fround variant around
parity with the f64 Float64Array baseline, while V8 13.6 did not.
The history matters here. This optimization came from the same period as
asm.js, Emscripten, and WebGL performance work, but it was not merely an
asm.js-only fast path. Mozilla's 2013 Float32 write-up describes the work as
general IonMonkey support for float32 operations, followed by an optimization
pass that recognizes Float32Array and Math.fround. The asm.js performance
article from the same period says the Float32 work was generic and applied to
any JavaScript that matched the optimizable shape; after that, SpiderMonkey and
Emscripten added float32 to the asm.js type system so asm.js code could benefit
from it specifically. In other words, SpiderMonkey's present advantage is best
understood as an asm.js-era optimization lineage that still benefits ordinary
JavaScript patterns, not as evidence that the active asm.js/OdinMonkey compiler
path is required for Math.fround to be fast.
This makes f32 engine-dependent. It is semantically valuable when you need
single-precision rounding. It is not currently a V8 performance optimization.
For V8, real f32 speedups are more likely to come from whole-kernel WebAssembly,
especially with SIMD, than from scalar Math.fround JavaScript.
f64: Mostly Neutral
The f64 suite adds explicit unary + proofs around ordinary JavaScript number
math. Since JavaScript number is already binary64, the optimizer usually has
little to gain.
| Benchmark | Rel speed | Precision | | --- | ---: | --- | | f64 dot4 | 1.00x | exact | | f64 dot4 typed-array | 1.01x | exact | | horner poly | 0.97x | exact | | f64 kahan sum | 1.01x | exact | | f64 welford | 1.01x | exact | | f64 biquad | 0.98x | exact | | f64 black-scholes | 0.99x | exact | | f64 newton sqrt | 1.00x | exact | | f64 scale-add | 1.04x | exact | | f64 recurrence | 1.01x | exact | | f64 RK4 logistic | 0.99x | exact |
The result is roughly parity. Some cases are slightly faster, some are slightly
slower, and the differences are small enough that they should not drive API
design. Use f64 when the type annotation helps the program express "this must
be numeric binary64-like data" and when explicit plain-number escapes are useful
for code review.
i32: The Most Useful Performance Domain
The i32 suite has three families:
- overflow-oriented kernels where int32 closure is part of the intended algorithm;
- bounded-number kernels where f64
numberarithmetic is already exact, but the source logically treats values as integers; - intentionally naive full-width multiply kernels where
(a * b) | 0is both semantically wrong and slow compared with transformer-generatedMath.imul.
| Benchmark | Rel speed | Precision | | --- | ---: | --- | | i32 dot4 imul | 1.01x | exact | | i32 dot8 imul | 1.02x | exact | | i32 fir4 | 1.01x | exact | | i32 LCG | 1.04x | exact | | i32 fixed IIR | 1.04x | exact | | i32 bresenham | 1.05x | exact | | i32 xorshift | 1.04x | exact | | i32 collatz | 1.05x | exact | | i32 affine mix | 1.03x | exact | | i32 horner mix | 0.95x | exact | | i32 bounded score | 0.90x | exact | | i32 bounded smooth | 1.76x | exact | | i32 bounded delta | 1.81x | exact | | i32 bounded state | 1.04x | exact | | i32 bounded poly | 0.85x | exact | | i32 bounded dither | 0.87x | exact |
i32 is not magic. Bounded small-number code where ordinary f64 arithmetic is already exact can still get slower, because int32 closure adds coercion work that the baseline does not need. After median-based measurement, those losses are much smaller than the first-run outliers, but they are real enough that hot paths still need per-engine benchmarking. The general picture is better than the other domains: multiply-heavy and TypedArray-heavy kernels often benefit, and overflow-oriented integer algorithms become easier to write correctly.
The strongest i32 appeal point is the last category. JavaScript code often tries
to express C-like signed 32-bit multiplication with (a * b) | 0. That is only
valid while the binary64 product still contains the low 32 bits exactly. For
full-width int32 values, the product can exceed 2^53, low bits are lost before
the final | 0, and the result is no longer equivalent to 32-bit wraparound
multiplication. In those cases, i32(a * b) is not just a hint; it is a compact
way to request the correct operation, and the transformer lowers it through
Math.imul.
// Not equivalent to int32 multiplication for full-width inputs.
const wrong = (a * b) | 0;
// Emits Math.imul(a, b), then preserves i32 closure for later operations.
const right = i32(a * b);| Benchmark | Rel speed | Precision | | --- | ---: | --- | | i32 naive LCG | 5.07x | naive wrong (100% differ) | | i32 naive fmix | 2.35x | naive wrong (100% differ) | | i32 naive FNV-1a | 4.50x | naive wrong (100% differ) |
This table is intentionally not a fair "same result, faster code" comparison. The baseline is wrong. The point is more practical: numtypes lets source code state the intended int32 domain once, then emits the operation shape engines already understand for full-width 32-bit multiplication.
The additional Math.imul probe is important here. On V8 TurboFan with
TypedArrays, Math.imul was much faster than * | 0 for variable multiply and
multiply-accumulate loops. On ordinary JS arrays with small integer values,
Math.imul was sometimes slower than * | 0. That means Math.imul is the
right conservative lowering for correctness and many hot loops, but a future
optimizer may want a range-proven escape hatch for small-value normal-array
code.
Focused V8 disassembly checks are useful for interpreting the slower i32 rows.
With --print-opt-code, Math.imul(x, y) lowers to the x64 imull
instruction in these hot loops. A hand-written (x * y) | 0 variant of the
same bounded poly kernel lowered to essentially the same integer multiply
sequence and was not faster.
The clearest slowdown is i32 bounded poly:
const x: i32 = i32(input[i] % 1024);
out[i] = i32((x * x + i32(3) * x + i32(7)) % i32(10007));The plain baseline is also optimized as integer code because V8 learns that
input[i] is a small non-negative value. Its hot path for % 10007 uses a
short unsigned-style magic-multiply sequence:
andl rdx,0x3ff
imull rsi,rdx
mull rcx
shrl rdx,12
imull rdx,rdx,0x2717
subl rsi,rdxThe i32-lowered version must preserve signed int32 closure and JavaScript %
semantics, so the remainder path carries extra sign correction:
andl rdx,0x3ff
imull rsi,rdx
imull rcx
shrl rax,31
sarl rdx,12
addl rdx,rax
imull rdx,rdx,0x2717
subl rsi,rdxThis is a range-analysis gap. The transformer knows the expression is i32, but
it does not yet prove that the value is non-negative and small enough for the
shorter unsigned remainder path. bounded score and bounded dither are
different: they do not contain %, and V8 also lowers their small multiplications
or additions to ordinary integer instructions. Their small losses are best
understood as code-shape and guard/register-allocation effects from explicit
closure in already-exact bounded arithmetic.
i32 horner mix is near parity when measured in isolation and is not the same
bounded-remainder issue.
u32: Correctness First
u32 is valuable for bitwise and hash code because unsigned closure matters. The performance story is mixed.
| Benchmark | Rel speed | Precision | | --- | ---: | --- | | u32 LCG | 0.99x | exact | | xorshift32 | 1.02x | exact | | xorshift*32 | 1.00x | exact | | mulberry32 correct | 0.99x | exact | | wang hash32 | 0.96x | exact | | murmur3 block | 0.95x | exact | | jenkins oat | 0.99x | exact | | CRC32 bitwise | 1.01x | exact | | Adler-32 | 1.01x | exact | | FNV-1a bytes | 1.02x | exact | | u32 bounded prefix | 1.01x | exact | | u32 bounded checksum | 1.10x | exact | | u32 bounded counter | 0.99x | exact | | u32 bounded pair hash | 1.23x | exact | | u32 bounded rgb pack | 0.99x | exact | | u32 bounded unpack sum | 2.74x | exact |
Some u32 cases are faster, especially when generated coercions are optimized
away in tight bounded loops. Other cases are neutral or slower. For real hashing
code, the more important point is often correctness: naive number
multiplication silently loses low bits when products exceed 2^53.
| Benchmark | Rel speed | Precision | | --- | ---: | --- | | mulberry32 | 2.21x | naive wrong (100% differ) | | murmur3 fmix | 2.10x | naive wrong (100% differ) |
The second table compares against intentionally naive baselines. It is not a
fair speed comparison because the baseline is wrong. It shows why Math.imul
exists: a plain * implementation can be fast or slow, but it is not equivalent
for full-width 32-bit integer multiplication.
Engine Notes
Math.imul
ECMAScript defines Math.imul(x, y) as 32-bit multiplication modulo 2^32,
returned as a signed int32. MDN describes it as C-like 32-bit integer
multiplication, and the TC39 integer-math proposal notes that engines can infer
some integer optimizations but that explicit source operations are useful when
inference is not practical.
For numtypes, this means i32(a * b) and u32(a * b) lower through
Math.imul when both operands are integer-domain values. Rewriting every
integer multiply to * would be incorrect for full 32-bit values.
Math.fround
ECMAScript defines Math.fround as binary64 input, binary32 conversion with
round-to-nearest ties-to-even, then binary64 output. The return value is still a
JavaScript number; the operation just forces single-precision rounding at that
point.
SpiderMonkey's historical float32 optimization is designed around this shape:
Float32Array values and Math.fround calls provide enough information for the
JIT to use float32 operations when doing so preserves the required rounded
result. V8 currently does not show the same scalar speedup in this benchmark
set, even when forced to TurboFan.
The code-history trail is:
- Bug 888109,
"IonMonkey: Introduce Float32 general optimization", added the general
Float32 optimization work in the IonMonkey backend. The bug description
explicitly targets lowering
Float32Arrayarithmetic from double load/convert/add/store sequences to float32 assembly instructions. - Efficient float32 arithmetic in JavaScript
describes the implementation sequence: add general float32 support to
IonMonkey, add
Math.fround, then add a recognition pass forFloat32Array/Math.froundpatterns. - Gap between asm.js and native performance gets even narrower with float32 optimizations connects that generic Float32 work back to asm.js and Emscripten: once the generic optimization existed, asm.js gained a float32 type path so compiled C/C++ float-heavy code could benefit.
- Saying goodbye to asm.js
says SpiderMonkey's asm.js optimizations are disabled by default as of
Firefox 148 and planned for removal. That makes it important not to describe
current
Math.froundbehavior as relying on the active asm.js compiler. The safer claim is that the optimization comes from the asm.js-era Float32 work and remains relevant to ordinary JavaScript shapes.
Unary +
Unary + proves "number-ness" at runtime. It can prevent accidental string
concatenation or object coercion from crossing into a numeric region, but it
does not create a narrower machine type than JavaScript's normal binary64
number. The optimizer can often remove redundant unary +, but the benchmark
results show that it should not be expected to produce a broad speedup.
Optimization Pass
The initial lowering pass is intentionally conservative and may emit more coercions than a human would write. A later optimization pass removes only coercions generated by numtypes, not coercions the user wrote by hand.
This generated-only rule matters. If the user writes:
const x = value | 0;the optimizer preserves it. If numtypes generated the same coercion as part of a closed expression and can prove it redundant, it may remove it.
The optimizer currently handles cases such as:
- repeated closure of already-closed local values;
- closure propagation through selected arithmetic and bitwise expressions;
- u32 mask and modulo producer patterns;
- optional TypedArray element access proofs when
optimizeTypedArrayElementAccessis enabled.
See docs/lowering-optimization-spec.md for the exact proof rules.
Practical Usage Rules
Use i32 when:
- the algorithm intentionally uses signed 32-bit overflow;
- multiplication must have C-like 32-bit semantics;
- the current code relies on
(a * b) | 0and the operands may be full-width int32 values; - the hot loop is TypedArray-heavy or integer-heavy;
- you can benchmark the transformed output on your target engine.
Use u32 when:
- unsigned range and bitwise behavior are part of correctness;
- the code implements hashes, checksums, PRNGs, encoders, or binary formats;
- performance is measured rather than assumed.
Use f32 when:
- single-precision rounding is semantically meaningful;
- compatibility with WebGL, binary formats, or float32 data pipelines matters;
- your target engine is known to optimize the
Math.froundpattern, or you accept the V8 slowdown as the cost of precision control.
Use f64 when:
- you want explicit numeric boundaries in TypeScript;
- you want declaration-level documentation that a value is meant to stay in the numeric domain;
- you are not expecting a consistent speedup from unary
+.
Avoid numtypes when:
- the code is ordinary application arithmetic where readability matters more than numeric-domain control;
- the hot path is not benchmarked;
- the algorithm would be clearer or faster as WebAssembly/SIMD.
References
- ECMAScript
Math.imul - ECMAScript
Math.fround - MDN:
Math.imul() - MDN:
Math.fround() - Mozilla JavaScript Blog: Efficient float32 arithmetic in JavaScript
- V8 docs: TurboFan
- TC39 proposal: Modulus and Additional Integer Math
License
MIT OR Apache-2.0
