npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

numtypes

v0.1.0

Published

Zero-runtime numeric type coercion symbols and a TypeScript transformer.

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, or as any;
  • mixed domains such as i32 + f32 are rejected unless the user casts one side;
  • as i32 is unchecked and emits no coercion by itself;
  • i32(x) is checked and emits x | 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-patch

numtypes 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 | 0

The TypeScript assertion form is an unchecked promise:

import type { i32 } from "numtypes";

const unchecked = 0.9 as i32; // emits 0.9

Assertions 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 typescript

ts-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.json

You can also install a persistent patch and keep using tsc:

npx ts-patch install
npx tsc -p tsconfig.json

For 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:md

The algebra benchmarks compile a source string containing both:

  1. a plain JavaScript number baseline; and
  2. 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 runtime

Higher 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-21

These 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 number arithmetic is already exact, but the source logically treats values as integers;
  • intentionally naive full-width multiply kernels where (a * b) | 0 is both semantically wrong and slow compared with transformer-generated Math.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,rdx

The 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,rdx

This 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 Float32Array arithmetic 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 for Float32Array/Math.fround patterns.
  • 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.fround behavior 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 optimizeTypedArrayElementAccess is 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) | 0 and 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.fround pattern, 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

License

MIT OR Apache-2.0