Skip to content

WIT: allow type declarations at package scope #694

Description

@yordis

A type can only be declared inside an interface or world today, and those are also the units of import and export, so sharing a type vocabulary means making an interface to hold it, and that interface reaches the artifact:

package t:demo@0.1.0;

interface types {
  record point { x: u32, y: u32 }
}

interface api {
  use types.{point};
  move-to: func(p: point);
}

world w {
  export api;
}
$ wasm-tools component embed --dummy demo.wit --world w -o dummy.wasm
$ wasm-tools component new dummy.wasm -o c.wasm
$ wasm-tools component wit c.wasm
world root {
  import t:demo/types@0.1.0;

  export t:demo/api@0.1.0;
}

Nothing calls t:demo/types. It is imported because it is the naming container for a record. #402 would make that more common: once a used interface has to already be imported, every consuming world writes import types; for something nobody calls, and that PR notes wasi:http/imports would need exactly that addition.

bytecodealliance/wasm-tools#1195 has been open since 2023 out of wasmCloud composition, where the composed world came out carrying import wasmcloud:messaging/types@0.1.0 with the reporter's inline note "this shouldn't be here since it is only types". The fix proposed there was a composer-level workaround and it never landed. #629's motivating example is the same interface types { ... } shape, and #295 is a newcomer stuck on what to even call the container, noting that a bare function can go directly on a world but "that doesn't work for modules that export enums, records, or resources".

My suggestion is to allow typedef-item at package scope, which is one production in WIT.md:

package-items ::= toplevel-use-item | interface-item | world-item | gate typedef-item

reusing gate and typedef-item unchanged, so package-scope types are feature-gateable the way interface items are. Every form is a parse error there today (wasm-tools 1.255.0: expected world, interface or use, found keyword type). I would leave resource out of the first cut, since alexcrichton's note on wasm-tools#1195 is that resource types still have to be imported regardless because they carry identity, which makes resource the one form where the container is doing real work.

The same package without the container:

package t:demo@0.1.0;

record point { x: u32, y: u32 }

enum direction { north, south, east, west }

interface api {
  move-to: func(p: point);
  heading: func() -> direction;
}

world w {
  export api;
}

No use, and the composed component exports t:demo/api@0.1.0 with nothing beside it. A package-scope type would be in scope for every interface and world in the package, since there is no container to path through.

I do not think this needs a new encoding. Package Format already says each top-level WIT definition turns into a type export of the same kebab-name, so this is that slot with a plain type in it, (type (export "point") (record ...)). Interfaces get a wrapping component-type with an import for every use to parameterize them, but these types are structural, so api needs no import to preserve point's identity; it carries the type as it would if point were declared inline. Resources are the exception, which is the other reason to leave resource out. That also settles naming, since every top-level definition lands in one export name space, so a package-scope point would collide with an interface point in the same package.

The shared name space is also what makes cross-package reference free. use t:demo/point@0.1.0; already parses, and with no interface named point in the dep the failure is interface not found in package, a resolution error rather than a syntax one, so dep:pkg/name can address a top-level type with no new syntax. One consequence: because these types are structural a consumer inlines them, so no import edge to t:demo appears in its binary, where useing a dep's interface today does leave one. That is the intended outcome, and it moves the type's provenance into the WIT source. I have notes on use ergonomics (a name list rather than one statement per type) and would rather follow up in a comment than widen this.

#598 is the same objection one level up, where @lukewagner writes that WIT "forces me to invent a namespace, package and world name that don't ultimately appear in the compiled component and are thus entirely superfluous". Types are the case where the invented name does appear.

On precedent for the size of the change, #313 and #340 changed what may appear at the top level of a .wit file, and the read there was that it "could be purely a WIT-level addition and not even require a change in encoding scheme". #137 reported that wasi-sockets' common-types.wit declared record, enum, and type at the top level; it was closed on the grounds that those files were incorrect, so the capability itself was never weighed. Every comparable IDL declares types at file or namespace scope with no container: protobuf, Cap'n Proto, Smithy, Thrift, FlatBuffers.

I think this is orthogonal to #372, since nested interfaces organize interfaces rather than let a type escape one.

The use case that pushed me here is annotation payload types in #695: a payload describes a WIT document, so it should not join a component's type surface, and there is no placement for it today. Filing this separately since it seems worth deciding on its own.

Thoughts?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions