fitzgen added the wasmtime:api label to Issue #10176.
fitzgen added the wasm-proposal:gc label to Issue #10176.
fitzgen opened issue #10176:
While we provide embedder APIs for creating one-off function/array/struct types, we don't currently have a way to define a set of types inside a rec group. This means the embedder cannot define a type that has a reference to itself, or any mutually recursive types. It also means that if a Wasm module uses such a type in its imports or exports, the embedder must find it and pluck it out via
Module::{imports,exports}rather than simply create it themselves when they are trying to define functions that take/return those types.This shouldn't be hard to implement, but it will require a bit of API design work that we want to make sure we are happy with, and that we think will extend well for Wasm's likely future type system extensions, before we commit to anything.
Random, half-baked thoughts:
- I think we will probably want to use builder-style APIs.
- Would be nice if the same Rust builder types could be reused for both defining types in a custom rec group and defining one-off types.
- Would be nice to re-implement our existing
StructType::newet al Wasm type constructors in terms of these new builders.- Representing a "forward reference to a type in this rec group I haven't defined yet" will be interesting. Especially since we need to eventually validate that the type did get defined.
- Rather than using rec-group-local indices (which makes sense at the wasm binary/validation level, but doesn't provide fantastic ergonomics at the embedder API level) we could perhaps try out an API similar to working with labels in a compiler, where you declare (but do not yet define) a type and can use that to define fields in Wasm structs and all that, and then later define the type and bind that definition to the earlier declaration. Finishing the rec group builder with unbound declarations would fail to validate and return an error.
- Perhaps, for API convenience and to consolidate validation, we should wait to validate everything until the whole rec group is finished (i.e.
RecGroupBuilder::finishor whatever is called) rather than making ~every individual builder method fallible, since some things only can be validated when the rec group is complete.
somdoron commented on issue #10176:
I would like to try and tackle this, see proposed design below:
Proposed design:
RecGroupBuilder+ minimal "template" typesThe core idea follows the "labels in a compiler" approach suggested above: you declare a type to get a
Copy, kind-typed handle (a label), use that handle as a forward reference while
defining other types, then define it later. Everything is validated in one place when the rec group is finished.A key goal is keeping the existing runtime types (
StructType,RefType,HeapType,ValType, …) exactly as they are: always valid, always registered. Forward references live in a small,
build-time-only "template" family that mirrors the real type hierarchy one-to-one, so it's a compile-time error to use an unfinished type where a finished one is required (no runtime
"pending" state that panics).Kind-typed labels
Labels carry their kind, which makes a whole class of mistakes unrepresentable:
#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] pub struct PendingStructId(/* .. */); #[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] pub struct PendingArrayId(/* .. */); #[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] pub struct PendingFuncId(/* .. */);The kind is preserved everywhere — definition, ref target, supertype, getter — and checked by the compiler.
The template family mirrors the type hierarchy
A template can express exactly one thing a concrete type can't: a
refto a sibling being defined in the same rec group. The leaf is a "pending heap type" that mirrorsHeapType's concrete
variants, with one extra local variant per kind:/// Mirror of `HeapType`, but a concrete target may be a not-yet-defined sibling. pub enum HeapTypeTemplate { Type(HeapType), // abstract (`any`, `eq`, …) or already-registered concrete LocalStruct(PendingStructId), LocalArray(PendingArrayId), LocalFunc(PendingFuncId), } /// Mirror of `ValType`. pub enum ValTypeTemplate { Type(ValType), // i32/.../v128 or a *concrete* ref Ref { nullable: bool, heap: HeapTypeTemplate }, // ref whose target may be local } /// Mirror of `StorageType` + a field's mutability. pub struct FieldTemplate { /* mutability + element */ } pub enum StorageTypeTemplate { Type(StorageType), // i8/i16 or concrete StorageType Ref { nullable: bool, heap: HeapTypeTemplate }, }Concrete, already-known types flow in transparently via
From/Into, so the no-forward-reference path looks identical to today's API, and a label drops straight into a ref target:impl From<HeapType> for HeapTypeTemplate {} // abstract or concrete impl From<StructType> for HeapTypeTemplate {} // convenience: -> Type(ConcreteStruct) impl From<ArrayType> for HeapTypeTemplate {} impl From<FuncType> for HeapTypeTemplate {} impl From<PendingStructId> for HeapTypeTemplate {} // -> LocalStruct impl From<PendingArrayId> for HeapTypeTemplate {} impl From<PendingFuncId> for HeapTypeTemplate {} impl From<ValType> for ValTypeTemplate {} impl From<StorageType> for StorageTypeTemplate {} impl From<ValType> for StorageTypeTemplate {} impl From<FieldType> for FieldTemplate {} // pass plain FieldTypes when no fwd refs impl FieldTemplate { pub fn new(m: Mutability, e: impl Into<StorageTypeTemplate>) -> Self; // ref target may be a label, an abstract heap type, or a registered type: pub fn ref_(m: Mutability, nullable: bool, heap: impl Into<HeapTypeTemplate>) -> Self; }The rule for embedders is simple: have the type already?
.into()it. Don't have it yet? use a typed label.Supertypes are kind-specific
A supertype is constrained to the same kind as the type being defined, and may itself be either a sibling label or an already-registered type:
pub enum StructSuperType { Local(PendingStructId), Type(StructType) } pub enum ArraySuperType { Local(PendingArrayId), Type(ArrayType) } pub enum FuncSuperType { Local(PendingFuncId), Type(FuncType) } impl From<PendingStructId> for StructSuperType {} impl From<StructType> for StructSuperType {} // ... and likewise for Array / FuncA struct's supertype can only ever be a struct (label or registered) — passing a
PendingArrayIdor anArrayTypeis a compile error.The builder
pub struct RecGroupBuilder { /* engine + members */ } impl RecGroupBuilder { pub fn new(engine: &Engine) -> Self; // forward declarations (kind-typed): pub fn declare_struct(&mut self) -> PendingStructId; pub fn declare_array(&mut self) -> PendingArrayId; pub fn declare_func(&mut self) -> PendingFuncId; // declare + define sugar for the non-recursive case: pub fn add_struct(&mut self, fields) -> PendingStructId; // define a previously-declared label; the id's type fixes the kind: pub fn define_struct(&mut self, id: PendingStructId, fields: impl IntoIterator<Item = impl Into<FieldTemplate>>) -> &mut Self; pub fn define_array (&mut self, id: PendingArrayId, field: impl Into<FieldTemplate>) -> &mut Self; pub fn define_func (&mut self, id: PendingFuncId, params, results) -> &mut Self; // finality + supertype, constrained to the matching kind via `impl Into`: pub fn define_struct_with_finality_and_supertype( &mut self, id: PendingStructId, finality: Finality, supertype: Option<impl Into<StructSuperType>>, // StructType or PendingStructId fields: impl IntoIterator<Item = impl Into<FieldTemplate>>, ) -> &mut Self; // ... array/func equivalents pub fn build(self) -> Result<RecGroup>; // single point of validation } pub struct RecGroup { /* registered members */ } impl RecGroup { // infallible getters — the label's type proves the kind: pub fn struct_(&self, id: PendingStructId) -> StructType; pub fn array (&self, id: PendingArrayId) -> ArrayType; pub fn func (&self, id: PendingFuncId) -> FuncType; pub fn types(&self) -> impl ExactSizeIterator<Item = CompositeType> + '_; // {Struct, Array, Func} }
build()is the only fallible point. It validates that every declared label was defined, field/param counts are within limits, supertypes are non-final and structurally matched, and all
referenced types belong to this engine. (Using a label from a different builder on aRecGroup's getter is a programmer error and panics like an out-of-bounds index.)Example: mutual recursion
let mut b = RecGroupBuilder::new(&engine); let s1 = b.declare_struct(); let s2 = b.declare_struct(); b.define_struct(s1, [ FieldTemplate::ref_(Mutability::Var, /* nullable */ true, s2), // (ref null s2) ]); b.define_struct(s2, [ FieldTemplate::ref_(Mutability::Const, /* nullable */ false, s1), // (ref s1) ]); let group = b.build()?; // validates + registers let s1: StructType = group.struct_(s1); // infallible let s2: StructType = group.struct_(s2);Example: mixing a forward reference with an already-registered type
let mut b = RecGroupBuilder::new(&engine); let node = b.declare_struct(); // sibling, defined below let existing: StructType = /* e.g. plucked from a module's exports */; b.define_struct(node, [ // forward ref to the sibling `node` -> (ref null <node>) FieldTemplate::ref_(Mutability::Var, true, node), // ref to an already-registered type -> (ref <existing>) FieldTemplate::ref_(Mutability::Const, false, existing.clone()), // a plain scalar field FieldTemplate::new(Mutability::Var, StorageType::I64), ]); let group = b.build()?; let node: StructType = group.struct_(node);Example: a supertype that is a sibling or an already-registered type
let mut b = RecGroupBuilder::new(&engine); // supertype is a sibling defined in this same group: let base = b.declare_struct(); let derived = b.declare_struct(); b.define_struct(base, [FieldTemplate::new(Mutability::Const, StorageType::I32)]); b.define_struct_with_finality_and_supertype( derived, Finality::Final, Some(base), // PendingStructId [FieldTemplate::new(Mutability::Const, StorageType::I32)], ); // supertype is an already-registered struct: let existing: StructType = /* ... */; let sub = b.declare_struct(); b.define_struct_with_finality_and_supertype( sub, Finality::Final, Some(existing), // StructType /* fields matching `existing` ... */ [], ); let group = b.build()?;Under the hood, a concrete reference (
existing) is an inter-group edge and is reference-counted to stay registered while building; a label reference is an intra-group edge. That
distinction is invisible to embedders.Notes / addressing the wishlist
Reuse for one-off types:
StructType::new/ArrayType::new/FuncType::newbecome thin wrappers over a single-memberRecGroupBuilder, so there's exactly one
construction/registration path.Implementation is small: the type registry already has
register_rec_group, which accepts a set ofWasmSubTypes with intra-group (Module(idx)) and inter-group (Engine(idx))
references, canonicalizes, hash-conses (so identical rec groups dedup), and registers. The builder just lowers templates into that representation and calls a thin public wrapper withrange = 0..n.
Nonesupertype: because the supertype parameter is generic, passingNoneneeds a type annotation (None::<StructType>). In practice you call the plaindefine_structwhen there's
no supertype, so this only comes up rarely.
fitzgen commented on issue #10176:
@somdoron please do not dump AI text into our issues. You can review our AI tool policy here: https://github.com/bytecodealliance/governance/blob/main/AI_TOOL_POLICY.md
somdoron edited a comment on issue #10176:
I would like to try and tackle this, see proposed design below:
Proposed:
RecGroupBuilderDeclare typed labels, define them, then
build()validates and registers the whole group at once. Existing types (StructType, …) stay unchanged. Labels areCopyand kind-typed, so passing an array where a struct is expected is a compile error. Concrete types flow in viaInto; forward references use a label.impl RecGroupBuilder { pub fn new(engine: &Engine) -> Self; pub fn declare_struct(&mut self) -> PendingStructId; // + array/func pub fn define_struct(&mut self, id: PendingStructId, fields: impl IntoIterator<Item = impl Into<FieldTemplate>>) -> &mut Self; pub fn build(self) -> Result<RecGroup>; // only fallible point } impl RecGroup { pub fn struct_(&self, id: PendingStructId) -> StructType; // infallible }let mut b = RecGroupBuilder::new(&engine); let (s1, s2) = (b.declare_struct(), b.declare_struct()); b.define_struct(s1, [FieldTemplate::ref_(Mutability::Var, true, s2)]); // (ref null s2) b.define_struct(s2, [FieldTemplate::ref_(Mutability::Const, false, s1)]); // (ref s1) let g = b.build()?; let (s1, s2) = (g.struct_(s1), g.struct_(s2));
somdoron commented on issue #10176:
@fitzgen sorry for that, I edited the message with the proposed API and with AI only proofing.
somdoron edited a comment on issue #10176:
I would like to try and tackle this, see proposed design below:
Proposed:
RecGroupBuilderDeclare typed labels, define them, then
build()validates and registers the whole group at once. Existing types (StructType, …) stay unchanged. Labels areCopyand kind-typed, so passing an array where a struct is expected is a compile error. Concrete types flow in viaInto. Forward references use a label.impl RecGroupBuilder { pub fn new(engine: &Engine) -> Self; pub fn declare_struct(&mut self) -> PendingStructId; // + array/func pub fn define_struct(&mut self, id: PendingStructId, fields: impl IntoIterator<Item = impl Into<FieldTemplate>>) -> &mut Self; pub fn build(self) -> Result<RecGroup>; // only fallible point } impl RecGroup { pub fn struct_(&self, id: PendingStructId) -> StructType; // infallible }let mut b = RecGroupBuilder::new(&engine); let (s1, s2) = (b.declare_struct(), b.declare_struct()); b.define_struct(s1, [FieldTemplate::ref_(Mutability::Var, true, s2)]); // (ref null s2) b.define_struct(s2, [FieldTemplate::ref_(Mutability::Const, false, s1)]); // (ref s1) let g = b.build()?; let (s1, s2) = (g.struct_(s1), g.struct_(s2));
somdoron edited a comment on issue #10176:
I would like to try and tackle this, see proposed design below:
Proposed:
RecGroupBuilderDeclare labels for the types you want, define them (referencing each other freely), then
build()validates and registers the whole group at once.impl RecGroupBuilder { pub fn new(engine: &Engine) -> Self; pub fn declare_struct(&mut self) -> PendingStructId; // + array/func pub fn define_struct(&mut self, id: PendingStructId, fields: impl IntoIterator<Item = impl Into<FieldTemplate>>) -> &mut Self; pub fn build(self) -> Result<RecGroup>; // only fallible point } impl RecGroup { pub fn struct_(&self, id: PendingStructId) -> StructType; // infallible }let mut b = RecGroupBuilder::new(&engine); let (s1, s2) = (b.declare_struct(), b.declare_struct()); b.define_struct(s1, [FieldTemplate::ref_(Mutability::Var, true, s2)]); // (ref null s2) b.define_struct(s2, [FieldTemplate::ref_(Mutability::Const, false, s1)]); // (ref s1) let g = b.build()?; let (s1, s2) = (g.struct_(s1), g.struct_(s2));
Last updated: Jul 29 2026 at 05:03 UTC