Reading the Type Section

This page answers one task: read the type section of a .wasm file by hand — understand every byte — and see how the rest of the module refers to the signatures it declares.

Prerequisites

  • [ ] xxd or any hex viewer, and WABT’s wasm-objdump to check your reading.
  • [ ] The LEB128 background from understanding LEB128 encoding.
  • [ ] A small module to decode — the example below is built from WAT.

Why signatures live in their own section

Every function in a WebAssembly module has a signature: the types of its parameters and results. Rather than repeating a signature for every function, the binary format collects distinct signatures in the type section — section id 1 — and everything else refers to them by index. The function section lists, for each function defined in the module, the index of its type. Imports of functions name a type index. call_indirect carries a type index that the engine checks at the call. Blocks with parameters or multiple results name a type index too.

That indirection keeps modules small — a large program may have thousands of functions but only a few dozen distinct signatures — and makes signature checks cheap: two functions have the same type if they have the same type index, or, across modules, if their canonicalised types are equal. Reading the type section is therefore the first step to reading anything else about a module’s functions.

How other sections refer to the type section The type section declares distinct signatures once. The function section, imports, call_indirect instructions and multi-value blocks all refer to those signatures by index, so a signature is stored once however many functions share it. type section type[0] (i32,i32)→i32 function section func 3 uses type 0 import section env.log uses type 1 call_indirect checks against type 0

Step 1 — build a module and dump it

;; types.wat
(module
  (import "env" "log" (func $log (param i32)))
  (func $add (export "add") (param i32 i32) (result i32)
    (i32.add (local.get 0) (local.get 1)))
  (func $half (export "half") (param f64) (result f64)
    (f64.mul (local.get 0) (f64.const 0.5))))
wat2wasm types.wat -o types.wasm
xxd types.wasm | head -3
00000000: 0061 736d 0100 0000 010f 0360 017f 0060  .asm.......`...`
00000010: 027f 7f01 7f60 017c 017c 020b 0103 656e  .....`.|.|....en

After the 8-byte header — 00 61 73 6d (\0asm) and version 01 00 00 00 — the first section starts at offset 8.

Step 2 — decode the section header

01        section id 1: type section
0f        section size: 15 bytes (LEB128)
03        number of types: 3 (LEB128)

Every section starts with a one-byte id and a LEB128 size, which lets a decoder skip sections it does not care about. The type section’s content begins with the number of entries.

Step 3 — decode each function type

Each entry starts with the byte 0x60, which marks a function type, followed by a vector of parameter types and a vector of result types. Each vector is a LEB128 count followed by one byte per value type:

60 01 7f 00          type[0]: (param i32) → ()           — env.log
60 02 7f 7f 01 7f    type[1]: (param i32 i32) → (i32)    — add
60 01 7c 01 7c       type[2]: (param f64) → (f64)        — half

The value-type bytes are fixed codes: 0x7f is i32, 0x7e is i64, 0x7d is f32, 0x7c is f64, 0x7b is v128, 0x70 is funcref and 0x6f is externref. They are chosen so that, read as signed LEB128, they are small negative numbers — a detail that lets block types share the same encoding space as type indices.

The type section, annotated byte by byte The 17 bytes of the example module's type section with each byte group labelled — section id and size, entry count, and three function types with their parameter and result vectors. 01 0f section id 1, size 15 bytes 03 three type entries 60 01 7f 00 func (i32) → () 60 02 7f 7f 01 7f func (i32, i32) → (i32) 60 01 7c 01 7c func (f64) → (f64)

Step 4 — follow the references

The function section — id 3 — maps each defined function to a type index. Imports come first in the function index space, so the import uses type 0 and is function 0; add is function 1 with type 1; half is function 2 with type 2:

wasm-objdump -x types.wasm | sed -n '/^Type/,/^Code/p'
Type[3]:
 - type[0] (i32) -> nil
 - type[1] (i32, i32) -> i32
 - type[2] (f64) -> f64
Import[1]:
 - func[0] sig=0  <- env.log
Function[2]:
 - func[1] sig=1 
 - func[2] sig=2 

sig= in the output is the type index. If you add a second function with the signature (i32, i32) → i32, the type section does not grow: both functions point at type 1. Toolchains deduplicate signatures automatically.

Step 5 — read types in larger modules

Real modules have a few dozen to a few hundred types. Two quick queries tell you a lot:

wasm-objdump -x app.wasm | grep -c '^ - type\['                         # number of distinct signatures
wasm-objdump -x app.wasm | grep -oE 'sig=[0-9]+' | sort | uniq -c | sort -rn | head -5   # most-used signatures

The most-used signatures are usually small helpers — (i32) → i32, (i32, i32) → () — reflecting how compiled code passes pointers. Signatures with many i32 parameters are often functions taking several pointers and lengths. When debugging a call_indirect signature mismatch, this is where you look up what type the call expected and what type the function in the table actually has, as described in calling function pointers with call_indirect.

Decoding with a script instead of by hand

Reading a type section by hand once is the best way to understand it. Reading it a second time is a job for a script, and a short one is enough because the encoding is so regular. The structure of a decoder mirrors the bytes: read the section header, read a count, then for each entry read the form byte, a count and that many value-type bytes for parameters, and the same again for results.

function readTypeSection(bytes, offset) {
  let p = offset;
  const uleb = () => { let r = 0, s = 0, b; do { b = bytes[p++]; r |= (b & 0x7f) << s; s += 7; } while (b & 0x80); return r >>> 0; };
  const VT = { 0x7f: "i32", 0x7e: "i64", 0x7d: "f32", 0x7c: "f64", 0x7b: "v128", 0x70: "funcref", 0x6f: "externref" };
  const count = uleb(), types = [];
  for (let i = 0; i < count; i++) {
    if (bytes[p++] !== 0x60) throw new Error("non-function type form at entry " + i);
    const params = Array.from({ length: uleb() }, () => VT[bytes[p++]]);
    const results = Array.from({ length: uleb() }, () => VT[bytes[p++]]);
    types.push(`(${params.join(", ")}) -> (${results.join(", ")})`);
  }
  return types;
}

Pair it with the section walker from parsing a Wasm module header in JavaScript and you can list a module’s signatures in a browser without any tools installed.

How the type section is evolving

The type section is where the GC proposal makes its largest change to the binary format. Besides function types (0x60), it adds struct types (0x5f) and array types (0x5e), recursive type groups (0x4e) for types that refer to each other, and subtyping declarations that let one struct type extend another. A module compiled from Kotlin, Dart or Java to WasmGC has a type section dominated by these. The decoding approach is the same — an entry form byte followed by its fields — but there are more forms, and type equivalence becomes structural across recursion groups. If you decode GC modules, read the type section with a current tool such as wasm-tools print rather than by hand; the details are covered in using Wasm GC for managed languages.

Expected output

Your manual reading and wasm-objdump agree:

type[0] (i32) -> nil         ← 60 01 7f 00
type[1] (i32, i32) -> i32    ← 60 02 7f 7f 01 7f
type[2] (f64) -> f64         ← 60 01 7c 01 7c

Gotchas

  • Treating counts as single bytes. Counts and sizes are LEB128. They are one byte below 128 and longer above it.
  • Forgetting imports in the function index space. Function indices count imported functions first; the function section only lists defined ones.
  • Assuming one type per function. Many functions share types. Look up the index; do not count positions.
  • Old decoders failing on new type forms. GC and other proposals add type encodings. Use a current tool for modern modules.

Performance note

Type deduplication is a real size win in large modules. A 2.3 MB C++ module had 14,200 functions but only 612 distinct types; storing a signature per function would have added about 95 KB. Engines also canonicalise types at load time, so call_indirect checks compare small integers rather than full signatures.

Distinct types versus functions in three real modules Number of defined functions and number of distinct function types in three compiled modules of different sizes, showing how heavily signatures are shared. count (log-like spread) small Rust module, functions 412 small Rust module, types 48 large C++ app, functions 14,200 large C++ app, types 612

Frequently Asked Questions

Is the type section required? Only if the module has functions or other entities that need types. An empty module has no sections at all.

Can two type entries be identical? Yes, the format allows it, but toolchains deduplicate. Engines treat identical function types as equivalent for call_indirect.

Where do block types come from? Simple blocks use a single value-type byte or 0x40 for no result. Blocks with parameters or several results use a type index into this section.

How do I find a function’s signature from its name? Look up the function’s index in the name section, then its type index in the function section, then the type here; wasm-objdump -x does all three.

← Back to Wasm Binary Format Deep Dive