Skip to content

How it works

Three layers, each building on the previous one:

Layer Idea Works under
Rank aliases Matrix = ndarray[tuple[int, int], ...] — tuple length is the rank mypy, pyright, ty
Your own sizes A2x3 = FloatArray[tuple[Literal[2], Literal[3]], ...], or Array2D[Literal[2], Literal[3]] mypy, pyright, ty
Shape solver numty_plugin hooks constructors, matmul (incl. (*Ts, N, M) pairs), arithmetic ops and ufuncs (with promotion), where/clip/broadcast_to, reshape (incl. symbolic conservation and *Ts block regroups), rank-changing ops (transpose/.T reversal incl. over *Ts), shape builders, comparisons, bool logic (&/\|), any/all, the arg family, einsum, linalg solvers, fancy indexing, take_along_axis, symbolic TypeVar dims (same name unifies, ranks check, the rest bails), call-site TypeVar conflicts, enum-driven stacks, folds constant int expressions and len() (Final arithmetic, enum member counts), solves shapes itself mypy only

The first two layers are pure standard typing — no magic, portable everywhere. The library ships only the generic bases (FloatArray, FlexArray, Array1D, Array2D) plus rank aliases; exact sizes live in your code, one line each. Plain constructors just work: np.zeros((2, 3)) already has a precise type, and the plugin keeps it precise even through assignment.

Layers 1–2 hit a wall at cross-argument checks: ndarray's shape parameter is effectively covariant, so a shared TypeVar silently joins conflicting literals (3 vs 2 becomes int) instead of erroring. The plugin stops asking the type system to solve shapes and does the solving itself, with readable errors like matmul batch dimensions disagree: (5,) vs (4,) — including a call-site check that catches the shared-TypeVar conflict the join hides (f(x_5, y_6) against (A[N], A[N])).

The plugin is registered in mypy.ini and needs PYTHONPATH=. so mypy can import it (MYPYPATH does not work for plugins). Without the plugin, only annotations spelled with the first two layers keep any checking, and under plain mypy less than the table suggests: rank aliases still catch rank mixups, but mypy widens a constructor's sizes to int once the array is assigned to a name, so exact-size aliases then reject correct code. mypy demo.py with no plugin configured reports 4 errors, 3 of them on correct code, and misses the 3x2 size bug. The F64-style heads (F32, U8, ...) need the plugin: without it a correct call is rejected (f(np.zeros((2, 3))) against F64[batch, features] fails with expected "F64[Never, Never]" under plain mypy) and results become F64[Any, Any] (#5).