███╗ ██╗██╗ ██╗███╗ ███╗████████╗██╗ ██╗██████╗ ███████╗
████╗ ██║██║ ██║████╗ ████║╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝
██╔██╗ ██║██║ ██║██╔████╔██║ ██║ ╚████╔╝ ██████╔╝█████╗
██║╚██╗██║██║ ██║██║╚██╔╝██║ ██║ ╚██╔╝ ██╔═══╝ ██╔══╝
██║ ╚████║╚██████╔╝██║ ╚═╝ ██║ ██║ ██║ ██║ ███████╗
╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝
NumType is to NumPy what TypeScript is to JavaScript: shape errors become editor errors.
Status: v0.2 research preview · Apache-2.0 · zero runtime dependencies · launch post: Teaching the type checker arithmetic
An n-dimensional array library for TypeScript with compile-time shape checking. matmul,
broadcasting, and reduction shape mismatches appear as editor squiggles while you type — not
as runtime crashes in production. It's gradual by design: literal dimensions are checked
statically, and dynamic (number) dimensions degrade gracefully to runtime checks, so it stays
usable in real code. The numeric backend is from-scratch Rust/WASM (opt-in); the default path is
a pure-JS reference runtime that is safe to run in the browser.
import { NDArray } from "numtype";
const a = NDArray.fromArray([2, 3], [1, 2, 3, 4, 5, 6]); // NDArray<[2, 3]>
const c = a.matmul(NDArray.zeros([3, 4])); // NDArray<[2, 4]> — hover shows it
a.matmul(NDArray.zeros([5, 4]));
// ~~~~~~~~~~~~~~~~~~~~~ matmul: inner dimensions must match — got [2, 3] and [5, 4]Try it in your browser → — the types load straight from npm into the TypeScript Playground; hover the variables, break the shapes. Nothing to install.
The red squiggle is the product; the WASM performance is a credibility feature, not the reason to reach for it. If you've ever shipped a tensor-shape bug to production, this is the pitch.
The original motivation is a gap in the TypeScript ecosystem: there is no real counterpart to NumPy. Python's entire scientific stack rests on one shared n-dimensional array foundation; the JS/TS world has adjacent pieces — TensorFlow.js is an ML framework, math.js targets general (mostly low-rank) math, scijs/ndarray predates modern TypeScript — but no general, typed n-d array layer that feels like standard infrastructure. And the one thing a TS-native answer could uniquely add is exactly what none of the existing options attempt: putting the type system itself to work on shapes.
NumType tries to close that gap in the only form that is honest for a new library: a minimum viable NumPy — a deliberately narrow op surface with the shape-typed core done properly — not a 400-operation clone. The obvious limitations that come with that claim are stated, not hidden: see Honest qualifications and Non-goals.
numpy-ts ports NumPy's API to TypeScript — impressively broadly (its
headline is 94% API coverage), with outputs validated against NumPy itself. NumType teaches
TypeScript's type checker to do NumPy's shape arithmetic. numpy-ts's "catch errors at compile
time" is signature- and dtype-level typing: a matmul of [2, 3] × [5, 4] type-checks there
and fails at runtime, while here it is a compile error at the offending argument. The two
projects barely compete — if you need 476 functions today, use numpy-ts; if you want shape
mismatches as red squiggles while you type, on a deliberately narrow op surface, that is what
NumType exists for. (Survey of the wider landscape, numpy-ts claims re-verified 2026-07-20:
docs/wettbewerbsanalyse-und-usp.md — Appendix A is in
English.)
Shapes aren't just matched — where dimensions are literal, they are computed, while you type:
const a = NDArray.zeros([1024]);
const b = a.slice({ start: 100, stop: 1000 });
// ^ hover shows: NDArray<[900]> — 1000 − 100, computed by the type checker
a.slice(1024);
// ~~~~ compile error: slice: index 1024 is out of bounds for axis 0
// with dim 1024 (shape [1024]) — a guaranteed runtime crash,
// surfaced while typing (integer indices throw; ranges clamp,
// exactly like NumPy — so ranges are computed, never rejected)The received wisdom is that TypeScript can't do arithmetic over values this large: the standard
tuple-length encoding costs one recursion step per unit of the value, and the checker's
recursion ceiling (~1000) puts 1000 − 100 structurally out of reach. The trick is a change of
representation, not a broken limit: a literal number type becomes its decimal digit string
via template-literal types (`${1000}` → "1000"), and the arithmetic is schoolbook
subtraction with borrow, digit by digit — O(digit count) instead of O(value), ~7 recursion
steps instead of ~1,000,000 for a 7-digit dimension. Bounds checks come almost free on top:
they only need the comparison half of that machinery, which is why negative indices
(a.slice(-2)) are checked too. Everything outside the provable literal subset degrades honestly
to number + runtime checks — never a false error.
The digit-string representation itself is established prior art in the type-level community — general-purpose arithmetic libraries (notably ts-arithmetic) compute over digit representations to escape the same recursion ceiling. NumType's contribution is the application, not the trick: wiring that arithmetic into an n-d array API — dimensions, slice lengths, bounds, shape products — where a computed number becomes a compile-time shape error.
pnpm add numtype # or: npm install numtype / yarn add numtypeZero runtime dependencies; the .wasm core is bundled, so there is no native toolchain to
install. The default NDArray runs in pure JS and works in any modern JS environment (Node or
browser). The optional WASM/threaded backends need Node (see Backends). ESM only.
The shape checking works in your editor out of the box. Everything type-level ships as
plain .d.ts files inside the package, so any editor running the TypeScript language service —
VS Code, WebStorm, Neovim, Zed, … — shows the hovers and shape errors immediately after
installing: no editor extension, no compiler plugin, no codegen step. Two requirements: your
project uses TypeScript (a plain-JS project still gets hovers and IntelliSense from the
.d.ts, but errors only with checkJs), and a reasonably current compiler — NumType is
developed and verified against TypeScript 7.x (the current npm latest); the type machinery
uses features introduced in TS 5.0, but older majors are untested.
Every operation below is exercised bit-for-bit against the WASM backend in spike/demo.ts
(pnpm demo), so these examples run verbatim.
import { NDArray } from "numtype";
// Construction — the shape is inferred from the literal you pass.
const a = NDArray.fromArray([2, 3], [1, 2, 3, 4, 5, 6]); // NDArray<[2, 3]>
const b = NDArray.fromArray([3], [10, 20, 30]); // NDArray<[3]>
// Broadcasting add: [2, 3] + [3] -> [2, 3]
const sum = a.add(b); // NDArray<[2, 3]> ([[11,22,33],[14,25,36]])
// Matrix multiply: [2, 3] @ [3, 2] -> [2, 2]
const m2 = NDArray.fromArray([3, 2], [7, 8, 9, 10, 11, 12]);
const product = a.matmul(m2); // NDArray<[2, 2]> ([[58,64],[139,154]])
// Reduction along an axis: [2, 3] -> [3]
const colSums = a.sum(0); // NDArray<[3]>
const kept = a.sum(0, true); // NDArray<[1, 3]> (keepdims)
// O(1) transpose (view): [2, 3] -> [3, 2]
const t = a.transpose(); // NDArray<[3, 2]>
// Slicing with statically computed shapes
const row = a.slice(1); // NDArray<[3]> — integer index drops the axis
const win = NDArray.zeros([1024]).slice({ start: 100, stop: 1000 }); // NDArray<[900]>
// Reshape / flatten — the product is computed at the type level
const cube = NDArray.zeros([2, 3, 4]);
const flat = cube.flatten(); // NDArray<[24]>
const re = cube.reshape([4, 6]); // NDArray<[4, 6]>
NDArray.zeros([1024, 1024]).flatten(); // NDArray<[1048576]> — a computed literal, not number
// Elementwise family + embedding primitives (the RAG/embedding use case)
const x = NDArray.fromArray([4], [0.2, 0.4, 0.1, 0.8]);
const y = NDArray.fromArray([4], [0.5, 0.1, 0.3, 0.2]);
const scaled = x.mul(y); // NDArray<[4]> (also .sub / .div)
const d = x.dot(y); // number
const cos = x.cosineSimilarity(y); // number (also .norm())
// Read the data back out
sum.toNestedArray(); // [[11, 22, 33], [14, 25, 36]]Shape mismatches are caught at the offending argument, with the runtime's exact message:
const a = NDArray.fromArray([2, 3], [1, 2, 3, 4, 5, 6]);
a.matmul(NDArray.zeros([5, 4])); // ❌ compile error at the argument: inner dims [2,3] vs [5,4]
a.add(NDArray.zeros([4])); // ❌ compile error: shapes [2,3] and [4] are not broadcastable
a.slice(9); // ❌ compile error: index 9 out of bounds for axis 0 (dim 2)The five ops added in 0.2.0 all came from one place: the friction log of the RAG example below,
built against 0.1.1. They shipped as TypeScript-runtime surface only (no WASM kernel), which is why
they are shown here rather than in the bit-for-bit block above. Since then the WASM-resident twin
has caught up with all of them — sqrt, the scalar overloads, mean, stack, item, argmax
and topk now work on WASM-resident arrays too, each proven bit-identical to the TypeScript
reference by differential tests, on the threaded backend as well. There is no
TypeScript-only op left (see What's implemented):
const scores = NDArray.fromArray([4], [0.2, 0.9, 0.1, 0.7]);
scores.topk(2); // { values: NDArray<[2]>, indices: NDArray<[2]> } — 0.9/0.7 at 1/3
scores.argmax(); // 1 — a plain number
scores.div(2).mean(); // scalar overloads + mean — still an NDArray, still chainable
scores.sqrt(); // elementwise, IEEE-exact (not a transcendental)
NDArray.stack([scores, scores]); // NDArray<[2, 4]> — literal tuple in, literal shape out
scores.item(1); // 0.9 — direct scalar read
scores.item(9); // ❌ compile error: index 9 is out of bounds for axis 0 with dim 4A larger, real consumer application — a from-scratch RAG (retrieval) demo that embeds documents,
scores queries against them with a single matmul, and ranks the results — lives in
examples/rag-demo and installs numtype from npm like any other project.
Since 0.2.0 it doubles as a before/after showcase: every workaround from its original friction
log is rewritten in place to the op that replaced it, with the pinned retrieval scores unchanged
(the example's README carries the friction-to-op table).
Real programs have runtime-determined shapes. Where a dimension is number rather than a
literal, NumType stops making static promises and defers to a runtime check — the same
escape-hatch philosophy that made TypeScript itself adoptable:
async function load(): Promise<NDArray<[number, 1536]>> { /* ... */ }
const emb = await load(); // NDArray<[number, 1536]>
emb.matmul(NDArray.zeros([1536, 8])); // NDArray<[number, 8]> — the literal dim is still trackedUnion and dynamic-rank shapes degrade the same way: NumType never emits a confidently wrong
error, only a wide (number) result or a deferred runtime check.
The default NDArray computes in pure JS and is browser-safe (it eagerly loads nothing native).
The from-scratch Rust/WASM cores are an explicit, opt-in choice:
NDArray.backend("wasm"); // single-threaded WASM core (SIMD128 blocked matmul)
NDArray.backend("threaded"); // Node-only, experimental: multi-threaded matmulEvery WASM kernel is bit-identical to the naive JS reference — proven by a differential test
suite that includes IEEE-754 special values (NaN, ±Inf, ±0, subnormals). Threading is a
Node-only, explicitly-experimental opt-in for v0 (the browser port is gated on COOP/COEP headers
a library can't set). See docs/ for the backend design and benchmarks.
The bundled .wasm is the single-threaded core, so backend("wasm") works out of the box.
The multi-threaded core is not shipped in the npm package — it requires a pinned nightly Rust
toolchain with -Z build-std to compile, which the published (stable-Rust) package deliberately
does not depend on. backend("threaded") is therefore build-it-yourself from a checkout: clone
the repo and run pnpm build:wasm:threads.
numtype has no runtime dependencies — the package.json has no dependencies field at all,
enforced by a CI guard. There is nothing transitive to audit and no supply-chain surface. Both
halves are hand-written: the Rust/WASM kernels (no wasm-bindgen, no BLAS, no ndarray crate —
a hand-rolled extern "C" ABI) and the entire type-level machinery (the digit-string arithmetic).
The single-threaded .wasm is pre-built and bundled, so pnpm add numtype compiles nothing
native on your machine.
Credibility is an asset for a research project, so the scope of the guarantee is stated plainly:
- "Python can't do this" is verified, not asserted. NumPy's own maintainers advise against relying on shape typing, and PyTorch's static-shapes request has sat open for years — the general problem resists Python's type system structurally.
- "TypeScript can do this" means: newly tractable, and now measured — at consumer scale. No
existing TS library has delivered general compile-time shape checking with broadcasting and
reductions; the prior art stops at literal-dimension matmul. A 35-point sweep
(
pnpm bench:scale) replaces the old "unproven" with numbers. Interaction holds everywhere: the warm hover median stayed between 0.04 and 0.11 ms across a 250-file project, a 10,000-operation chain, and rank 896 alike. Scale costs land on cold start instead — 1.5 ms at 250 files, but 10 s for one 10,000-link chain. Instantiations grow linearly in files (about 3,800 per file with distinct shapes; repeated shapes are deduplicated project-wide and plateau flat) and linearly in chain length (265 per link), but superlinearly in rank, until at rank 1024 the checker aborts on valid code with "Type instantiation is excessively deep". Real arrays have single-digit rank, so that wall says more about the machine than about the use case — but it is a wall, and this is where it stands. What this does not cover is API-surface scale: whether the machinery still holds at a NumPy-sized set of operations is a separate, still-unmeasured question. Full numbers: docs/scale-probe-ergebnisse.md. - Scope of the guarantee: realistic ranks and op chains, not "every conceivable shape." The
plausible failure modes for a type-level shape system in TypeScript are high-rank tensors,
very large broadcast dimensions, and long chains of composed operations. The gradual design
(literal dims static,
numberdims at runtime) makes that a feature, not a footnote.
The type-checker cost is measured, not hoped: the slice arithmetic costs ~1.59× instantiations, the bounds checks ~1.036×, and hover latency measured against the native TS 7 language server is 0.04–0.08 ms median — about three orders of magnitude under a 100 ms editor gate. That median holds at scale too: 0.04–0.11 ms at 250 files, at 10,000-operation chains, and at rank 896.
NumType follows SemVer with 0.x semantics, sharpened for a library whose inferred types are part of the API:
- Breaking changes bump the minor (0.1 → 0.2) and are listed in the release notes. Anything that can change an inferred type or reject previously-accepted code counts as breaking and never lands in a patch.
- Stable through all of 0.x: zero runtime dependencies, WASM kernels bit-identical to the JS reference, and the "never wrong, only incomplete" type philosophy — a confidently wrong compile-time claim is always a bug; please report it. These aren't aspirations: CI enforces them with a zero-dep guard, a frozen artifact hash, and the differential test suite.
- Types get more precise over time: shapes that degrade to
numbertoday may become computed literals in a later minor. If you pin exact inferred types in your own tests, expect those pins to move on minor bumps — never on patches. - The op surface grows additively. Existing operations only change with a minor bump.
backend("threaded")is experimental and sits outside any stability promise. - Research-preview honesty: the stated goal is probing whether this approach holds up at scale. If it hits a structural wall, 0.x may see substantial redesign.
The type layer (broadcast / matmul / reduce as gradual types, errors at the offending argument,
statically computed slice / reshape / flatten shapes, literal bounds checks) and a from-scratch
Rust/WASM numeric core: strided O(1) views (transpose, slice), a blocked + packed + SIMD128
matmul, zero-copy WASM residency, and a hand-rolled multi-threaded matmul — each proven
bit-identical to the JS reference. The op surface is deliberately narrow (construction/
conversion, add/sub/mul/div, matmul, sum with keepdims, transpose, slice,
reshape/flatten, dot/norm/cosineSimilarity) — a minimum viable NumPy, not a clone.
argmax/topk (ranking primitives — index of the maximum element, and the top-k values +
indices of a 1-D vector) are available on NDArray and on WASM-resident arrays alike, each through
its own from-scratch Rust kernels (nt_argmax_{all,axis}_strided, nt_topk_strided) — proven
bit-identical to the TypeScript reference by differential tests, on the threaded backend as well,
including the IEEE special-value edges their total orders are defined on. For argmax, NaN counts
as maximal and ties go to the first index. For topk, NaN entries rank first (by ascending index
among themselves), then values descend, and every tie — equal values and +0/-0 alike — is
broken by ascending index; that makes the order strict and total, so exactly one answer is correct
and the kernel is free to reach it by whichever algorithm is fastest. topk returns real data
values, not just indices, and values[i] is a plain element copy of data[indices[i]] — a NaN's
exact bit payload survives it, which is pinned by its own test.
Scalar overloads for add/sub/mul/div, a new mean reduction, and a new sqrt op are
also available: x.div(2) reads as "divide by 2" (shape-preserving, no [1]-wrap needed, even at
rank 0), mean composes sum with one division per output element, and sqrt is elementwise
Math.sqrt (shape-preserving at every rank — IEEE 754 requires correct rounding for square root,
same as +/-/*//, so this stays exact, unlike a true transcendental op). These three are
not TypeScript-only either: the WASM-resident twin WNDArray carries all of them — sqrt and
the four scalar ops through their own from-scratch Rust kernels, mean by composing the existing
sum and scalar-division kernels rather than adding one — each proven bit-identical to the
TypeScript reference by differential tests, on the threaded backend as well.
NDArray.stack(rows) builds a [N, D] matrix from N independently-computed 1-D row
vectors — the np.stack/np.array([...]) reflex, and a statically-shaped one: stacking two
literal [3] rows infers NDArray<[2, 3]>, a mismatched row length is a compile error at the
rows argument, and an array of unknown length degrades honestly to NDArray<[number, D]>.
Available on WASM-resident arrays too, via backend("wasm").stack(rows) (or the threaded backend's
own copy) — without a new kernel: stacking is pure data movement, so it reuses the frozen
nt_materialize gather once per row, writing each into its own slot of a single output buffer.
x.item(...indices) is the direct scalar read — NumPy's own x.item(i, j, ...) — full
indexing only (exactly one index per axis, rank 0 included: x.item() reads the sole element).
A literal index provably out of bounds for its axis (NumPy-style negative indexing included) or
a non-integer literal (1.5) is a compile error at that argument; a wrong number of indices for
a statically-known rank is a native TypeScript arity error. No kernel at all here — item is a
plain strided read, not an op with a kernel to write in the first place. It works on WASM-resident
arrays too: there it reads the single f64 straight out of the core's linear memory (offset from
the handle's own strides, so transposed and sliced views read correctly), which means no WASM code
executes for it at all — and it is still held to bit-identity with the TypeScript reference.
For the full per-phase specifications, results, and the competitive analysis, start at the research-notes reading guide (curated entry points by interest) or the roadmap. Internal research notes are partly in German — the guide says which, and why.
No 400-operation NumPy clone, no GPU/autograd, no DataFrames, no transcendental ops that would break bit-parity with the JS reference.
Apache-2.0. The explicit patent grant covers the from-scratch kernel algorithms; see
NOTICE for attribution.
![Editor demo: types computed while typing — matmul result NDArray<[2, 4]>, slice arithmetic NDArray<[900]>, and a shape mismatch surfacing as a compile error at the argument](https://raw.githubusercontent.com/crankfunk/numtype/main/docs/assets/numtype-demo.gif)