Back to course

The Type Section

20 minbeginnerModule 2: DeclarationsContinues from wasm-leb128
20 min · Module 2: Declarations · Foundations

Why This Matters

WebAssembly has no type checking in the language sense. There is no int keyword to misspell and no way to write a function that returns the wrong kind of thing — not because the language checks, but because every function's signature is a number, and the number indexes a table of signatures that the module must contain.

That is the type section: a list of function types, and nothing else. It is the first section in the file, and it is first for the reason the section ordering explains — everything else refers to it. A function says "I have type 0", an import says "I have type 2", and a validator checks those numbers exist.

The consequence is a design choice that distinguishes WebAssembly from every other format in this collection. Types are structural, not nominal. Two modules that declare (i32, i32) -> i32 declare the same type, and there is no name to get wrong, no header to mismatch, no namespace to resolve. The cost is that you cannot write a recursive type without a type section entry to point at, which is why every real type system with recursion needs a mechanism for it — and why that mechanism is a separate proposal rather than part of the core.

A Simple Model

Three signatures, from a module with three functions. The whole section is sixteen bytes:

000a: 03 60 02 7f 7f 01 7f 60 01 7f 01 7f 60 01 7f 00
      |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |  |
      |  |  |  |  |  |  |  |  |  |  |  |  |  |  +----- type 2: one i32 param,
      |  |  |  |  |  |  |  |  |  |  |  |  |  |        NO results (0x00)
      |  |  |  |  |  |  |  |  |  |  |  |  |  +------------ 0x7f: one i32 param
      |  |  |  |  |  |  |  |  |  |  |  |  +----------------- 0x01: one result
      |  |  |  |  |  |  |  |  |  |  |  +-------------------- type 1: (i32) -> i32
      |  |  |  |  |  |  |  |  |  |  +-------------------------- 0x60: a function type
      |  |  |  |  |  |  |  |  |  +------------------------------------- 0x01: one result
      |  |  |  |  |  |  |  |  +------------------------------------------- 0x7f: i32
      |  |  |  |  |  |  |  +----------------------------------------------- 0x7f: i32
      |  |  |  |  |  |  +-------------------------------------------------- 0x02: TWO params
      |  |  |  |  |  +----------------------------------------------------- type 0: (i32,i32) -> i32
      |  |  |  |  +-------------------------------------------------------- 0x60: a function type
      |  +------------------------------------------------------------- 0x03: THREE types
      +------------------------------------------------------------------ start of payload

Three shapes and only three shapes in the whole file, which is the first thing to notice: every entry begins with 0x60. That byte is a form tag, and the type section's only form today is "function". It exists so the section can grow a second form later without ambiguity, and it is the same idea as the DW_TAG_ prefix that lets DWARF add a DIE kind without renumbering.

The five value types

Four numeric types and one placeholder. Every value in a WebAssembly function is one of these:

ByteTypeSizeNotes
0x7fi3232 bitsSigned or unsigned depending on the operation. WebAssembly has no separate uint
0x7ei6464 bitsSame split as i32 at a different width
0x7df3232 bitsIEEE 754 binary32, little-endian
0x7cf6464 bitsIEEE 754 binary64, little-endian
0x7bv128128 bitsThe SIMD proposal. Present in the encoding, absent from this course's samples

Two things to notice. There is no pointer, no array, no struct, no bool, and no void. A function with no results has a result count of zero, not a "void" type, and the last type above encodes as 01 7f 00 — one i32 parameter and no results. A type system this small is what makes a validator tractable enough to run before a module is trusted.

And the sign is an operation, not a type.i32 covers both -1 and 4294967295; which one a bit pattern means depends on whether you used i32.add or i32.lt_u. That is not an oversight — it is what lets a language with a rich integer model compile to a type system with two integers, and it is the same reason the LEB128 concept found a disassembler printing i32.const unsigned.

The Actual Detail

Why an indirection at all

Here is the thing that makes the type section necessary rather than decorative. From the same module, the function section is three bytes:

0041: 02 00 01
      |  |  +-- function 1 has type 1
      |  +----- function 0 has type 0
      +-------- TWO functions

Two functions, two type numbers. Neither the signature nor the parameter count is repeated. And the effect compounds, because the same signature can be named from three places — a function, an import, and an export:

type 0  (i32, i32) -> i32    named by: function 0
type 1  (i32) -> i32           named by: function 1
type 2  (i32) -> ()            named by: import env.log

So a module with fifty functions using five distinct signatures stores five types and fifty numbers, rather than fifty copies of the signature. The savings are real and they are not the only benefit: because a type is a number, structural identity is free. Two functions that declare the same type index are guaranteed to have the same signature, with no comparison of lists required.

The cost, and it is not small. Nothing stops a producer from declaring (i32) -> i32 as type 1 and (i32) -> i32 again as type 7. The format does not require types to be deduplicated, so func[0] and func[1] can have identical signatures under different numbers, and an equality test on a signature is not an equality test on a type index. This bites a tool that tries to match an imported function to a locally-defined one by comparing type indices: it will report no match for two functions that are the same function. The correct test compares the structures, which means walking both type sections — and that is a real cost to pay for a format that refused to make types nominal.

Reading the whole section back

The decoder shipped with this course reads the three entries and prints them as signatures, and this is the output for the section above:

$ python3 wasm_decode.py decls.wasm
  [0] @0xb  (func (param i32 i32) (result i32))
  [1] @0x11 (func (param i32) (result i32))
  [2] @0x16 (func (param i32) (result -))

The trailing - is a deliberate choice in the decoder rather than a missing value: the result count is genuinely zero, and printing an empty list is more honest than printing the word "void", which the format does not have. It is a small example of a general point about reading binary formats — a field that can be zero is not a field that can be absent, and a printer that renders both as nothing has thrown away a distinction the format made.

And the offsets it reports are worth checking rather than accepting. It prints 0xb, 0x11 and 0x16, and the arithmetic confirms all three:

0x0a  03                    count = 3
0x0b  60 02 7f 7f 01 7f        type 0: 6 bytes, ends 0x10
0x11  60 01 7f 01 7f           type 1: 5 bytes, ends 0x15
0x16  60 01 7f 00              type 2: 4 bytes, ends 0x19
0x1a                            payload ends; 0x0a + 1 + 6 + 5 + 4 = 0x1a

Note what the first offset is not: the section's payload starts at 0x0a, and 0x0a holds the count. Type 0 begins one byte later, at 0x0b, because the count comes first. That is a one-byte difference between a plausible reading and the right one, and it is exactly the class of error that a decoder can make while still producing correct values — the entries are decoded correctly, the positions are reported wrongly, and nothing downstream can tell.

A real one, found while writing this paragraph. The draft of this concept claimed the decoder shipped with the course was off by one on these offsets, on the strength of a quick mental count. It is not — the check above shows the reported offsets are right, and it was the mental count that was wrong. The episode is worth recording anyway, because it is the shape of mistake this course keeps making and warns about: a claim about the data that felt obvious enough to skip the arithmetic. The three rules that would have caught it are cheap. Count the bytes yourself and compare. Ask what the total has to be and check it. And when a claim contradicts a tool you have not verified, believe the tool until the arithmetic says otherwise. Every one of the verified findings in this course survived because its arithmetic was done, and this one would have shipped as a confident falsehood if it had not been checked.

A Real Example

What the indirection buys a producer, and where it costs a consumer. Both directions matter, and the second is the one that catches people.

FOR A PRODUCER 50 functions, 5 distinct signatures with the indirection: 5 types + 50 one-byte numbers = 55 bytes without it: 50 signatures inline, averaging 6 bytes = 300 bytes and a fatter saving when a signature is long -- a ten-parameter function costs one byte instead of twenty FOR A CONSUMER LOOKING FOR A MATCH WRONG: compare type indices func A has type 1, the import has type 7, both are (i32) -> i32, so a naive match fails RIGHT: walk both type sections and compare structurally for each index in A's types: if the imported index's entry is the same list of the same valtype bytes: match and note that this is O(types x signature length)

The right-hand column is the real cost, and it is worth understanding rather than resenting. Because the format makes types structural and represents them positionally, structural equality requires a lookup. A nominal system — where two types are the same if they have the same name — gets equality for free by comparing pointers. A structural system has to compare the structures, which means reading two arrays of variable-length lists and comparing them element by element.

Three consequences follow, and each is a place a tool can go wrong:

That last point is a good place to see the format's design pressure. A type system small enough that structural equality is a byte comparison is a type system that cannot express recursion, because a recursive type is a cycle and a cycle has no finite byte representation. WebAssembly's answer has been to keep the core flat and put recursion in a separate proposal with its own representation. The simplicity of the type section is not an accident of implementation; it is what buys the validator's speed, and the price is paid somewhere else.

Try It Yourself

$ wasm-objdump -x decls.wasm | sed -n '/^Type/,/^Import/p'
$ python3 courses/wasm/assets/samples/wasm_decode.py --hex decls.wasm

Check Your Understanding

Without looking back: a module's type section is 03 60 02 7f 7f 01 7f 60 01 7f 01 7f 60 01 7f 00. Three functions are declared, with type indices 0, 1 and 1. What are the three signatures, and why does the second and third function sharing an index matter more than it looks?

Apply It

You are writing an import resolver. Your tool matches a module's calls to imported functions by finding a local function with a matching signature, so it can rewrite the import away. It works on every module except one, where it reports "no local implementation of env.log" — even though the module contains a function that is textually identical. Nothing errors; the module is valid. You have the object and the type sections for both. What is the most likely cause, what is the one-line check, and what is the general rule your matcher is missing?

The general habit: an index into a per-file table is an identity only inside that file. The moment a comparison crosses files, resolve the indices to what they point at first — and do it before the bug is in front of a user.

Connect

WebAssembly's structural types are the opposite of DWARF's. A DWARF type is a graph of DIEs joined by DW_AT_type references: a struct names its members, a member names its type, a pointer names a base type, and equality between two type descriptions is a graph-walking problem that no reader can shortcut. WebAssembly has no such graph — a type is a flat list of valtype bytes — which is why its validator can check a whole module's types quickly and why a signature match is a byte comparison.

The comparison with COFF is about the other half of the problem. COFF has no types at all: a symbol is 18 bytes with a storage class and no signature, and a linker checks that a symbol exists rather than that it is callable. WebAssembly traded that for a type table, and got arity checking, JIT specialisation on known signatures, and the ability to reject a mismatched import at validation time instead of at run time. Both formats answer "does this symbol exist"; only one answers "can I call it correctly", and the price is a section that has to be present even when every function is () -> ().

And the 0x60 form tag has a direct precedent in the COFF auxiliary records and the DWARF DW_TAG_ prefix. All three formats reserve a small value to say "which kind of record follows", and all three do it for the same reason: so the set of records can grow without a new section and without renumbering. In COFF it is the StorageClass; in DWARF it is the tag; in WebAssembly it is a single 0x60 that is currently the only legal value in the type section.

Next: Imports and Index Spaces — the section that decides which number a function has, and why that number is not what you would guess.