TL;DR A concrete dimension is currently an instance (
I = Dimension("I")), soField[Dims[I], float64]is not a valid static annotation and gt4py ships a mypy plugin to paper over it. Make a dimension a class (class I(gtx.Dimension): ...) whose class object carries today’s value-level behaviour via a metaclass. That is the entire proposal — but the shape is chosen so that staggering, dimension variables and dependent local dimensions all land on top of it without coming back to change it.
Prototype: mypy-verified, vendored under Generic dimensions and statically typed staggering (
dimension-generic-fields/typed_dimensions.py,static_checks.py,static_errors.py,test_typed_dimensions.py; mypy 2.3, no plugin). That document is the wider investigation this one is extracted from; everything beyond Part I stays there. The Part I surface has since also been cross-checked on pyright and against the shippedgtx.Field/gtx.Dims— see Verification status.
Scope
| In | Out |
|---|---|
| A concrete dimension is a type | Staggering syntax and index conventions |
| Value-level behaviour preserved on the class object | Dimension variables / dim-generic operators |
| Old- and new-style dimensions coexist during migration | Dependent local dimensions and connectivity chains |
Dimension types usable as TypeVar bounds | Anything about Dims ordering semantics |
The out-column items each have their own proposal. This one is deliberately the smallest thing that unblocks all of them.
Problem
I = Dimension("I") is an instance (common.py:79), so Field[Dims[I], T] is
not a valid annotation — mypy reports Variable "I" is not valid as a type [valid-type]. That is GridTools/gt4py#2503, and the shipped answer is a
compatibility shim: src/gt4py/next/type_system/mypy_plugin.py (wired in via
pyproject.toml [tool.mypy] plugins = [...]), which substitutes placeholder
classes _DimA–_DimD and then _AnyDim for dimension arguments.
What that costs:
- At most four distinct dimensions are tracked per run, globally, keyed by
argument name — so same-named dimensions in different modules collide and
everything past the fourth becomes
_AnyDim. - No
TypeVars over dimensions, so no dimension-generic operator can ever be typed. - Every downstream project must install the plugin, and only mypy is served; pyright users get nothing.
- The plugin also blurs dtypes (
floatvsfloat32vsfloat64) to suppress a different class of false positive, which erases exactly the information dtype generics need.
Success criterion: with this proposal landed, the dimension half of
mypy_plugin.py is deleted rather than maintained, and client code type-checks
without a gt4py-specific plugin.
Design
class DimensionBase(metaclass=DimensionMeta): # root: anything usable in Dims[...]
value: ClassVar[str] # defaults to the class name
kind: ClassVar[DimensionKind] = DimensionKind.HORIZONTAL
def __new__(cls, val: int) -> NamedIndex: # `I(0)`, *not* instantiation
return NamedIndex(cls, val)
class Dimension(DimensionBase): ... # base of *user-declared* dimensions
class I(Dimension): ...
class K(Dimension, kind=DimensionKind.VERTICAL): ...- The class object is the value. There is no instance level. Wherever a
Dimensioninstance flows today — domains, offset providers,FieldType.dims, IR nodes — the class object flows instead. DimensionMetacarries the value-level API:__add__/__sub__building connectivities, comparison operators →Domain,__repr__,__eq__/__hash__,__reduce__. Binary operators on a class object dispatch through its metaclass, so this is the only place they can live.I(0)→NamedIndexlives onDimensionBase.__new__, not onDimensionMeta.__call__. mypy ignores a metaclass__call__and typesI(0)as an instantiation ofI(Too many arguments for "I" [call-arg]), which would make this proposal introduce a false positive where none exists today.__new__may return any type, both checkers honour its return type, andtype.__call__skips__init__because aNamedIndexis not an instance ofI— so the same one line serves the static and the runtime view. This also supersedes the earlier “__new__raises” formulation: there is still no instance level,__new__simply never returns one.- Equality and hashing must include
kind, not justvalue. Today’sDimensionis a frozen dataclass whose__eq__comparesvalueandkind, with a dataclass-generated__hash__over both;Dimension("I")andDimension("I", VERTICAL)are neither equal nor hash-equal. The vendored prototype’s name-only__eq__/__hash__is therefore not semantics-preserving and must not be copied verbatim. DimensionMeta.__reduce__is load-bearing, not a nicety.eve.utils.content_hashpickles to fingerprint build-cache entries, andotf/runners.pyships compilation tasks to aspawn-basedProcessPoolExecutorthroughpickle. A class made by theDimension("I")compatibility factory is dynamically created and therefore unpicklable (PicklingError: attribute lookup ... failed), and a statically declared one pickles by reference, which would make fingerprints depend on the defining module where today they are value-based. Reducing to(Dimension, (value, kind))restores both properties at once.
The extension point
This is the part that makes the proposal worth landing on its own.
The two-level split — a root DimensionBase (anything usable inside Dims[...])
above Dimension (the base of user-declared dimensions) — exists so that
parameterized dimension types can be usable in Dims[...] without being
user-declarable. Both known follow-ups need exactly that, and neither is decided
here:
Staggered[D]— a dimension parameterized by a dimension. Binding its parameter toDimensionrather than the root is what makesStaggered[Staggered[I]]statically unrepresentable.Local[C]— a local dimension parameterized by the connectivity it comes from, soV2EDim’s dependence onVertexlives in the type (Dependent local dimensions and connectivity chains, §4.4/§5.4 — both designs are mypy-verified on top of this base).
Dimension variables (TypeVar("D", bound=Dimension), TypeVarTuple over dims)
need only that dimensions are types, which is the proposal itself.
So the forward-compatibility claim is narrow and checkable: the root admits parameterized dimension types, and both candidate users of that slot have working prototypes. Nothing else about them is settled here.
Relation to the shipped _Staggered name prefix
gt4py already ships staggered dimensions (feat[next]: staggered fields, #2667,
ADR 0026), encoding “staggered” as a _Staggered prefix on the dimension
name. That ADR’s rationale is sound given today’s model: a dimension is
identified by its name and appears as a common.Dimension, as an
itir.AxisLiteral, and as a plain string in generated backend code, so a flag on
Dimension would have to be threaded through all three.
Dimensions-as-types changes that premise — identity becomes nominal, and the name
becomes what crosses the IR boundary rather than what is the dimension.
(itir.AxisLiteral is (value: str, kind) and already carries
TODO(havogt): Refactor to use declare Axis/Dimension at the Program level.)
Step 1 keeps the prefix and costs almost nothing. Staggered[I] must
materialize a class whose value is "_StaggeredI"; because the metaclass
compares by name, the reading helpers keep working untouched — is_staggered,
as_non_staggered, check_dims and order_dimensions only ever look at
.value/.kind, and gtfn’s _add_staggered_aliases rebuilds a Dimension from
a string. Two caveats, both established by reading the call sites:
- The vendored prototype sets
value = f"{base.value}½", not"_StaggeredI". As vendored it would breakis_staggered’s prefix test and, through it,order_dimensionsandcheck_dims. Whichever way this is settled, the prototype and this document have to agree before Part II starts. flip_staggeredis not a pure reader: it constructsDimension(value, kind). Under class-style dimensions it returns a factory-made class rather thanStaggered[I], which is fine only because equality is by name — one more reason the compatibility factory has to intern (see Migration, step 0).
Step 2 is a separate change behind this surface. Once staggering is answered
by the type, is_staggered’s dim.value.startswith("_Staggered") sniffing has no
callers and the encoding stops being a user-visible convention. Being precise
about what does not go away: distinct axes still need distinct identifiers at
the IR and backend boundary, so some mangling survives there. What disappears is
the string sniffing and the fact that users can observe — or collide with — the
prefix.
Migration
Each step is landable separately, with name-based equality carrying the interop. Sizes below are measured against the tree at the time of writing.
Step 0 — settle identity semantics. Blocking, ~0 lines. Static checkers see
dimension classes nominally; runtime equality is structural (name + kind).
That mismatch is load-bearing rather than academic: tests/next_tests declares a
dimension literally named "I" 46 times, and IDim in 45 distinct files, and
today’s plugin hides this by keying its _DimA–_DimD substitution on the
argument name, so all 45 collapse to one placeholder. Afterwards mypy will
treat them as 45 distinct types. Recommended answer: keep name + kind
equality and make the Dimension(value, kind) compatibility factory intern,
so Dimension("I") is Dimension("I"). Do not add duplicate-name rejection here
— nothing rejects same-named dimensions today, so it is a new behaviour that
would break those 45 files at once; defer it to step 4 behind a warning.
Step 1 — DimensionMeta and class-style Dimension in common. Name-based
interop; Dimension("I") keeps working as an interning factory. Carries the
__new__ → NamedIndex, kind-aware __eq__/__hash__ and __reduce__ points
from Design. One prerequisite outside common: eve.type_validation
has no type[...] case, so a type[Dimension] annotation currently degrades to
“is any class” — a DataModel field annotated type[common.Dimension] accepts
int. Adding that case is small and localized, and step 2 depends on it.
Acceptance: nox -s test_next green with no call-site changes outside common.
Step 2 — the annotation and guard sweep. This is the bulk of the work.
Dimension as an annotation means “an instance” and has to become
type[Dimension]; isinstance(x, Dimension) has to become
isinstance(x, DimensionMeta). In src/gt4py/next alone that is ~109 annotation
sites and 41 isinstance guards, plus ~355 more occurrences in tests/ and ~35
in docs//examples/. Mechanical and greppable, but it is larger than steps 1
and 3 combined and should be its own PR. Two sites need specific attention rather
than a sweep:
type_system/type_specifications.py—DimensionType.dim,IndexType.dim,OffsetType.source/target,ListType.offset_typeandFieldType.dimsare eveDataModelfields, so they are where the missingtype[...]validator case actually bites.ffront/fbuiltins.py—_type_conversion’selif t is common.Dimension: return ts.DimensionTypemaps builtin signatures and must learntype[Dimension].
Until this step lands, the runtime guards fire immediately: Domain(dims=(I,), ...)
raises ValueError: 'dims' argument needs to be a 'tuple[Dimension, ...]'.
Step 3 — migrate internal definitions and add typing coverage. Tests, docs and
fbuiltins to class style. New typing_tests cases for dimension-mismatch
rejection, TypeVar bounds over dimensions, unparameterized Dims, and I(0) —
each run under both mypy and pyright, since pyright coverage is the new
capability this buys and nothing currently exercises it. Gate the plugin’s
dimension hooks off rather than deleting them; downstream code mid-migration
still needs them, and the fullname.endswith("Dim") hook means deleting early
breaks any dimension that has not been converted yet.
Step 4 — deprecate. Dimension("I") warns, then the dimension half of
mypy_plugin.py is deleted. Duplicate-name rejection, if wanted, lands here.
Open questions and risks
- The metaclass-overload mypy quirk — closed. mypy rejects generic
self-types on metaclass methods at the definition site but applies them
correctly at every call site, so the operator overloads carry
# type: ignore[misc], pinned bystatic_checks.py. Re-verified on mypy 2.3 (2026-08-05): both the quirk and the suppression still behave as described. pyright has now been checked too (1.1.411) and needs none of those suppressions — see Verification status. The checker-agnostic fallback (free functions,shift(I, 1)) is not needed. What did turn up in its place is the metaclass__call__problem, now folded into Design. - eve handling of dimension classes, in two distinct places. Validation:
eve.type_validationhas notype[...]case and silently weakenstype[Dimension]to “is any class” — aDataModelwith adim: type[common.Dimension]field acceptsinttoday. Serialization: dataclass instances serialize structurally; classes need avalue/kind-based representation, whichDimensionMeta.__reduce__supplies. Note thatitirnodes themselves do not embedcommon.Dimension—AxisLiteralis(value: str, kind)— so the affected surface istype_specifications.py, not the IR. - Migration surface is the largest cost item, and it is bigger than “move
the dimension definitions”: the annotation/guard sweep of step 2 is ~109
annotation sites and 41
isinstanceguards insrc/gt4py/nextalone, before tests and docs. Name-based interop bounds the risk, not the size, and it still needs a dedicated test layer exercising mixed old/new dimensions in one program. typinginternals cache subscriptions by hash, so hash-equal-but-distinct dimensions do alias — confirmed: for two distinct classes both namedI,Field[Dims[I]] is Field[Dims[I2]]evaluates toTrue, and the annotation silently binds whichever was subscripted first. The earlier note that same-named distinct dimensions “are rejected anyway” is wrong: nothing in the tree rejects them, andtests/next_testsrelies on that (see Migration, step 0). Under name+kind equality the aliasing is consistent with runtime semantics, but mypy will still see the two as distinct types, so the static and runtime views disagree exactly here.- Dependent local dimensions stay open and are expected to. The one question that reaches back here is whether dimension variables range over hop stacks (dependent local dimensions §8.2); the v0 answer — dimension variables bind origin and non-local dims only — leaves this proposal unaffected either way.
Verification status
Checked against gt4py main (ee1bb4f6a) on 2026-08-26, mypy 2.3.0 and pyright
1.1.411.
- The premise reproduces on the shipped types, not only on the prototype’s
own
Field/Dims. With no plugin,IDim = gtx.Dimension("IDim")ingtx.Field[gtx.Dims[IDim], gtx.float64]givesVariable "IDim" is not valid as a type [valid-type], whileclass IDim(Dimension)type-checks and rejects passing aField[Dims[JDim], float64]whereField[Dims[IDim], float64]is expected. - The vendored prototype still passes mypy 2.3.0 with its own
mypy.ini(Success: no issues found in 2 source files). - pyright accepts the whole Part I surface with zero diagnostics — metaclass
__repr__/__hash__/__add__/__sub__/comparisons withcls: type[AnyD]generic self-types,__init_subclass__,Dims[...], and TypeVars ranging over dimensions. It needs none of mypy’s# type: ignore[misc]suppressions. pyright does report 8 errors on the vendored prototype, but all 8 aredual(), which is Part II and out of scope here. I(0)via a metaclass__call__fails on mypy (Too many arguments for "I" [call-arg], revealed typeI) while pyright resolves it correctly; moving it toDimensionBase.__new__satisfies mypy, pyright and the runtime.- A factory-made dimension class is unpicklable as-is, and
type[Dimension]passes eveDataModelvalidation for any class at all.
Not yet checked: behaviour of the sweep in step 2 under an actual migration, and whether ICON4Py has dimension usages outside the patterns present in this tree.
Related
- Generic dimensions and statically typed staggering — the investigation this is extracted from; keeps staggering (Part II) and dimension variables (Part III).
- Dependent local dimensions and connectivity chains — the second consumer of the parameterized-dimension slot.
- Dtype-generic fields in gt4py.next — shares the binding/monomorphization machinery, and is the other half of what the mypy plugin currently blurs.