Repository: bytecodealliance/wasm-tools
Pull request #1606 (2024-06-10 23:58:49)

Add `lann/wasm-wave` to crates 
This is an import of https://github.com/lann/wasm-wave to a new home in this repository!

I imported the code at release 0.6.0 to `crates/wasm-wave`

I made minor fixes to: 
* fit in workspace
* delete dependency on wasmtime (those trait impls will be upstreamed into the wasmtime crate under a flag once this lands)
* and fix some benign warnings https://github.com/lann/wasm-wave/pull/36
* replace the use of snapbox/tryfn in the tests with a bespoke implementation, because snapbox and some of its deps dont support wasm32-wasi and the functionality the tests were really using from that crate was pretty minor.


pchickey: @lann would appreciate your review and approval on this as well!

Patch:
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..684c5c1e
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+*.waves text eol=lf
+*.out text eol=lf
diff --git a/Cargo.lock b/Cargo.lock
index dd46f31d..98a59b1c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -131,6 +131,12 @@ version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80"
 
+[[package]]
+name = "beef"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1"
+
 [[package]]
 name = "bincode"
 version = "1.3.3"
@@ -638,6 +644,12 @@ dependencies = [
  "miniz_oxide",
 ]
 
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
 [[package]]
 name = "fuzz-stats"
 version = "0.0.0"
@@ -851,6 +863,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
 [[package]]
 name = "leb128"
 version = "0.2.5"
@@ -898,6 +916,39 @@ version = "0.4.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
 
+[[package]]
+name = "logos"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "161971eb88a0da7ae0c333e1063467c5b5727e7fb6b710b8db4814eade3a42e8"
+dependencies = [
+ "logos-derive",
+]
+
+[[package]]
+name = "logos-codegen"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e31badd9de5131fdf4921f6473d457e3dd85b11b7f091ceb50e4df7c3eeb12a"
+dependencies = [
+ "beef",
+ "fnv",
+ "lazy_static",
+ "proc-macro2",
+ "quote",
+ "regex-syntax",
+ "syn 2.0.60",
+]
+
+[[package]]
+name = "logos-derive"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c2a69b3eb68d5bd595107c9ee58d7e07fe2bb5e360cc85b0f084dedac80de0a"
+dependencies = [
+ "logos-codegen",
+]
+
 [[package]]
 name = "mach2"
 version = "0.4.2"
@@ -1695,6 +1746,17 @@ dependencies = [
  "wit-smith",
 ]
 
+[[package]]
+name = "wasm-wave"
+version = "0.210.0"
+dependencies = [
+ "anyhow",
+ "indexmap 2.2.6",
+ "logos",
+ "thiserror",
+ "wit-parser 0.210.0",
+]
+
 [[package]]
 name = "wasmparser"
 version = "0.202.0"
diff --git a/Cargo.toml b/Cargo.toml
index c3d369d3..1690376c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,6 +36,7 @@ members = [
   'crates/c-api',
   'crates/fuzz-stats',
   'crates/wasm-mutate-stats',
+  'crates/wasm-wave',
   'fuzz',
   'crates/wit-encoder',
   'crates/wit-parser/fuzz',
diff --git a/ci/publish.rs b/ci/publish.rs
index 60418a29..af0ff3fa 100644
--- a/ci/publish.rs
+++ b/ci/publish.rs
@@ -31,6 +31,7 @@ const CRATES_TO_PUBLISH: &[&str] = &[
     "wit-encoder",
     "wasm-compose",
     "wit-smith",
+    "wasm-wave",
     "wasm-tools",
 ];
 
diff --git a/crates/wasm-wave/Cargo.toml b/crates/wasm-wave/Cargo.toml
new file mode 100644
index 00000000..430d8f8a
--- /dev/null
+++ b/crates/wasm-wave/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "wasm-wave"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+authors = ["lann.martin@fermyon.com"]
+description = "WebAssembly Value Encoding"
+documentation = "https://docs.rs/wasm-wave"
+categories = ["wasm", "encoding", "parser-implementations"]
+repository = "https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-wave"
+readme = "README.md"
+
+[features]
+default = ["wit"]
+wit = ["dep:wit-parser"]
+
+[dependencies]
+indexmap.workspace = true
+logos = "0.14.0"
+thiserror = "1.0.48"
+wit-parser = { workspace = true, optional = true }
+
+[dev-dependencies]
+anyhow.workspace = true
diff --git a/crates/wasm-wave/README.md b/crates/wasm-wave/README.md
new file mode 100644
index 00000000..5b3e010d
--- /dev/null
+++ b/crates/wasm-wave/README.md
@@ -0,0 +1,274 @@
+# WAVE: Web Assembly Value Encoding
+
+WAVE is a human-oriented text encoding of WebAssembly Component Model values. It is designed to be consistent with the
+[WIT IDL format](https://github.com/WebAssembly/component-model/blob/main/design/mvp/WIT.md).
+
+|Type|Example Values
+|---|---
+|Bools|`true`, `false`
+|Integers|`123`, `-9`
+|Floats|`3.14`, `6.022e+23`, `nan`, `-inf`
+|Chars|`'x'`, `'☃︎'`, `'\''`, `'\u{0}'`
+|Strings|`"abc\t123"`
+|Tuples|`("abc", 123)`
+|Lists|`[1, 2, 3]`
+|Records|`{field-a: 1, field-b: "two"}`
+|Variants|`days(30)`, `forever`
+|Enums|`south`, `west`
+|Options|`"flat some"`, `some("explicit some")`, `none`
+|Results|`"flat ok"`, `ok("explicit ok")`, `err("oops")`
+|Flags|`{read, write}`, `{}`
+
+## Usage
+
+```rust
+use wasmtime::component::{Type, Val};
+
+let val: Val = wasm_wave::from_str(&Type::String, "\"👋 Hello, world! 👋\"").unwrap();
+println!("{}", wasm_wave::to_string(&val).unwrap());
+```
+
+→ `"👋 Hello, world! 👋"`
+
+## Encoding
+
+Values are encoded as Unicode text. UTF-8 should be used wherever an interoperable binary encoding is required.
+
+### Whitespace
+
+Whitespace is _insignificant between tokens_ and _significant within tokens_: keywords, labels, chars, and strings.
+
+### Comments
+
+Comments start with `//` and run to the end of the line.
+
+### Keywords
+
+Several tokens are reserved WAVE keywords: `true`, `false`, `inf`, `nan`, `some`, `none`, `ok`, `err`. Variant or enum cases that match one of these keywords must be prefixed with `%`.
+
+### Labels
+
+Kebab-case labels are used for record fields, variant cases, enum cases, and flags. Labels use ASCII alphanumeric characters and hyphens, following the [Wit identifier syntax](https://github.com/WebAssembly/component-model/blob/main/design/mvp/WIT.md#identifiers):
+
+- Labels consist of one or more hypen-separated words.
+  - `one`, `two-words`
+- Words consist of one ASCII letter followed by any number of ASCII alphanumeric characters.
+  - `q`, `abc123`
+- Each word can contain lowercase or uppercase characters but not both; each word in a label can use a different (single) case.
+  - `HTTP3`, `method-GET`
+- Any label may be prefixed with `%`; this is not part of the label itself but allows for representing labels that would otherwise be parsed as keywords.
+  - `%err`, `%non-keyword`
+
+### Bools
+
+Bools are encoded as one of the keywords `false` or `true`.
+
+### Integers
+
+Integers are encoded as base-10 numbers.
+
+> TBD: hex/bin repr? e.g. `0xab`, `0b1011`
+
+### Floats
+
+Floats are encoded as JSON numbers or one of the keywords `nan`, (not a number) `inf` (infinity), or `-inf` (negative infinity).
+
+### Chars
+
+Chars are encoded as `'<char>'`, where `<char>` is one of:
+
+- a single [Unicode Scalar Value](https://unicode.org/glossary/#unicode_scalar_value)
+- one of these escapes:
+  - `\'` → `'`
+  - `\"` → `"`
+  - `\\` → `\`
+  - `\t` → U+9 (HT)
+  - `\n` → U+A (LF)
+  - `\r` → U+D (CR)
+  - `\u{···}` → U+··· (where `···` is a hexadecimal Unicode Scalar Value)
+
+Escaping newline (`\n`), `\`, and `'` is mandatory for chars.
+
+### Strings
+
+Strings are encoded as a double-quote-delimited sequence of `<char>`s (as for [Chars](#chars)).
+
+Escaping newline (`\n`), `\`, and `"` is mandatory for strings.
+
+### Multiline Strings
+
+A multiline string begins with `"""` followed immediately by a line break (`\n` or `\r\n`) and ends with a line break, zero or more spaces, and `"""`. The number of spaces immediately preceding the ending `"""` determines the indent level of the entire multiline string. Every other line break in the string must be followed by at least this many spaces which are then omitted ("dedented") from the decoded string.
+
+Each line break in the encoded string except for the first and last is decoded as a newline character (`\n`).
+
+Escaping `\` is mandatory for multiline strings. Escaping carriage return (`\r`) is mandatory immediately before a literal newline character (`\n`) if it is to be retained. Escaping `"` is mandatory where necessary to break up any sequence of `"""` within a string, even if the first `"` is escaped (i.e. `\"""` is prohibited).
+
+```python
+"""
+A single line
+"""
+```
+→ `"A single line"`
+
+```python
+"""
+    Indentation determined
+      by ending delimiter
+  """
+```
+→
+```clike
+"  Indentation determined\n    by ending delimiter"
+```
+
+```python
+"""
+  Must escape carriage return at end of line: \r
+  Must break up double quote triplets: ""\""
+  """
+```
+→
+```clike
+"Must escape carriage return at end of line: \r\nMust break up double quote triplets: \"\"\"\""
+```
+
+### Tuples
+
+Tuples are encoded as a parenthesized sequence of comma-separated values. Trailing commas are permitted.
+
+`tuple<u8, string>` → `(123, "abc")`
+
+### Lists
+
+Lists are encoded as a square-braketed sequence of comma-separated values. Trailing commas are permitted.
+
+`list<char>` → `[]`, `['a', 'b', 'c']`
+
+### Records
+
+Records are encoded as curly-braced set of comma-separated record entries. Trailing commas are permitted. Each record entry consists of a field label, a colon, and a value. Fields may be present in any order. Record entries with the `option`-typed value `none` may be omitted; if all of a record's fields are omitted in this way the "empty" record must be encoded as `{:}` (to disambiguate from an empty `flags` value).
+
+```clike
+record example {
+  must-have: u8,
+  optional: option<u8>,
+}
+```
+
+→ `{must-have: 123}` = `{must-have: 123, optional: none,}`
+
+```clike
+record all-optional {
+  optional: option<u8>,
+}
+```
+
+→ `{:}` = `{optional: none}`
+
+> Note: Field labels _may_ be prefixed with `%` but this is never required.
+
+### Variants
+
+Variants are encoded as a case label. If the case has a payload, the label is followed by the parenthesized case payload value.
+
+If a variant case matches a WAVE keyword it must be prefixed with `%`.
+
+```clike
+variant response {
+  empty,
+  body(list<u8>),
+  err(string),
+}
+```
+
+→ `empty`, `body([79, 75])`, `%err("oops")`
+
+### Enums
+
+Enums are encoded as a case label.
+
+If an enum case matches a WAVE keyword it must be prefixed with `%`.
+
+`enum status { ok, not-found }` → `%ok`, `not-found`
+
+### Options
+
+Options may be encoded in their variant form (e.g. `some(...)` or `none`). A `some` value may also be encoded as the "flat" payload value itself, but only if the payload is not an option or result type.
+
+- `option<u8>` → `123` = `some(123)`
+
+### Results
+
+Results may be encoded in their variant form (e.g. `ok(...)`, `err("oops")`). An `ok` value may also be encoded as the "flat" payload value itself, but only if it has a payload which is not an option or result type.
+
+- `result<u8>` → `123` = `ok(123)`
+- `result<_, string>` → `ok`, `err("oops")`
+- `result` → `ok`, `err`
+
+### Flags
+
+Flags are encoded as a curly-braced set of comma-separated flag labels in any order. Trailing commas are permitted.
+
+`flags perms { read, write, exec }` → `{write, read,}`
+
+> Note: Flags _may_ be prefixed with `%` but this is never required.
+
+> TBD: Allow record form? `{read: true, write: true, exec: false}`
+
+### Resources
+
+> TBD (`<named-type>(<idx>)`?)
+
+## Appendix: Function calls
+
+Some applications may benefit from a standard way to encode function calls and/or results, described here.
+
+Function calls can be encoded as some application-dependent function identifier (such as a kebab-case label) followed by parenthesized function arguments.
+
+If function results need to be encoded along with the call, they can be separated from the call by `->`.
+
+```clike
+my-func("param")
+    
+with-result() -> ok("result")
+```
+
+### Function arguments
+
+Arguments are encoded as a sequence of comma-separated values.
+
+Any number of `option` `none` values at the end of the sequence may be omitted.
+
+```clike
+// f: func(a: option<u8>, b: option<u8>, c: option<u8>)
+// all equivalent:
+f(some(1))
+f(some(1), none)
+f(some(1), none, none)
+```
+
+> TBD: Named-parameter encoding? e.g.
+> `my-func(named-param: 1)`
+> Could allow omitting "middle" `none` params.
+
+### Function results
+
+Results are encoded in one of several ways depending on the number of result values:
+
+- Any number of result values may be encoded as a parenthesized sequence of comma-separated result entries. Each result entry consists of a label (for named results) or zero-based index number, a colon, and the result value. Result entry ordering must match the function definition.
+- Zero result values are encoded as `()` or omitted entirely.
+- A single result value may be encoded as the "flat" result value itself.
+
+```clike
+-> ()
+
+-> some("single result")
+// or
+-> (0: some("single result"))
+    
+-> (result-a: "abc", result-b: 123)
+```
+
+---
+
+:ocean:
diff --git a/crates/wasm-wave/contrib/vscode/.gitignore b/crates/wasm-wave/contrib/vscode/.gitignore
new file mode 100644
index 00000000..cdda1252
--- /dev/null
+++ b/crates/wasm-wave/contrib/vscode/.gitignore
@@ -0,0 +1,1 @@
+/*.vsix
diff --git a/crates/wasm-wave/contrib/vscode/.vscode/launch.json b/crates/wasm-wave/contrib/vscode/.vscode/launch.json
new file mode 100644
index 00000000..0e191b59
--- /dev/null
+++ b/crates/wasm-wave/contrib/vscode/.vscode/launch.json
@@ -0,0 +1,17 @@
+// A launch configuration that launches the extension inside a new window
+// Use IntelliSense to learn about possible attributes.
+// Hover to view descriptions of existing attributes.
+// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+{
+	"version": "0.2.0",
+    "configurations": [
+        {
+            "name": "Extension",
+            "type": "extensionHost",
+            "request": "launch",
+            "args": [
+                "--extensionDevelopmentPath=${workspaceFolder}"
+            ]
+        }
+    ]
+}
\ No newline at end of file
diff --git a/crates/wasm-wave/contrib/vscode/.vscodeignore b/crates/wasm-wave/contrib/vscode/.vscodeignore
new file mode 100644
index 00000000..f16fa550
--- /dev/null
+++ b/crates/wasm-wave/contrib/vscode/.vscodeignore
@@ -0,0 +1,1 @@
+.vscode/**
diff --git a/crates/wasm-wave/contrib/vscode/README.md b/crates/wasm-wave/contrib/vscode/README.md
new file mode 100644
index 00000000..7d782fc3
--- /dev/null
+++ b/crates/wasm-wave/contrib/vscode/README.md
@@ -0,0 +1,8 @@
+# WAVE VS Code Extension
+
+Basic syntax highlighting only. Contributions very welcome!
+
+```console
+$ npx vsce package
+$ code --install-extension wasm-wave-0.0.1.vsix
+```
diff --git a/crates/wasm-wave/contrib/vscode/language-configuration.json b/crates/wasm-wave/contrib/vscode/language-configuration.json
new file mode 100644
index 00000000..ec5e7d95
--- /dev/null
+++ b/crates/wasm-wave/contrib/vscode/language-configuration.json
@@ -0,0 +1,28 @@
+{
+    "comments": {
+        // symbol used for single line comment. Remove this entry if your language does not support line comments
+        "lineComment": "//"
+    },
+    // symbols used as brackets
+    "brackets": [
+        ["{", "}"],
+        ["[", "]"],
+        ["(", ")"]
+    ],
+    // symbols that are auto closed when typing
+    "autoClosingPairs": [
+        ["{", "}"],
+        ["[", "]"],
+        ["(", ")"],
+        ["\"", "\""],
+        ["'", "'"]
+    ],
+    // symbols that can be used to surround a selection
+    "surroundingPairs": [
+        ["{", "}"],
+        ["[", "]"],
+        ["(", ")"],
+        ["\"", "\""],
+        ["'", "'"]
+    ]
+}
\ No newline at end of file
diff --git a/crates/wasm-wave/contrib/vscode/package.json b/crates/wasm-wave/contrib/vscode/package.json
new file mode 100644
index 00000000..e182a7d0
--- /dev/null
+++ b/crates/wasm-wave/contrib/vscode/package.json
@@ -0,0 +1,29 @@
+{
+  "name": "wasm-wave",
+  "displayName": "Wave",
+  "description": "",
+  "version": "0.0.1",
+  "engines": {
+    "vscode": "^1.85.0"
+  },
+  "categories": [
+    "Programming Languages"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-wave"
+  },
+  "contributes": {
+    "languages": [{
+      "id": "wave",
+      "aliases": ["WAVE", "wave"],
+      "extensions": [".wave", ".waves"],
+      "configuration": "./language-configuration.json"
+    }],
+    "grammars": [{
+      "language": "wave",
+      "scopeName": "source.wave",
+      "path": "./syntaxes/wave.tmLanguage.json"
+    }]
+  }
+}
diff --git a/crates/wasm-wave/contrib/vscode/syntaxes/wave.tmLanguage.json b/crates/wasm-wave/contrib/vscode/syntaxes/wave.tmLanguage.json
new file mode 100644
index 00000000..376533a2
--- /dev/null
+++ b/crates/wasm-wave/contrib/vscode/syntaxes/wave.tmLanguage.json
@@ -0,0 +1,80 @@
+{
+	"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
+	"name": "WAVE",
+	"patterns": [
+		{ "include": "#comment" },
+		{ "include": "#raw-keyword"},
+		{ "include": "#number" },
+		{ "include": "#keyword-constant" },
+		{ "include": "#keyword-label" },
+		{ "include": "#label" },
+		{ "include": "#char" },
+		{ "include": "#string" }
+	],
+	"repository": {
+		"comment": {
+			"patterns": [{
+				"name": "comment.line.double-slash.wave",
+				"match": "(//)[^\\n]*",
+				"captures": {
+					"1": {
+						"name": "punctuation.definition.comment.wave"
+					}
+				}
+			}]
+		},
+		"raw-keyword": {
+			"patterns": [{
+				"name": "variable.other.label.wave",
+				"match": "%(true|false|some|none|ok|err|inf|nan)\\b"
+			}]
+		},
+		"number": {
+			"patterns": [
+				{
+					"name": "constant.numeric.wave",
+					"match": "-?(0|([1-9][0-9]*))(\\.[0-9]+)?([eE][-+]?[0-9]+)?"
+				},
+				{
+					"name": "constant.numeric.wave",
+					"match": "\\b(nan|inf|-inf)\\b"
+				}
+			]
+		},
+		"keyword-constant": {
+			"patterns": [{
+				"name": "constant.language.wave",
+				"match": "\\b(true|false)\\b"
+			}]
+		},
+		"keyword-label": {
+			"patterns": [{
+				"name": "variable.language.label.wave",
+				"match": "\\b(some|none|ok|err)\\b"
+			}]
+		},
+		"label": {
+			"patterns": [{
+				"name": "variable.other.label.wave",
+				"match": "\\b%?([a-z][a-z0-9]*|[A-Z][A-Z0-9]*)(-([a-z][a-z0-9]*|[A-Z][A-Z0-9]*))*\\b"
+			}]
+		},
+		"char": {
+			"name": "constant.character.wave",
+			"begin": "'",
+			"end": "'",
+			"patterns": [{ "include": "#char-escape" }]
+		},
+		"string": {
+			"name": "string.quoted.double.wave",
+			"begin": "\"",
+			"end": "\"",
+			"patterns": [{ "include": "#char-escape"}]
+		},
+		"char-escape": {
+			"name": "constant.character.escape.wave",
+			"match": "\\\\(['\"tnr\\\\]|u\\{[0-9a-fA-F]+\\})"
+		}
+	},
+	"scopeName": "source.wave"
+}
\ No newline at end of file
diff --git a/crates/wasm-wave/src/ast.rs b/crates/wasm-wave/src/ast.rs
new file mode 100644
index 00000000..a82c6c49
--- /dev/null
+++ b/crates/wasm-wave/src/ast.rs
@@ -0,0 +1,473 @@
+//! Abstract syntax tree types
+
+use std::{borrow::Cow, collections::HashMap, str::FromStr};
+
+use crate::{
+    lex::Span,
+    parser::{ParserError, ParserErrorKind},
+    strings::{unescape, StringPartsIter},
+    wasm::{WasmType, WasmTypeKind, WasmValue, WasmValueError},
+};
+
+/// A WAVE AST node.
+#[derive(Clone, Debug)]
+pub struct Node {
+    ty: NodeType,
+    span: Span,
+    children: Vec<Node>,
+}
+
+impl Node {
+    pub(crate) fn new(
+        ty: NodeType,
+        span: impl Into<Span>,
+        children: impl IntoIterator<Item = Node>,
+    ) -> Self {
+        Self {
+            ty,
+            span: span.into(),
+            children: Vec::from_iter(children),
+        }
+    }
+
+    /// Returns this node's type.
+    pub fn ty(&self) -> NodeType {
+        self.ty
+    }
+
+    /// Returns this node's span.
+    pub fn span(&self) -> Span {
+        self.span.clone()
+    }
+
+    /// Returns a bool value if this node represents a bool.
+    pub fn as_bool(&self) -> Result<bool, ParserError> {
+        match self.ty {
+            NodeType::BoolTrue => Ok(true),
+            NodeType::BoolFalse => Ok(false),
+            _ => Err(self.error(ParserErrorKind::InvalidType)),
+        }
+    }
+
+    /// Returns a number value of the given type (integer or float) if this node
+    /// can represent a number of that type.
+    pub fn as_number<T: FromStr>(&self, src: &str) -> Result<T, ParserError> {
+        self.ensure_type(NodeType::Number)?;
+        self.slice(src)
+            .parse()
+            .map_err(|_| self.error(ParserErrorKind::InvalidValue))
+    }
+
+    /// Returns a char value if this node represents a valid char.
+    pub fn as_char(&self, src: &str) -> Result<char, ParserError> {
+        self.ensure_type(NodeType::Char)?;
+        let inner = &src[self.span.start + 1..self.span.end - 1];
+        let (ch, len) = if inner.starts_with('\\') {
+            unescape(inner).ok_or_else(|| self.error(ParserErrorKind::InvalidEscape))?
+        } else {
+            let ch = inner.chars().next().unwrap();
+            (ch, ch.len_utf8())
+        };
+        // Verify length
+        if len != inner.len() {
+            return Err(self.error(ParserErrorKind::MultipleChars));
+        }
+        Ok(ch)
+    }
+
+    /// Returns a str value if this node represents a valid string.
+    pub fn as_str<'src>(&self, src: &'src str) -> Result<Cow<'src, str>, ParserError> {
+        let mut parts = self.iter_str(src)?;
+        let Some(first) = parts.next().transpose()? else {
+            return Ok("".into());
+        };
+        match parts.next().transpose()? {
+            // Single part may be borrowed
+            None => Ok(first),
+            // Multiple parts must be collected into a single owned String
+            Some(second) => {
+                let s: String = [Ok(first), Ok(second)]
+                    .into_iter()
+                    .chain(parts)
+                    .collect::<Result<_, _>>()?;
+                Ok(s.into())
+            }
+        }
+    }
+
+    /// Returns an iterator of string "parts" which together form a decoded
+    /// string value if this node represents a valid string.
+    pub fn iter_str<'src>(
+        &self,
+        src: &'src str,
+    ) -> Result<impl Iterator<Item = Result<Cow<'src, str>, ParserError>>, ParserError> {
+        match self.ty {
+            NodeType::String => {
+                let span = self.span.start + 1..self.span.end - 1;
+                Ok(StringPartsIter::new(&src[span.clone()], span.start))
+            }
+            NodeType::MultilineString => {
+                let span = self.span.start + 3..self.span.end - 3;
+                Ok(StringPartsIter::new_multiline(
+                    &src[span.clone()],
+                    span.start,
+                )?)
+            }
+            _ => Err(self.error(ParserErrorKind::InvalidType)),
+        }
+    }
+
+    /// Returns an iterator of value nodes if this node represents a tuple.
+    pub fn as_tuple(&self) -> Result<impl ExactSizeIterator<Item = &Node>, ParserError> {
+        self.ensure_type(NodeType::Tuple)?;
+        Ok(self.children.iter())
+    }
+
+    /// Returns an iterator of value nodes if this node represents a list.
+    pub fn as_list(&self) -> Result<impl ExactSizeIterator<Item = &Node>, ParserError> {
+        self.ensure_type(NodeType::List)?;
+        Ok(self.children.iter())
+    }
+
+    /// Returns an iterator of field name and value node pairs if this node
+    /// represents a record value.
+    pub fn as_record<'this, 'src>(
+        &'this self,
+        src: &'src str,
+    ) -> Result<impl ExactSizeIterator<Item = (&'src str, &'this Node)>, ParserError> {
+        self.ensure_type(NodeType::Record)?;
+        Ok(self
+            .children
+            .chunks(2)
+            .map(|chunk| (chunk[0].as_label(src).unwrap(), &chunk[1])))
+    }
+
+    /// Returns a variant label and optional payload if this node can represent
+    /// a variant value.
+    pub fn as_variant<'this, 'src>(
+        &'this self,
+        src: &'src str,
+    ) -> Result<(&'src str, Option<&'this Node>), ParserError> {
+        match self.ty {
+            NodeType::Label => Ok((self.as_label(src)?, None)),
+            NodeType::VariantWithPayload => {
+                let label = self.children[0].as_label(src)?;
+                let value = &self.children[1];
+                Ok((label, Some(value)))
+            }
+            _ => Err(self.error(ParserErrorKind::InvalidType)),
+        }
+    }
+
+    /// Returns an enum value label if this node represents a label.
+    pub fn as_enum<'src>(&self, src: &'src str) -> Result<&'src str, ParserError> {
+        self.as_label(src)
+    }
+
+    /// Returns an option value if this node represents an option.
+    pub fn as_option(&self) -> Result<Option<&Node>, ParserError> {
+        match self.ty {
+            NodeType::OptionSome => Ok(Some(&self.children[0])),
+            NodeType::OptionNone => Ok(None),
+            _ => Err(self.error(ParserErrorKind::InvalidType)),
+        }
+    }
+
+    /// Returns a result value with optional payload value if this node
+    /// represents a result.
+    pub fn as_result(&self) -> Result<Result<Option<&Node>, Option<&Node>>, ParserError> {
+        let payload = self.children.first();
+        match self.ty {
+            NodeType::ResultOk => Ok(Ok(payload)),
+            NodeType::ResultErr => Ok(Err(payload)),
+            _ => Err(self.error(ParserErrorKind::InvalidType)),
+        }
+    }
+
+    /// Returns an iterator of flag labels if this node represents flags.
+    pub fn as_flags<'this, 'src: 'this>(
+        &'this self,
+        src: &'src str,
+    ) -> Result<impl Iterator<Item = &'src str> + 'this, ParserError> {
+        self.ensure_type(NodeType::Flags)?;
+        Ok(self.children.iter().map(|node| {
+            debug_assert_eq!(node.ty, NodeType::Label);
+            node.slice(src)
+        }))
+    }
+
+    fn as_label<'src>(&self, src: &'src str) -> Result<&'src str, ParserError> {
+        self.ensure_type(NodeType::Label)?;
+        let label = self.slice(src);
+        let label = label.strip_prefix('%').unwrap_or(label);
+        Ok(label)
+    }
+
+    /// Converts this node into the given typed value from the given input source.
+    pub fn to_wasm_value<V: WasmValue>(&self, ty: &V::Type, src: &str) -> Result<V, ParserError> {
+        Ok(match ty.kind() {
+            WasmTypeKind::Bool => V::make_bool(self.as_bool()?),
+            WasmTypeKind::S8 => V::make_s8(self.as_number(src)?),
+            WasmTypeKind::S16 => V::make_s16(self.as_number(src)?),
+            WasmTypeKind::S32 => V::make_s32(self.as_number(src)?),
+            WasmTypeKind::S64 => V::make_s64(self.as_number(src)?),
+            WasmTypeKind::U8 => V::make_u8(self.as_number(src)?),
+            WasmTypeKind::U16 => V::make_u16(self.as_number(src)?),
+            WasmTypeKind::U32 => V::make_u32(self.as_number(src)?),
+            WasmTypeKind::U64 => V::make_u64(self.as_number(src)?),
+            WasmTypeKind::Float32 => V::make_float32(self.as_number(src)?),
+            WasmTypeKind::Float64 => V::make_float64(self.as_number(src)?),
+            WasmTypeKind::Char => V::make_char(self.as_char(src)?),
+            WasmTypeKind::String => V::make_string(self.as_str(src)?),
+            WasmTypeKind::List => self.to_wasm_list(ty, src)?,
+            WasmTypeKind::Record => self.to_wasm_record(ty, src)?,
+            WasmTypeKind::Tuple => self.to_wasm_tuple(ty, src)?,
+            WasmTypeKind::Variant => self.to_wasm_variant(ty, src)?,
+            WasmTypeKind::Enum => self.to_wasm_enum(ty, src)?,
+            WasmTypeKind::Option => self.to_wasm_option(ty, src)?,
+            WasmTypeKind::Result => self.to_wasm_result(ty, src)?,
+            WasmTypeKind::Flags => self.to_wasm_flags(ty, src)?,
+            other => {
+                return Err(
+                    self.wasm_value_error(WasmValueError::UnsupportedType(other.to_string()))
+                )
+            }
+        })
+    }
+
+    /// Converts this node into the given types.
+    /// See [`crate::untyped::UntypedFuncCall::to_wasm_params`].
+    pub fn to_wasm_params<'types, V: WasmValue + 'static>(
+        &self,
+        types: impl IntoIterator<Item = &'types V::Type>,
+        src: &str,
+    ) -> Result<Vec<V>, ParserError> {
+        let mut types = types.into_iter();
+        let mut values = self
+            .as_tuple()?
+            .map(|node| {
+                let ty = types.next().ok_or_else(|| {
+                    ParserError::with_detail(
+                        ParserErrorKind::InvalidParams,
+                        node.span().clone(),
+                        "more param(s) than expected",
+                    )
+                })?;
+                node.to_wasm_value::<V>(ty, src)
+            })
+            .collect::<Result<Vec<_>, _>>()?;
+        // Handle trailing optional fields
+        for ty in types {
+            if ty.kind() == WasmTypeKind::Option {
+                values.push(V::make_option(ty, None).map_err(|err| self.wasm_value_error(err))?);
+            } else {
+                return Err(ParserError::with_detail(
+                    ParserErrorKind::InvalidParams,
+                    self.span.end - 1..self.span.end,
+                    "missing required param(s)",
+                ));
+            }
+        }
+        Ok(values)
+    }
+
+    fn to_wasm_list<V: WasmValue>(&self, ty: &V::Type, src: &str) -> Result<V, ParserError> {
+        let element_type = ty.list_element_type().unwrap();
+        let elements = self
+            .as_list()?
+            .map(|node| node.to_wasm_value(&element_type, src))
+            .collect::<Result<Vec<_>, _>>()?;
+        V::make_list(ty, elements).map_err(|err| self.wasm_value_error(err))
+    }
+
+    fn to_wasm_record<V: WasmValue>(&self, ty: &V::Type, src: &str) -> Result<V, ParserError> {
+        let values = self.as_record(src)?.collect::<HashMap<_, _>>();
+        let record_fields = ty.record_fields().collect::<Vec<_>>();
+        let fields = record_fields
+            .iter()
+            .map(|(name, field_type)| {
+                let value = match (values.get(name.as_ref()), field_type.kind()) {
+                    (Some(node), _) => node.to_wasm_value(field_type, src)?,
+                    (None, WasmTypeKind::Option) => V::make_option(field_type, None)
+                        .map_err(|err| self.wasm_value_error(err))?,
+                    _ => {
+                        return Err(
+                            self.wasm_value_error(WasmValueError::MissingField(name.to_string()))
+                        );
+                    }
+                };
+                Ok((name.as_ref(), value))
+            })
+            .collect::<Result<Vec<_>, _>>()?;
+        V::make_record(ty, fields).map_err(|err| self.wasm_value_error(err))
+    }
+
+    fn to_wasm_tuple<V: WasmValue>(&self, ty: &V::Type, src: &str) -> Result<V, ParserError> {
+        let types = ty.tuple_element_types().collect::<Vec<_>>();
+        let values = self.as_tuple()?;
+        if types.len() != values.len() {
+            return Err(
+                self.wasm_value_error(WasmValueError::WrongNumberOfTupleValues {
+                    want: types.len(),
+                    got: values.len(),
+                }),
+            );
+        }
+        let values = ty
+            .tuple_element_types()
+            .zip(self.as_tuple()?)
+            .map(|(ty, node)| node.to_wasm_value(&ty, src))
+            .collect::<Result<Vec<_>, _>>()?;
+        V::make_tuple(ty, values).map_err(|err| self.wasm_value_error(err))
+    }
+
+    fn to_wasm_variant<V: WasmValue>(&self, ty: &V::Type, src: &str) -> Result<V, ParserError> {
+        let (label, payload) = self.as_variant(src)?;
+        let payload_type = ty
+            .variant_cases()
+            .find_map(|(case, payload)| (case == label).then_some(payload))
+            .ok_or_else(|| self.wasm_value_error(WasmValueError::UnknownCase(label.into())))?;
+        let payload_value = self.to_wasm_maybe_payload(label, &payload_type, payload, src)?;
+        V::make_variant(ty, label, payload_value).map_err(|err| self.wasm_value_error(err))
+    }
+
+    fn to_wasm_enum<V: WasmValue>(&self, ty: &V::Type, src: &str) -> Result<V, ParserError> {
+        V::make_enum(ty, self.as_enum(src)?).map_err(|err| self.wasm_value_error(err))
+    }
+
+    fn to_wasm_option<V: WasmValue>(&self, ty: &V::Type, src: &str) -> Result<V, ParserError> {
+        let payload_type = ty.option_some_type().unwrap();
+        let value = match self.ty {
+            NodeType::OptionSome => {
+                self.to_wasm_maybe_payload("some", &Some(payload_type), self.as_option()?, src)?
+            }
+            NodeType::OptionNone => {
+                self.to_wasm_maybe_payload("none", &None, self.as_option()?, src)?
+            }
+            _ if flattenable(payload_type.kind()) => Some(self.to_wasm_value(&payload_type, src)?),
+            _ => {
+                return Err(self.error(ParserErrorKind::InvalidType));
+            }
+        };
+        V::make_option(ty, value).map_err(|err| self.wasm_value_error(err))
+    }
+
+    fn to_wasm_result<V: WasmValue>(&self, ty: &V::Type, src: &str) -> Result<V, ParserError> {
+        let (ok_type, err_type) = ty.result_types().unwrap();
+        let value = match self.ty {
+            NodeType::ResultOk => {
+                Ok(self.to_wasm_maybe_payload("ok", &ok_type, self.as_result()?.unwrap(), src)?)
+            }
+            NodeType::ResultErr => Err(self.to_wasm_maybe_payload(
+                "err",
+                &err_type,
+                self.as_result()?.unwrap_err(),
+                src,
+            )?),
+            _ => match ok_type {
+                Some(ty) if flattenable(ty.kind()) => Ok(Some(self.to_wasm_value(&ty, src)?)),
+                _ => return Err(self.error(ParserErrorKind::InvalidType)),
+            },
+        };
+        V::make_result(ty, value).map_err(|err| self.wasm_value_error(err))
+    }
+
+    fn to_wasm_flags<V: WasmValue>(&self, ty: &V::Type, src: &str) -> Result<V, ParserError> {
+        V::make_flags(ty, self.as_flags(src)?).map_err(|err| self.wasm_value_error(err))
+    }
+
+    fn to_wasm_maybe_payload<V: WasmValue>(
+        &self,
+        case: &str,
+        payload_type: &Option<V::Type>,
+        payload: Option<&Node>,
+        src: &str,
+    ) -> Result<Option<V>, ParserError> {
+        match (payload_type.as_ref(), payload) {
+            (Some(ty), Some(node)) => Ok(Some(node.to_wasm_value(ty, src)?)),
+            (None, None) => Ok(None),
+            (Some(_), None) => {
+                Err(self.wasm_value_error(WasmValueError::MissingPayload(case.into())))
+            }
+            (None, Some(_)) => {
+                Err(self.wasm_value_error(WasmValueError::UnexpectedPayload(case.into())))
+            }
+        }
+    }
+
+    fn wasm_value_error(&self, err: WasmValueError) -> ParserError {
+        ParserError::with_source(ParserErrorKind::WasmValueError, self.span(), err)
+    }
+
+    pub(crate) fn slice<'src>(&self, src: &'src str) -> &'src str {
+        &src[self.span()]
+    }
+
+    fn ensure_type(&self, ty: NodeType) -> Result<(), ParserError> {
+        if self.ty == ty {
+            Ok(())
+        } else {
+            Err(self.error(ParserErrorKind::InvalidType))
+        }
+    }
+
+    fn error(&self, kind: ParserErrorKind) -> ParserError {
+        ParserError::new(kind, self.span())
+    }
+}
+
+fn flattenable(kind: WasmTypeKind) -> bool {
+    // TODO: Consider wither to allow flattening an option in a result or vice-versa.
+    !matches!(kind, WasmTypeKind::Option | WasmTypeKind::Result)
+}
+
+/// The type of a WAVE AST [`Node`].
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum NodeType {
+    /// Boolean `true`
+    BoolTrue,
+    /// Boolean `false`
+    BoolFalse,
+    /// Number
+    /// May be an integer or float, including `nan`, `inf`, `-inf`
+    Number,
+    /// Char
+    /// Span includes delimiters.
+    Char,
+    /// String
+    /// Span includes delimiters.
+    String,
+    /// Multiline String
+    /// Span includes delimiters.
+    MultilineString,
+    /// Tuple
+    /// Child nodes are the tuple values.
+    Tuple,
+    /// List
+    /// Child nodes are the list values.
+    List,
+    /// Record
+    /// Child nodes are field Label, value pairs, e.g.
+    /// `[<field 1>, <value 1>, <field 2>, <value 2>, ...]`
+    Record,
+    /// Label
+    /// In value position may represent an enum value or variant case (without payload).
+    Label,
+    /// Variant case with payload
+    /// Child nodes are variant case Label and payload value.
+    VariantWithPayload,
+    /// Option `some`
+    /// Child node is the payload value.
+    OptionSome,
+    /// Option `none`
+    OptionNone,
+    /// Result `ok`
+    /// Has zero or one child node for the payload value.
+    ResultOk,
+    /// Result `err`
+    /// Has zero or one child node for the payload value.
+    ResultErr,
+    /// Flags
+    /// Child nodes are flag Labels.
+    Flags,
+}
diff --git a/crates/wasm-wave/src/lex.rs b/crates/wasm-wave/src/lex.rs
new file mode 100644
index 00000000..27d71d9c
--- /dev/null
+++ b/crates/wasm-wave/src/lex.rs
@@ -0,0 +1,137 @@
+//! Lexing types
+
+use std::fmt::Display;
+
+pub use logos::Span;
+
+/// A WAVE `logos::Lexer`
+pub type Lexer<'source> = logos::Lexer<'source, Token>;
+
+/// A WAVE token
+#[derive(Clone, Copy, Debug, PartialEq, Eq, logos::Logos)]
+#[logos(error = Option<Span>)]
+#[logos(skip r"[ \t\n\r]+")]
+#[logos(skip r"//[^\n]*")]
+#[logos(subpattern label_word = r"[a-z][a-z0-9]*|[A-Z][A-Z0-9]*")]
+#[logos(subpattern char_escape = r#"\\['"tnr\\]|\\u\{[0-9a-fA-F]{1,6}\}"#)]
+pub enum Token {
+    /// The `{` symbol
+    #[token("{")]
+    BraceOpen,
+    /// The `}` symbol
+    #[token("}")]
+    BraceClose,
+
+    /// The `(` symbol
+    #[token("(")]
+    ParenOpen,
+    /// The `)` symbol
+    #[token(")")]
+    ParenClose,
+
+    /// The `[` symbol
+    #[token("[")]
+    BracketOpen,
+    /// The `]` symbol
+    #[token("]")]
+    BracketClose,
+
+    /// The `:` symbol
+    #[token(":")]
+    Colon,
+
+    /// The `,` symbol
+    #[token(",")]
+    Comma,
+
+    /// A number literal
+    #[regex(r"-?(0|([1-9][0-9]*))(\.[0-9]+)?([eE][-+]?[0-9]+)?")]
+    #[token("-inf")]
+    Number,
+
+    /// A label or keyword
+    #[regex(r"%?(?&label_word)(-(?&label_word))*")]
+    LabelOrKeyword,
+
+    /// A char literal
+    #[regex(r#"'([^\\'\n]{1,4}|(?&char_escape))'"#, validate_char)]
+    Char,
+
+    /// A string literal
+    #[regex(r#""([^\\"\n]|(?&char_escape))*""#)]
+    String,
+
+    /// A multi-line string literal
+    #[token(r#"""""#, lex_multiline_string)]
+    MultilineString,
+}
+
+impl Display for Token {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{self:?}")
+    }
+}
+
+fn validate_char(lex: &mut Lexer) -> Result<(), Option<Span>> {
+    let s = &lex.slice()[1..lex.slice().len() - 1];
+    if s.starts_with('\\') || s.chars().count() == 1 {
+        Ok(())
+    } else {
+        Err(Some(lex.span()))
+    }
+}
+
+fn lex_multiline_string(lex: &mut Lexer) -> bool {
+    if let Some(end) = lex.remainder().find(r#"""""#) {
+        lex.bump(end + 3);
+        true
+    } else {
+        false
+    }
+}
+
+/// A WAVE keyword
+#[derive(Clone, Copy, Debug, PartialEq)]
+#[allow(missing_docs)]
+pub enum Keyword {
+    True,
+    False,
+    Some,
+    None,
+    Ok,
+    Err,
+    Inf,
+    Nan,
+}
+
+impl Keyword {
+    /// Returns any keyword exactly matching the given string.
+    pub fn decode(raw_label: &str) -> Option<Self> {
+        Some(match raw_label {
+            "true" => Self::True,
+            "false" => Self::False,
+            "some" => Self::Some,
+            "none" => Self::None,
+            "ok" => Self::Ok,
+            "err" => Self::Err,
+            "inf" => Self::Inf,
+            "nan" => Self::Nan,
+            _ => return None,
+        })
+    }
+}
+
+impl Display for Keyword {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(match self {
+            Keyword::True => "true",
+            Keyword::False => "false",
+            Keyword::Some => "some",
+            Keyword::None => "none",
+            Keyword::Ok => "ok",
+            Keyword::Err => "err",
+            Keyword::Inf => "inf",
+            Keyword::Nan => "nan",
+        })
+    }
+}
diff --git a/crates/wasm-wave/src/lib.rs b/crates/wasm-wave/src/lib.rs
new file mode 100644
index 00000000..4edde423
--- /dev/null
+++ b/crates/wasm-wave/src/lib.rs
@@ -0,0 +1,69 @@
+//! Web Assembly Value Encoding
+//!
+//! WAVE is a human-oriented text encoding of WebAssembly Component Model values.
+//!
+//! For more information, see the [README](https://github.com/bytecodealliance/wasm-tools/tree/main/crates/wasm-wave#readme).
+#![deny(missing_docs)]
+
+pub mod ast;
+pub mod lex;
+pub mod parser;
+mod strings;
+pub mod untyped;
+pub mod value;
+pub mod wasm;
+pub mod writer;
+
+use parser::Parser;
+use wasm::WasmValue;
+use writer::Writer;
+
+/// Parses a [`WasmValue`] from the given WAVE-encoded string.
+/// ```
+/// # fn main() -> Result<(), wasm_wave::parser::ParserError> {
+/// use wasm_wave::{wasm::WasmValue, value::{Type, Value}};
+/// let val: Value = wasm_wave::from_str(&Type::CHAR, r"'\u{1F44B}'")?;
+/// assert_eq!(val, Value::make_char('👋'));
+/// # Ok(())
+/// # }
+/// ```
+pub fn from_str<V: WasmValue>(ty: &V::Type, s: &str) -> Result<V, parser::ParserError> {
+    let mut parser = Parser::new(s);
+
+    let value = parser.parse_value(ty)?;
+
+    // Ensure that we've parsed the entire string.
+    parser.finish()?;
+
+    Ok(value)
+}
+
+/// Returns the given [`WasmValue`] as a WAVE-encoded string.
+/// ```
+/// # fn main() -> Result<(), wasm_wave::writer::WriterError> {
+/// use wasm_wave::{wasm::WasmValue, value::Value};
+/// let wave_str = wasm_wave::to_string(&Value::make_char('\u{1F44B}'))?;
+/// assert_eq!(wave_str, "'👋'");
+/// # Ok(())
+/// # }
+pub fn to_string(val: &impl WasmValue) -> Result<String, writer::WriterError> {
+    let mut buf = vec![];
+    Writer::new(&mut buf).write_value(val)?;
+    Ok(String::from_utf8(buf).unwrap_or_else(|err| panic!("invalid UTF-8: {err:?}")))
+}
+
+fn canonicalize_nan32(val: f32) -> f32 {
+    if val.is_nan() {
+        f32::from_bits(0x7fc00000)
+    } else {
+        val
+    }
+}
+
+fn canonicalize_nan64(val: f64) -> f64 {
+    if val.is_nan() {
+        f64::from_bits(0x7ff8000000000000)
+    } else {
+        val
+    }
+}
diff --git a/crates/wasm-wave/src/parser.rs b/crates/wasm-wave/src/parser.rs
new file mode 100644
index 00000000..f35e5e34
--- /dev/null
+++ b/crates/wasm-wave/src/parser.rs
@@ -0,0 +1,673 @@
+//! Parsing types
+
+use std::{collections::HashSet, error::Error, fmt::Display};
+
+use indexmap::IndexMap;
+
+use crate::{
+    ast::{Node, NodeType},
+    lex::{Keyword, Lexer, Span, Token},
+    untyped::{UntypedFuncCall, UntypedValue},
+    WasmValue,
+};
+
+/// A Web Assembly Value Encoding parser.
+pub struct Parser<'source> {
+    lex: Lexer<'source>,
+    curr: Option<Token>,
+}
+
+impl<'source> Parser<'source> {
+    /// Returns a new Parser of the given source.
+    pub fn new(source: &'source str) -> Self {
+        Self::with_lexer(Lexer::new(source))
+    }
+
+    /// Returns a new Parser with the given [`Lexer`].
+    pub fn with_lexer(lexer: Lexer<'source>) -> Self {
+        Self {
+            lex: lexer,
+            curr: None,
+        }
+    }
+
+    /// Parses a WAVE-encoded value of the given [`crate::wasm::WasmType`] into a
+    /// corresponding [`WasmValue`].
+    pub fn parse_value<V: WasmValue>(&mut self, ty: &V::Type) -> Result<V, ParserError> {
+        let node = self.parse_node()?;
+        node.to_wasm_value(ty, self.lex.source())
+    }
+
+    /// Parses a WAVE-encoded value into an [`UntypedValue`].
+    pub fn parse_raw_value(&mut self) -> Result<UntypedValue<'source>, ParserError> {
+        let node = self.parse_node()?;
+        Ok(UntypedValue::new(self.lex.source(), node))
+    }
+
+    /// Parses a function name followed by a WAVE-encoded, parenthesized,
+    /// comma-separated sequence of values into an [`UntypedFuncCall`].
+    pub fn parse_raw_func_call(&mut self) -> Result<UntypedFuncCall<'source>, ParserError> {
+        self.advance()?;
+        let name = self.parse_label()?;
+        self.advance()?;
+        self.expect_token(Token::ParenOpen)?;
+        let params = self.parse_tuple()?;
+        Ok(UntypedFuncCall::new(self.lex.source(), name, params))
+    }
+
+    /// Returns an error if any significant input remains unparsed.
+    pub fn finish(&mut self) -> Result<(), ParserError> {
+        match self.lex.clone().spanned().next() {
+            None => Ok(()),
+            Some((_, span)) => Err(ParserError::new(
+                ParserErrorKind::TrailingCharacters,
+                span.clone(),
+            )),
+        }
+    }
+
+    fn parse_node(&mut self) -> Result<Node, ParserError> {
+        Ok(match self.advance()? {
+            Token::Number => self.leaf_node(NodeType::Number),
+            Token::Char => self.leaf_node(NodeType::Char),
+            Token::String => self.leaf_node(NodeType::String),
+            Token::MultilineString => self.leaf_node(NodeType::MultilineString),
+            Token::ParenOpen => self.parse_tuple()?,
+            Token::BracketOpen => self.parse_list()?,
+            Token::BraceOpen => self.parse_record_or_flags()?,
+            Token::LabelOrKeyword => match Keyword::decode(self.slice()) {
+                Some(Keyword::True) => self.leaf_node(NodeType::BoolTrue),
+                Some(Keyword::False) => self.leaf_node(NodeType::BoolFalse),
+                Some(Keyword::Some) => self.parse_option(NodeType::OptionSome)?,
+                Some(Keyword::None) => self.parse_option(NodeType::OptionNone)?,
+                Some(Keyword::Ok) => self.parse_result(NodeType::ResultOk)?,
+                Some(Keyword::Err) => self.parse_result(NodeType::ResultErr)?,
+                Some(Keyword::Inf | Keyword::Nan) => self.leaf_node(NodeType::Number),
+                None => self.parse_label_maybe_payload()?,
+            },
+            Token::BraceClose
+            | Token::ParenClose
+            | Token::BracketClose
+            | Token::Colon
+            | Token::Comma => return Err(self.unexpected_token()),
+        })
+    }
+
+    fn parse_tuple(&mut self) -> Result<Node, ParserError> {
+        let start = self.span().start;
+        let children = self.parse_comma_separated_nodes(Token::ParenClose)?;
+        let span = start..self.span().end;
+        if children.is_empty() {
+            return Err(ParserError::new(ParserErrorKind::EmptyTuple, span));
+        }
+        Ok(Node::new(NodeType::Tuple, span, children))
+    }
+
+    fn parse_list(&mut self) -> Result<Node, ParserError> {
+        let start = self.span().start;
+        let children = self.parse_comma_separated_nodes(Token::BracketClose)?;
+        Ok(Node::new(NodeType::List, start..self.span().end, children))
+    }
+
+    fn parse_record_or_flags(&mut self) -> Result<Node, ParserError> {
+        let start = self.span().start;
+        self.advance()?;
+
+        match self.token() {
+            // Handle empty record (`{:}`)
+            Token::Colon => {
+                self.advance()?; // Advance to `}`
+                self.expect_token(Token::BraceClose)?;
+                return Ok(Node::new(NodeType::Record, start..self.span().end, []));
+            }
+            // Handle empty flags (`{}`)
+            Token::BraceClose => return Ok(Node::new(NodeType::Flags, start..self.span().end, [])),
+            _ => (),
+        }
+
+        // Check for a following `:` to distinguish records from flags
+        if self.next_is(Token::Colon) {
+            self.finish_record(start)
+        } else {
+            self.finish_flags(start)
+        }
+    }
+
+    fn finish_record(&mut self, start: usize) -> Result<Node, ParserError> {
+        let mut seen = HashSet::with_capacity(1);
+        let mut children = Vec::with_capacity(2);
+        loop {
+            // Parse field label
+            let label = self.parse_label()?;
+            // Check for duplicate fields
+            let field = self.slice().trim_start_matches('%');
+            if !seen.insert(field) {
+                return Err(ParserError::with_detail(
+                    ParserErrorKind::DuplicateField,
+                    label.span(),
+                    format!("{field:?}"),
+                ));
+            }
+            // Parse colon
+            self.advance()?;
+            self.expect_token(Token::Colon)?;
+            // Parse value
+            let value = self.parse_node()?;
+            children.extend([label, value]);
+            // Parse comma and/or end of record
+            if self.advance()? == Token::Comma {
+                self.advance()?;
+            }
+            if self.token() == Token::BraceClose {
+                break;
+            }
+        }
+        Ok(Node::new(
+            NodeType::Record,
+            start..self.span().end,
+            children,
+        ))
+    }
+
+    fn finish_flags(&mut self, start: usize) -> Result<Node, ParserError> {
+        let mut flags = IndexMap::with_capacity(1);
+        loop {
+            // Parse flag label
+            let label = self.parse_label()?;
+            // Insert and check for duplicate flags
+            let span = label.span();
+            let flag = self.slice().trim_start_matches('%');
+            if flags.insert(flag, label).is_some() {
+                return Err(ParserError::with_detail(
+                    ParserErrorKind::DuplicateFlag,
+                    span,
+                    format!("{flag:?}"),
+                ));
+            }
+            // Parse comma and/or end of flags
+            if self.advance()? == Token::Comma {
+                self.advance()?;
+            }
+            if self.token() == Token::BraceClose {
+                break;
+            }
+        }
+        Ok(Node::new(
+            NodeType::Flags,
+            start..self.span().end,
+            flags.into_values(),
+        ))
+    }
+
+    fn parse_label_maybe_payload(&mut self) -> Result<Node, ParserError> {
+        let start = self.span().start;
+        let label = self.parse_label()?;
+        if self.next_is(Token::ParenOpen) {
+            self.advance()?;
+            let payload = self.parse_node()?;
+            self.advance()?;
+            self.expect_token(Token::ParenClose)?;
+            Ok(Node::new(
+                NodeType::VariantWithPayload,
+                start..self.span().end,
+                [label, payload],
+            ))
+        } else {
+            Ok(label)
+        }
+    }
+
+    fn parse_option(&mut self, ty: NodeType) -> Result<Node, ParserError> {
+        let start = self.span().start;
+        let payload = match ty {
+            NodeType::OptionSome => {
+                self.advance()?;
+                self.expect_token(Token::ParenOpen)?;
+                let payload = self.parse_node()?;
+                self.advance()?;
+                self.expect_token(Token::ParenClose)?;
+                Some(payload)
+            }
+            NodeType::OptionNone => None,
+            _ => unreachable!(),
+        };
+        Ok(Node::new(ty, start..self.span().end, payload))
+    }
+
+    fn parse_result(&mut self, ty: NodeType) -> Result<Node, ParserError> {
+        let start = self.span().start;
+        let mut payload = None;
+        if self.next_is(Token::ParenOpen) {
+            self.advance()?;
+            self.expect_token(Token::ParenOpen)?;
+            payload = Some(self.parse_node()?);
+            self.advance()?;
+            self.expect_token(Token::ParenClose)?;
+        }
+        Ok(Node::new(ty, start..self.span().end, payload))
+    }
+
+    fn parse_label(&mut self) -> Result<Node, ParserError> {
+        self.expect_token(Token::LabelOrKeyword)?;
+        Ok(self.leaf_node(NodeType::Label))
+    }
+
+    fn advance(&mut self) -> Result<Token, ParserError> {
+        let token = match self.lex.next() {
+            Some(Ok(token)) => token,
+            Some(Err(span)) => {
+                let span = span.unwrap_or_else(|| self.lex.span());
+                return Err(ParserError::new(ParserErrorKind::InvalidToken, span));
+            }
+            None => {
+                return Err(ParserError::new(
+                    ParserErrorKind::UnexpectedEnd,
+                    self.lex.span(),
+                ));
+            }
+        };
+        self.curr = Some(token);
+        Ok(token)
+    }
+
+    fn token(&self) -> Token {
+        self.curr.unwrap()
+    }
+
+    fn span(&self) -> Span {
+        self.lex.span()
+    }
+
+    fn slice(&self) -> &'source str {
+        &self.lex.source()[self.span()]
+    }
+
+    fn next_is(&mut self, token: Token) -> bool {
+        self.lex.clone().next().and_then(|res| res.ok()) == Some(token)
+    }
+
+    fn expect_token(&self, token: Token) -> Result<(), ParserError> {
+        if self.token() == token {
+            Ok(())
+        } else {
+            Err(self.unexpected_token())
+        }
+    }
+
+    fn unexpected_token(&self) -> ParserError {
+        ParserError::with_detail(ParserErrorKind::UnexpectedToken, self.span(), self.token())
+    }
+
+    fn parse_comma_separated_nodes(&mut self, end_token: Token) -> Result<Vec<Node>, ParserError> {
+        let mut nodes = vec![];
+        if self.next_is(end_token) {
+            self.advance()?;
+            return Ok(nodes);
+        }
+        loop {
+            nodes.push(self.parse_node()?);
+
+            match self.advance()? {
+                Token::Comma => {
+                    if self.next_is(end_token) {
+                        self.advance()?;
+                        break;
+                    }
+                }
+                _ => {
+                    self.expect_token(end_token)?;
+                    break;
+                }
+            }
+        }
+        Ok(nodes)
+    }
+
+    fn leaf_node(&self, ty: NodeType) -> Node {
+        Node::new(ty, self.span(), [])
+    }
+}
+
+/// A WAVE parsing error.
+#[derive(Debug)]
+pub struct ParserError {
+    kind: ParserErrorKind,
+    span: Span,
+    detail: Option<String>,
+    source: Option<Box<dyn Error + Send + Sync + 'static>>,
+}
+
+impl ParserError {
+    pub(crate) fn new(kind: ParserErrorKind, span: Span) -> Self {
+        Self {
+            kind,
+            span,
+            detail: None,
+            source: None,
+        }
+    }
+
+    pub(crate) fn with_detail(kind: ParserErrorKind, span: Span, detail: impl Display) -> Self {
+        Self {
+            kind,
+            span,
+            detail: Some(detail.to_string()),
+            source: None,
+        }
+    }
+
+    pub(crate) fn with_source(
+        kind: ParserErrorKind,
+        span: Span,
+        source: impl Into<Box<dyn Error + Send + Sync>>,
+    ) -> Self {
+        Self {
+            kind,
+            span,
+            detail: None,
+            source: Some(source.into()),
+        }
+    }
+
+    /// Returns the [`ParserErrorKind`] of this error.
+    pub fn kind(&self) -> ParserErrorKind {
+        self.kind
+    }
+
+    /// Returns the [`Span`] of this error.
+    pub fn span(&self) -> Span {
+        self.span.clone()
+    }
+
+    /// Returns any detail string for this error.
+    pub fn detail(&self) -> Option<&str> {
+        self.detail.as_deref()
+    }
+}
+
+impl Display for ParserError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if let Some(source) = &self.source {
+            write!(f, "{}: {} at {:?}", self.kind, source, self.span)
+        } else if let Some(detail) = &self.detail {
+            write!(f, "{}: {} at {:?}", self.kind, detail, self.span)
+        } else {
+            write!(f, "{} at {:?}", self.kind, self.span)
+        }
+    }
+}
+
+impl Error for ParserError {
+    fn source(&self) -> Option<&(dyn Error + 'static)> {
+        Some(self.source.as_deref()? as _)
+    }
+}
+
+/// The kind of a WAVE parsing error.
+#[derive(Clone, Copy, Debug, PartialEq)]
+#[non_exhaustive]
+#[allow(missing_docs)]
+pub enum ParserErrorKind {
+    EmptyTuple,
+    MultipleChars,
+    InvalidEscape,
+    InvalidMultilineString,
+    InvalidParams,
+    InvalidToken,
+    InvalidType,
+    InvalidValue,
+    TrailingCharacters,
+    UnexpectedEnd,
+    UnexpectedToken,
+    DuplicateField,
+    DuplicateFlag,
+    WasmValueError,
+}
+
+impl Display for ParserErrorKind {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let msg = match self {
+            ParserErrorKind::EmptyTuple => "empty tuple",
+            ParserErrorKind::MultipleChars => "multiple characters in char value",
+            ParserErrorKind::InvalidEscape => "invalid character escape",
+            ParserErrorKind::InvalidMultilineString => "invalid multiline string",
+            ParserErrorKind::InvalidParams => "invalid params",
+            ParserErrorKind::InvalidToken => "invalid token",
+            ParserErrorKind::InvalidType => "invalid value type",
+            ParserErrorKind::InvalidValue => "invalid value",
+            ParserErrorKind::TrailingCharacters => "trailing characters after value",
+            ParserErrorKind::UnexpectedEnd => "unexpected end of input",
+            ParserErrorKind::UnexpectedToken => "unexpected token",
+            ParserErrorKind::DuplicateField => "duplicate field",
+            ParserErrorKind::DuplicateFlag => "duplicate flag",
+            ParserErrorKind::WasmValueError => "error converting Wasm value",
+        };
+        write!(f, "{msg}")
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::value::{Type, Value};
+
+    #[test]
+    fn parse_option_or_result() {
+        let ty = Type::option(Type::BOOL);
+        assert_eq!(
+            parse_value("some(true)", &ty),
+            Value::make_option(&ty, Some(Value::make_bool(true))).unwrap()
+        );
+        let ty = Type::result(Some(Type::BOOL), None);
+        assert_eq!(
+            parse_value("ok(false)", &ty),
+            Value::make_result(&ty, Ok(Some(Value::make_bool(false)))).unwrap()
+        );
+    }
+
+    #[test]
+    fn parse_flat_option_or_result() {
+        let ty = Type::option(Type::BOOL);
+        assert_eq!(
+            parse_value("true", &ty),
+            Value::make_option(&ty, Some(Value::make_bool(true))).unwrap()
+        );
+        let ty = Type::result(Some(Type::BOOL), None);
+        assert_eq!(
+            parse_value("false", &ty),
+            Value::make_result(&ty, Ok(Some(Value::make_bool(false)))).unwrap()
+        );
+    }
+
+    #[test]
+    fn parse_record_reordering() {
+        let ty = Type::record([("red", Type::S32), ("green", Type::CHAR)]).unwrap();
+        // Parse the fields in the order they appear in the type.
+        assert_eq!(
+            parse_value("{red: 0, green: 'a'}", &ty),
+            Value::make_record(
+                &ty,
+                [
+                    ("red", Value::make_s32(0)),
+                    ("green", Value::make_char('a'))
+                ]
+            )
+            .unwrap()
+        );
+        // Parse the fields in reverse order.
+        assert_eq!(
+            parse_value("{green: 'a', red: 0}", &ty),
+            Value::make_record(
+                &ty,
+                [
+                    ("red", Value::make_s32(0)),
+                    ("green", Value::make_char('a'))
+                ]
+            )
+            .unwrap()
+        );
+    }
+
+    #[test]
+    fn parse_record_with_optional_fields() {
+        let field_ty = Type::option(Type::CHAR);
+        let ty = Type::record([("red", Type::S32), ("green", field_ty.clone())]).unwrap();
+        // Explicit `some`.
+        assert_eq!(
+            parse_value("{red: 0, green: some('a')}", &ty),
+            Value::make_record(
+                &ty,
+                [
+                    ("red", Value::make_s32(0)),
+                    (
+                        "green",
+                        Value::make_option(&field_ty, Some(Value::make_char('a'))).unwrap()
+                    )
+                ]
+            )
+            .unwrap()
+        );
+        // Flattened `some`.
+        assert_eq!(
+            parse_value("{red: 0, green: 'a'}", &ty),
+            Value::make_record(
+                &ty,
+                [
+                    ("red", Value::make_s32(0)),
+                    (
+                        "green",
+                        Value::make_option(&field_ty, Some(Value::make_char('a'))).unwrap()
+                    )
+                ]
+            )
+            .unwrap()
+        );
+        // Explicit `none`.
+        assert_eq!(
+            parse_value("{red: 0, green: none}", &ty),
+            Value::make_record(
+                &ty,
+                [
+                    ("red", Value::make_s32(0)),
+                    ("green", Value::make_option(&field_ty, None).unwrap())
+                ]
+            )
+            .unwrap()
+        );
+        // Implied `none`.
+        assert_eq!(
+            parse_value("{red: 0}", &ty),
+            Value::make_record(
+                &ty,
+                [
+                    ("red", Value::make_s32(0)),
+                    ("green", Value::make_option(&field_ty, None).unwrap())
+                ]
+            )
+            .unwrap()
+        );
+    }
+
+    #[test]
+    fn parse_flag_reordering() {
+        let ty = Type::flags(["hot", "cold"]).unwrap();
+        // Parse the flags in the order they appear in the type.
+        assert_eq!(
+            parse_value("{hot, cold}", &ty),
+            Value::make_flags(&ty, ["hot", "cold"]).unwrap()
+        );
+        // Parse the flags in reverse order.
+        assert_eq!(
+            parse_value("{cold, hot}", &ty),
+            Value::make_flags(&ty, ["hot", "cold"]).unwrap()
+        );
+    }
+
+    #[test]
+    fn parse_percent_identifiers() {
+        let ty = Type::record([("red", Type::S32), ("green", Type::CHAR)]).unwrap();
+        // Test identifiers with '%' prefixes.
+        assert_eq!(
+            parse_value("{ %red: 0, %green: 'a' }", &ty),
+            Value::make_record(
+                &ty,
+                [
+                    ("red", Value::make_s32(0)),
+                    ("green", Value::make_char('a'))
+                ]
+            )
+            .unwrap()
+        );
+    }
+
+    #[test]
+    fn parse_prefixed_keyword_variant_cases() {
+        let ty = Type::list(
+            Type::variant([
+                ("true", Some(Type::U8)),
+                ("false", None),
+                ("inf", Some(Type::U8)),
+                ("nan", None),
+                ("some", Some(Type::U8)),
+                ("none", None),
+                ("ok", Some(Type::U8)),
+                ("err", None),
+            ])
+            .unwrap(),
+        );
+        parse_value(
+            "[%true(1), %false, %inf(1), %nan, %some(1), %none, %ok(1), %err]",
+            &ty,
+        );
+    }
+
+    #[test]
+    fn reject_unprefixed_keyword_enum_cases() {
+        let cases = ["true", "false", "inf", "nan", "none", "ok", "err"];
+        let ty = Type::enum_ty(cases).unwrap();
+        for case in cases {
+            let err = Parser::new(case).parse_value::<Value>(&ty).unwrap_err();
+            assert_eq!(err.kind(), ParserErrorKind::InvalidType);
+        }
+    }
+
+    #[test]
+    fn parse_unprefixed_keyword_fields() {
+        let ty = Type::record([
+            ("true", Type::U8),
+            ("false", Type::U8),
+            ("inf", Type::U8),
+            ("nan", Type::U8),
+            ("some", Type::U8),
+            ("none", Type::U8),
+            ("ok", Type::U8),
+            ("err", Type::U8),
+        ])
+        .unwrap();
+        parse_value(
+            "{true: 1, false: 1, inf: 1, nan: 1, some: 1, none: 1, ok: 1, err: 1}",
+            &ty,
+        );
+    }
+
+    #[test]
+    fn parse_unprefixed_keyword_flags() {
+        let ty = Type::flags(["true", "false", "inf", "nan", "some", "none", "ok", "err"]).unwrap();
+        parse_value("{true, false, inf, nan, some, none, ok, err}", &ty);
+    }
+
+    #[test]
+    fn reject_unprefixed_some_variant_case() {
+        let ty = Type::variant([("some", Some(Type::U8))]).unwrap();
+        let err = Parser::new("some(1)")
+            .parse_value::<Value>(&ty)
+            .unwrap_err();
+        assert_eq!(err.kind(), ParserErrorKind::InvalidType);
+    }
+
+    fn parse_value(input: &str, ty: &Type) -> Value {
+        Parser::new(input)
+            .parse_value(ty)
+            .unwrap_or_else(|err| panic!("error decoding {input:?}: {err}"))
+    }
+}
diff --git a/crates/wasm-wave/src/strings.rs b/crates/wasm-wave/src/strings.rs
new file mode 100644
index 00000000..90ed03d1
--- /dev/null
+++ b/crates/wasm-wave/src/strings.rs
@@ -0,0 +1,202 @@
+use std::{borrow::Cow, str::Split};
+
+use crate::parser::{ParserError, ParserErrorKind};
+
+/// An iterator over parsed string "parts".
+pub enum StringPartsIter<'a> {
+    Normal(StringParts<'a>),
+    Multiline(MultilineStringParts<'a>),
+}
+
+impl<'a> StringPartsIter<'a> {
+    /// Returns an iterator over string parts for the given substring containing
+    /// only the _inner_ contents (between quotes) of a single-line string.
+    /// The given pos is the source position of this substring for error reporting.
+    pub fn new(src: &'a str, pos: usize) -> Self {
+        Self::Normal(StringParts { src, pos })
+    }
+
+    /// Returns an iterator over string parts for the given substring containing
+    /// only the _inner_ contents (between quotes) of a multiline string.
+    /// The given pos is the source position of this substring for error reporting.
+    pub fn new_multiline(src: &'a str, pos: usize) -> Result<Self, ParserError> {
+        Ok(Self::Multiline(MultilineStringParts::new(src, pos)?))
+    }
+}
+
+impl<'a> Iterator for StringPartsIter<'a> {
+    type Item = Result<Cow<'a, str>, ParserError>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        match self {
+            StringPartsIter::Normal(parts) => parts.next(),
+            StringPartsIter::Multiline(parts) => parts.next(),
+        }
+    }
+}
+
+pub struct StringParts<'a> {
+    src: &'a str,
+    pos: usize,
+}
+
+impl<'a> StringParts<'a> {
+    fn next_part(&mut self) -> Result<Cow<'a, str>, ParserError> {
+        let (part, consumed) = match self.src.find('\\') {
+            Some(0) => {
+                let (esc_char, esc_len) = unescape(self.src).ok_or_else(|| {
+                    ParserError::new(ParserErrorKind::InvalidEscape, self.pos..self.pos + 1)
+                })?;
+                (Cow::Owned(esc_char.to_string()), esc_len)
+            }
+            Some(next_esc) => {
+                let esc_prefix = &self.src[..next_esc];
+                (Cow::Borrowed(esc_prefix), esc_prefix.len())
+            }
+            None => (Cow::Borrowed(self.src), self.src.len()),
+        };
+        self.src = &self.src[consumed..];
+        self.pos += consumed;
+        Ok(part)
+    }
+}
+
+impl<'a> Iterator for StringParts<'a> {
+    type Item = Result<Cow<'a, str>, ParserError>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        if self.src.is_empty() {
+            return None;
+        }
+        Some(self.next_part())
+    }
+}
+
+/// An iterator over parsed string "parts", each of which is either a literal
+/// substring of the source or a decoded escape sequence.
+pub struct MultilineStringParts<'a> {
+    curr: StringParts<'a>,
+    lines: Split<'a, char>,
+    next_pos: usize,
+    indent: &'a str,
+}
+
+impl<'a> MultilineStringParts<'a> {
+    fn new(src: &'a str, pos: usize) -> Result<Self, ParserError> {
+        let end = pos + src.len();
+
+        // Strip leading carriage return as part of first newline
+        let (src, next_pos) = match src.strip_prefix('\r') {
+            Some(src) => (src, pos + 2),
+            None => (src, pos + 1),
+        };
+
+        let mut lines = src.split('\n');
+
+        // Remove mandatory final line, using trailing spaces as indent
+        let indent = lines.next_back().unwrap();
+        if indent.contains(|ch| ch != ' ') {
+            return Err(ParserError::with_detail(
+                ParserErrorKind::InvalidMultilineString,
+                end - 3..end,
+                r#"closing """ must be on a line preceded only by spaces"#,
+            ));
+        }
+
+        // Validate mandatory initial empty line
+        if lines.next() != Some("") {
+            return Err(ParserError::with_detail(
+                ParserErrorKind::InvalidMultilineString,
+                pos..pos + 1,
+                r#"opening """ must be followed immediately by newline"#,
+            ));
+        }
+
+        let mut parts = Self {
+            curr: StringParts { src: "", pos: 0 },
+            lines,
+            next_pos,
+            indent,
+        };
+
+        // Skip first newline
+        if let Some(nl) = parts.next().transpose()? {
+            debug_assert_eq!(nl, "\n");
+        }
+
+        Ok(parts)
+    }
+}
+
+impl<'a> Iterator for MultilineStringParts<'a> {
+    type Item = Result<Cow<'a, str>, ParserError>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        if self.curr.src.is_empty() {
+            let next = self.lines.next()?;
+
+            // Update next line position
+            let pos = self.next_pos;
+            self.next_pos += next.len() + 1;
+
+            // Strip indent
+            let Some(src) = next.strip_prefix(self.indent) else {
+                return Some(Err(ParserError::with_detail(
+                    ParserErrorKind::InvalidMultilineString,
+                    pos..pos + 1,
+                    r#"lines must be indented at least as much as closing """"#,
+                )));
+            };
+            let pos = pos + self.indent.len();
+
+            // Strip trailing carriage return for `\r\n` newlines
+            let src = src.strip_suffix('\r').unwrap_or(src);
+
+            self.curr = StringParts { src, pos };
+
+            Some(Ok("\n".into()))
+        } else {
+            Some(self.curr.next_part())
+        }
+    }
+}
+
+// Given a substring starting with an escape sequence, returns the decoded char
+// and length of the sequence, or None if the sequence is invalid.
+pub fn unescape(src: &str) -> Option<(char, usize)> {
+    let mut chars = src.chars();
+    if chars.next() != Some('\\') {
+        return None;
+    }
+    Some(match chars.next()? {
+        '\\' => ('\\', 2),
+        '\'' => ('\'', 2),
+        '"' => ('"', 2),
+        't' => ('\t', 2),
+        'n' => ('\n', 2),
+        'r' => ('\r', 2),
+        'u' => {
+            if chars.next()? != '{' {
+                return None;
+            }
+            let mut val = 0;
+            let mut digits = 0;
+            loop {
+                let ch = chars.next()?;
+                if ch == '}' {
+                    if digits == 0 {
+                        return None;
+                    }
+                    break;
+                }
+                val = (val << 4) | ch.to_digit(16)?;
+                digits += 1;
+                if digits > 6 {
+                    return None;
+                }
+            }
+            (char::from_u32(val)?, digits + 4)
+        }
+        _ => return None,
+    })
+}
diff --git a/crates/wasm-wave/src/untyped.rs b/crates/wasm-wave/src/untyped.rs
new file mode 100644
index 00000000..6baf3c1d
--- /dev/null
+++ b/crates/wasm-wave/src/untyped.rs
@@ -0,0 +1,245 @@
+//! Untyped value
+
+use std::borrow::Cow;
+
+use crate::{ast::Node, lex::Keyword, parser::ParserError, Parser, WasmValue};
+
+/// An UntypedValue is a parsed but not type-checked WAVE value.
+#[derive(Clone, Debug)]
+pub struct UntypedValue<'source> {
+    source: Cow<'source, str>,
+    node: Node,
+}
+
+impl<'source> UntypedValue<'source> {
+    pub(crate) fn new(source: impl Into<Cow<'source, str>>, node: Node) -> Self {
+        Self {
+            source: source.into(),
+            node,
+        }
+    }
+
+    /// Parses an untyped value from WAVE.
+    pub fn parse(source: &'source str) -> Result<Self, ParserError> {
+        let mut parser = Parser::new(source);
+        let val = parser.parse_raw_value()?;
+        parser.finish()?;
+        Ok(val)
+    }
+
+    /// Creates an owned value, copying the entire source string if necessary.
+    pub fn into_owned(self) -> UntypedValue<'static> {
+        UntypedValue::new(self.source.into_owned(), self.node)
+    }
+
+    /// Returns the source this value was parsed from.
+    pub fn source(&self) -> &str {
+        &self.source
+    }
+
+    /// Returns this value's root node.
+    pub fn node(&self) -> &Node {
+        &self.node
+    }
+
+    /// Converts this untyped value into the given typed value.
+    pub fn to_wasm_value<V: WasmValue>(&self, ty: &V::Type) -> Result<V, ParserError> {
+        self.node.to_wasm_value(ty, &self.source)
+    }
+}
+
+impl std::fmt::Display for UntypedValue<'_> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        fmt_node(f, &self.node, &self.source)
+    }
+}
+
+/// An UntypedFuncCall is a parsed but not type-checked WAVE function call.
+///
+/// WAVE function calls have the form `<name>(<params...>)`.
+pub struct UntypedFuncCall<'source> {
+    source: Cow<'source, str>,
+    name: Node,
+    params: Node,
+}
+
+impl<'source> UntypedFuncCall<'source> {
+    pub(crate) fn new(source: impl Into<Cow<'source, str>>, name: Node, params: Node) -> Self {
+        Self {
+            source: source.into(),
+            name,
+            params,
+        }
+    }
+
+    /// Parses an untyped function call from WAVE.
+    pub fn parse(source: &'source str) -> Result<Self, ParserError> {
+        let mut parser = Parser::new(source);
+        let call = parser.parse_raw_func_call()?;
+        parser.finish()?;
+        Ok(call)
+    }
+
+    /// Creates an owned function call, copying the entire source string if necessary.
+    pub fn into_owned(self) -> UntypedFuncCall<'static> {
+        UntypedFuncCall::new(
+            self.source.into_owned(),
+            self.name.clone(),
+            self.params.clone(),
+        )
+    }
+
+    /// Returns the source this function call was parsed from.
+    pub fn source(&self) -> &str {
+        &self.source
+    }
+
+    /// Returns the function name node.
+    pub fn name_node(&self) -> &Node {
+        &self.name
+    }
+
+    /// Returns the function parameters node.
+    pub fn params_node(&self) -> &Node {
+        &self.params
+    }
+
+    /// Returns the function name.
+    pub fn name(&self) -> &str {
+        self.name.slice(&self.source)
+    }
+
+    /// Converts the untyped parameters into the given types.
+    ///
+    /// Any number of trailing option-typed values may be omitted; those will
+    /// be returned as `none` values.
+    pub fn to_wasm_params<'types, V: WasmValue + 'static>(
+        &self,
+        types: impl IntoIterator<Item = &'types V::Type>,
+    ) -> Result<Vec<V>, ParserError> {
+        self.params.to_wasm_params(types, self.source())
+    }
+}
+
+impl std::fmt::Display for UntypedFuncCall<'_> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(self.name.slice(&self.source))?;
+        fmt_node(f, &self.params, &self.source)
+    }
+}
+
+fn fmt_node(f: &mut impl std::fmt::Write, node: &Node, src: &str) -> std::fmt::Result {
+    use crate::ast::NodeType::*;
+    match node.ty() {
+        BoolTrue | BoolFalse | Number | Char | String | MultilineString | Label => {
+            f.write_str(node.slice(src))
+        }
+        Tuple => fmt_sequence(f, '(', ')', node.as_tuple()?, src),
+        List => fmt_sequence(f, '[', ']', node.as_list()?, src),
+        Record => {
+            let fields = node.as_record(src)?;
+            if fields.len() == 0 {
+                return f.write_str("{:}");
+            }
+            f.write_char('{')?;
+            for (idx, (name, value)) in node.as_record(src)?.enumerate() {
+                if idx != 0 {
+                    f.write_str(", ")?;
+                }
+                write!(f, "{name}: ")?;
+                fmt_node(f, value, src)?;
+            }
+            f.write_char('}')
+        }
+        VariantWithPayload => {
+            let (label, payload) = node.as_variant(src)?;
+            if Keyword::decode(label).is_some() {
+                f.write_char('%')?;
+            }
+            fmt_variant(f, label, payload, src)
+        }
+        OptionSome => fmt_variant(f, "some", node.as_option()?, src),
+        OptionNone => fmt_variant(f, "none", None, src),
+        ResultOk => fmt_variant(f, "ok", node.as_result()?.unwrap(), src),
+        ResultErr => fmt_variant(f, "err", node.as_result()?.unwrap_err(), src),
+        Flags => {
+            f.write_char('{')?;
+            for (idx, flag) in node.as_flags(src)?.enumerate() {
+                if idx != 0 {
+                    f.write_str(", ")?;
+                }
+                f.write_str(flag)?;
+            }
+            f.write_char('}')
+        }
+    }
+}
+
+fn fmt_sequence<'a>(
+    f: &mut impl std::fmt::Write,
+    open: char,
+    close: char,
+    nodes: impl Iterator<Item = &'a Node>,
+    src: &str,
+) -> std::fmt::Result {
+    f.write_char(open)?;
+    for (idx, node) in nodes.enumerate() {
+        if idx != 0 {
+            f.write_str(", ")?;
+        }
+        fmt_node(f, node, src)?;
+    }
+    f.write_char(close)
+}
+
+fn fmt_variant(
+    f: &mut impl std::fmt::Write,
+    case: &str,
+    payload: Option<&Node>,
+    src: &str,
+) -> std::fmt::Result {
+    f.write_str(case)?;
+    if let Some(node) = payload {
+        f.write_char('(')?;
+        fmt_node(f, node, src)?;
+        f.write_char(')')?;
+    }
+    Ok(())
+}
+
+impl From<ParserError> for std::fmt::Error {
+    fn from(_: ParserError) -> Self {
+        Self
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn round_trips() {
+        for src in [
+            "true",
+            "18446744073709551616",
+            "-9223372036854775808",
+            "[-3.1415, 0, inf, nan, -inf]",
+            "['☃', '\\n']",
+            r#""☃☃☃""#,
+            "(1, false)",
+            "{:}",
+            "{code: red}",
+            "left(1)",
+            "[some(1), none]",
+            "[ok(1), err(2)]",
+            "[ok, err]",
+            "%inf(inf)",
+            "%some",
+            "%none(none)",
+        ] {
+            let val = UntypedValue::parse(src).unwrap();
+            let encoded = val.to_string();
+            assert_eq!(encoded, src);
+        }
+    }
+}
diff --git a/crates/wasm-wave/src/value/convert.rs b/crates/wasm-wave/src/value/convert.rs
new file mode 100644
index 00000000..236fb7b9
--- /dev/null
+++ b/crates/wasm-wave/src/value/convert.rs
@@ -0,0 +1,260 @@
+use crate::{
+    value::ValueEnum,
+    wasm::{WasmType, WasmTypeKind},
+    WasmValue,
+};
+
+use super::{Type, Value};
+
+pub fn from_wasm_type(ty: &impl WasmType) -> Option<Type> {
+    if let Some(ty) = Type::simple(ty.kind()) {
+        return Some(ty);
+    }
+    Some(match ty.kind() {
+        WasmTypeKind::List => Type::list(from_wasm_type(&ty.list_element_type()?)?),
+        WasmTypeKind::Record => Type::record(
+            ty.record_fields()
+                .map(|(name, ty)| Some((name, from_wasm_type(&ty)?)))
+                .collect::<Option<Vec<_>>>()?,
+        )?,
+        WasmTypeKind::Tuple => Type::tuple(
+            ty.tuple_element_types()
+                .map(|ty| from_wasm_type(&ty))
+                .collect::<Option<Vec<_>>>()?,
+        )?,
+        WasmTypeKind::Variant => Type::variant(
+            ty.variant_cases()
+                .map(|(name, payload)| Some((name, from_optional_wasm_type(payload)?)))
+                .collect::<Option<Vec<_>>>()?,
+        )?,
+        WasmTypeKind::Enum => Type::enum_ty(ty.enum_cases())?,
+        WasmTypeKind::Option => Type::option(from_wasm_type(&ty.option_some_type()?)?),
+        WasmTypeKind::Result => {
+            let (ok, err) = ty.result_types()?;
+            Type::result(from_optional_wasm_type(ok)?, from_optional_wasm_type(err)?)
+        }
+        WasmTypeKind::Flags => Type::flags(ty.flags_names())?,
+        _ => return None,
+    })
+}
+
+fn from_optional_wasm_type(ty: Option<impl WasmType>) -> Option<Option<Type>> {
+    Some(match ty {
+        Some(ty) => Some(from_wasm_type(&ty)?),
+        None => None,
+    })
+}
+
+trait ValueTyped {
+    fn value_type() -> Type;
+}
+
+macro_rules! impl_primitives {
+    ($Self:ty, $(($case:ident, $ty:ty)),*) => {
+        $(
+            impl ValueTyped for $ty {
+                fn value_type() -> Type {
+                    Type::must_simple(WasmTypeKind::$case)
+                }
+            }
+
+            impl From<$ty> for $Self {
+                fn from(value: $ty) -> Self {
+                    Self(ValueEnum::$case(value))
+                }
+            }
+        )*
+    };
+}
+
+impl_primitives!(
+    Value,
+    (Bool, bool),
+    (S8, i8),
+    (S16, i16),
+    (S32, i32),
+    (S64, i64),
+    (U8, u8),
+    (U16, u16),
+    (U32, u32),
+    (U64, u64),
+    (Float32, f32),
+    (Float64, f64),
+    (Char, char)
+);
+
+impl ValueTyped for String {
+    fn value_type() -> Type {
+        Type::STRING
+    }
+}
+
+impl From<String> for Value {
+    fn from(value: String) -> Self {
+        Self(ValueEnum::String(value.into()))
+    }
+}
+
+impl<'a> ValueTyped for &'a str {
+    fn value_type() -> Type {
+        String::value_type()
+    }
+}
+
+impl<'a> From<&'a str> for Value {
+    fn from(value: &'a str) -> Self {
+        value.to_string().into()
+    }
+}
+
+impl<const N: usize, T: ValueTyped> ValueTyped for [T; N] {
+    fn value_type() -> Type {
+        Type::list(T::value_type())
+    }
+}
+
+impl<const N: usize, T: ValueTyped + Into<Value>> From<[T; N]> for Value {
+    fn from(values: [T; N]) -> Self {
+        let ty = Vec::<T>::value_type();
+        let values = values.into_iter().map(Into::into);
+        Value::make_list(&ty, values).unwrap()
+    }
+}
+
+impl<T: ValueTyped> ValueTyped for Vec<T> {
+    fn value_type() -> Type {
+        Type::list(T::value_type())
+    }
+}
+
+impl<T: ValueTyped + Into<Value>> From<Vec<T>> for Value {
+    fn from(values: Vec<T>) -> Self {
+        let ty = Vec::<T>::value_type();
+        let values = values.into_iter().map(Into::into);
+        Value::make_list(&ty, values).unwrap()
+    }
+}
+
+impl<T: ValueTyped> ValueTyped for Option<T> {
+    fn value_type() -> Type {
+        Type::option(T::value_type())
+    }
+}
+
+impl<T: ValueTyped + Into<Value>> From<Option<T>> for Value {
+    fn from(value: Option<T>) -> Self {
+        let ty = Option::<T>::value_type();
+        Value::make_option(&ty, value.map(Into::into)).unwrap()
+    }
+}
+
+impl<T: ValueTyped, U: ValueTyped> ValueTyped for Result<T, U> {
+    fn value_type() -> Type {
+        Type::result(Some(T::value_type()), Some(U::value_type()))
+    }
+}
+
+impl<T: ValueTyped + Into<Value>, U: ValueTyped + Into<Value>> From<Result<T, U>> for Value {
+    fn from(value: Result<T, U>) -> Self {
+        let ty = Result::<T, U>::value_type();
+        let value = match value {
+            Ok(ok) => Ok(Some(ok.into())),
+            Err(err) => Err(Some(err.into())),
+        };
+        Value::make_result(&ty, value).unwrap()
+    }
+}
+
+macro_rules! impl_tuple {
+    ($(($($var:ident),*)),*) => {
+        $(
+            impl<$($var: ValueTyped),*> ValueTyped for ($($var),*,) {
+                fn value_type() -> Type {
+                    Type::tuple(vec![$($var::value_type()),*]).unwrap()
+                }
+            }
+
+            #[allow(non_snake_case)]
+            impl<$($var: ValueTyped + Into<Value>),*> From<($($var),*,)> for Value {
+                fn from(($($var),*,): ($($var),*,)) -> Value {
+                    let ty = <($($var),*,)>::value_type();
+                    $(
+                        let $var = $var.into();
+                    )*
+                    Value::make_tuple(&ty, vec![$($var),*]).unwrap()
+                }
+            }
+
+        )*
+    };
+}
+
+impl_tuple!(
+    (T1),
+    (T1, T2),
+    (T1, T2, T3),
+    (T1, T2, T3, T4),
+    (T1, T2, T3, T4, T5),
+    (T1, T2, T3, T4, T5, T6),
+    (T1, T2, T3, T4, T5, T6, T7),
+    (T1, T2, T3, T4, T5, T6, T7, T8),
+    (T1, T2, T3, T4, T5, T6, T7, T8, T9),
+    (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10),
+    (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11),
+    (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12),
+    (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13),
+    (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14),
+    (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15),
+    (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, T12, T13, T14, T15, T16)
+);
+
+#[cfg(test)]
+mod tests {
+    use crate::value::{Type, Value};
+
+    #[test]
+    fn type_conversion_round_trips() {
+        for ty in [
+            Type::BOOL,
+            Type::U8,
+            Type::FLOAT32,
+            Type::STRING,
+            Type::list(Type::BOOL),
+            Type::record([("a", Type::BOOL)]).unwrap(),
+            Type::tuple([Type::BOOL]).unwrap(),
+            Type::variant([("a", None), ("b", Some(Type::BOOL))]).unwrap(),
+            Type::enum_ty(["north", "south"]).unwrap(),
+            Type::option(Type::BOOL),
+            Type::result(Some(Type::BOOL), None),
+            Type::flags(["read", "write"]).unwrap(),
+        ] {
+            let got = Type::from_wasm_type(&ty).unwrap();
+            assert_eq!(got, ty);
+        }
+    }
+
+    #[test]
+    fn value_conversions() {
+        for (val, expect) in [
+            (1u8.into(), "1"),
+            ((-123i8).into(), "-123"),
+            (f32::NAN.into(), "nan"),
+            (f64::NEG_INFINITY.into(), "-inf"),
+            ('x'.into(), "'x'"),
+            ("str".into(), "\"str\""),
+            (vec![1, 2, 3].into(), "[1, 2, 3]"),
+            ([1, 2, 3].into(), "[1, 2, 3]"),
+            (['a'; 3].into(), "['a', 'a', 'a']"),
+            (Some(1).into(), "some(1)"),
+            (None::<u8>.into(), "none"),
+            (Ok::<u8, String>(1).into(), "ok(1)"),
+            (Err::<u8, String>("oops".into()).into(), "err(\"oops\")"),
+            ((1,).into(), "(1)"),
+            ((1, "str", [9; 2]).into(), "(1, \"str\", [9, 9])"),
+        ] {
+            let val: Value = val;
+            let got = crate::to_string(&val).unwrap();
+            assert_eq!(got, expect);
+        }
+    }
+}
diff --git a/crates/wasm-wave/src/value/func.rs b/crates/wasm-wave/src/value/func.rs
new file mode 100644
index 00000000..bfaf8215
--- /dev/null
+++ b/crates/wasm-wave/src/value/func.rs
@@ -0,0 +1,63 @@
+use crate::wasm::WasmFunc;
+
+use super::{Type, WasmValueError};
+
+/// A FuncType represents the parameter and result type(s) of a Wasm func.
+#[derive(Clone, PartialEq)]
+pub struct FuncType {
+    params: Vec<(String, Type)>,
+    results: Vec<(String, Type)>,
+}
+
+impl FuncType {
+    /// Returns a new FuncType with the given param and result type(s).
+    /// Returns an error if the resulting func type would be invalid in the
+    /// component model, e.g. has any unnamed results with more than one
+    /// result type.
+    pub fn new(
+        params: impl Into<Vec<(String, Type)>>,
+        results: impl Into<Vec<(String, Type)>>,
+    ) -> Result<Self, WasmValueError> {
+        let params = params.into();
+        if params.iter().any(|(name, _)| name.is_empty()) {
+            return Err(WasmValueError::Other("func params must be named".into()));
+        }
+        let results = results.into();
+        if results.len() > 1 && results.iter().any(|(name, _)| name.is_empty()) {
+            return Err(WasmValueError::Other(
+                "funcs with more than one result must have all results named".into(),
+            ));
+        }
+        Ok(Self { params, results })
+    }
+}
+
+impl WasmFunc for FuncType {
+    type Type = Type;
+
+    fn params(&self) -> Box<dyn Iterator<Item = Self::Type> + '_> {
+        Box::new(self.params.iter().map(|(_, ty)| ty.clone()))
+    }
+
+    fn param_names(&self) -> Box<dyn Iterator<Item = std::borrow::Cow<str>> + '_> {
+        Box::new(self.params.iter().map(|(name, _)| name.into()))
+    }
+
+    fn results(&self) -> Box<dyn Iterator<Item = Self::Type> + '_> {
+        Box::new(self.results.iter().map(|(_, ty)| ty.clone()))
+    }
+
+    fn result_names(&self) -> Box<dyn Iterator<Item = std::borrow::Cow<str>> + '_> {
+        Box::new(
+            self.results
+                .iter()
+                .flat_map(|(name, _)| (!name.is_empty()).then_some(name.into())),
+        )
+    }
+}
+
+impl std::fmt::Display for FuncType {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        crate::wasm::DisplayFunc(self.clone()).fmt(f)
+    }
+}
diff --git a/crates/wasm-wave/src/value/mod.rs b/crates/wasm-wave/src/value/mod.rs
new file mode 100644
index 00000000..1fb2cc42
--- /dev/null
+++ b/crates/wasm-wave/src/value/mod.rs
@@ -0,0 +1,553 @@
+//! Value enum for WAVE values.
+
+mod convert;
+#[cfg(test)]
+mod tests;
+mod ty;
+
+mod func;
+#[cfg(feature = "wit")]
+mod wit;
+
+#[cfg(feature = "wit")]
+pub use wit::{resolve_wit_func_type, resolve_wit_type};
+
+use std::{borrow::Cow, collections::HashMap, sync::Arc};
+
+use crate::{
+    canonicalize_nan32, canonicalize_nan64,
+    wasm::{
+        ensure_type_kind, maybe_unwrap_type, unwrap_val, WasmType, WasmTypeKind, WasmValue,
+        WasmValueError,
+    },
+};
+
+use self::ty::{
+    EnumType, FlagsType, ListType, OptionType, RecordType, ResultType, TupleType, TypeEnum,
+    VariantType,
+};
+
+pub use self::func::FuncType;
+pub use self::ty::Type;
+
+/// A Value is a WAVE value, and implements [`WasmValue`].
+#[derive(Debug, Clone, PartialEq)]
+pub struct Value(ValueEnum);
+
+#[derive(Debug, Clone, PartialEq)]
+pub(super) enum ValueEnum {
+    Bool(bool),
+    S8(i8),
+    U8(u8),
+    S16(i16),
+    U16(u16),
+    S32(i32),
+    U32(u32),
+    S64(i64),
+    U64(u64),
+    Float32(f32),
+    Float64(f64),
+    Char(char),
+    String(Box<str>),
+    List(List),
+    Record(Record),
+    Tuple(Tuple),
+    Variant(Variant),
+    Enum(Enum),
+    Option(OptionValue),
+    Result(ResultValue),
+    Flags(Flags),
+}
+
+#[derive(Debug, Clone, PartialEq)]
+#[doc(hidden)]
+pub struct List {
+    ty: Arc<ListType>,
+    elements: Vec<Value>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+#[doc(hidden)]
+pub struct Record {
+    ty: Arc<RecordType>,
+    fields: Vec<Value>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+#[doc(hidden)]
+pub struct Tuple {
+    ty: Arc<TupleType>,
+    elements: Vec<Value>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+#[doc(hidden)]
+pub struct Variant {
+    ty: Arc<VariantType>,
+    case: usize,
+    payload: Option<Box<Value>>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+#[doc(hidden)]
+pub struct Enum {
+    ty: Arc<EnumType>,
+    case: usize,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+#[doc(hidden)]
+pub struct OptionValue {
+    ty: Arc<OptionType>,
+    value: Option<Box<Value>>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+#[doc(hidden)]
+pub struct ResultValue {
+    ty: Arc<ResultType>,
+    value: Result<Option<Box<Value>>, Option<Box<Value>>>,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+#[doc(hidden)]
+pub struct Flags {
+    ty: Arc<FlagsType>,
+    flags: Vec<usize>,
+}
+
+macro_rules! impl_primitives {
+    ($Self:ident, $(($case:ident, $ty:ty, $make:ident, $unwrap:ident)),*) => {
+        $(
+            fn $make(val: $ty) -> $Self {
+                $Self(ValueEnum::$case(val))
+            }
+
+            fn $unwrap(&self) -> $ty {
+                *unwrap_val!(&self.0, ValueEnum::$case, stringify!($case))
+            }
+        )*
+    };
+}
+
+impl WasmValue for Value {
+    type Type = Type;
+
+    fn kind(&self) -> WasmTypeKind {
+        match &self.0 {
+            ValueEnum::Bool(_) => WasmTypeKind::Bool,
+            ValueEnum::S8(_) => WasmTypeKind::S8,
+            ValueEnum::S16(_) => WasmTypeKind::S16,
+            ValueEnum::S32(_) => WasmTypeKind::S32,
+            ValueEnum::S64(_) => WasmTypeKind::S64,
+            ValueEnum::U8(_) => WasmTypeKind::U8,
+            ValueEnum::U16(_) => WasmTypeKind::U16,
+            ValueEnum::U32(_) => WasmTypeKind::U32,
+            ValueEnum::U64(_) => WasmTypeKind::U64,
+            ValueEnum::Float32(_) => WasmTypeKind::Float32,
+            ValueEnum::Float64(_) => WasmTypeKind::Float64,
+            ValueEnum::Char(_) => WasmTypeKind::Char,
+            ValueEnum::String(_) => WasmTypeKind::String,
+            ValueEnum::List(_) => WasmTypeKind::List,
+            ValueEnum::Record(_) => WasmTypeKind::Record,
+            ValueEnum::Tuple(_) => WasmTypeKind::Tuple,
+            ValueEnum::Variant(_) => WasmTypeKind::Variant,
+            ValueEnum::Enum(_) => WasmTypeKind::Enum,
+            ValueEnum::Option(_) => WasmTypeKind::Option,
+            ValueEnum::Result(_) => WasmTypeKind::Result,
+            ValueEnum::Flags(_) => WasmTypeKind::Flags,
+        }
+    }
+
+    impl_primitives!(
+        Self,
+        (Bool, bool, make_bool, unwrap_bool),
+        (S8, i8, make_s8, unwrap_s8),
+        (S16, i16, make_s16, unwrap_s16),
+        (S32, i32, make_s32, unwrap_s32),
+        (S64, i64, make_s64, unwrap_s64),
+        (U8, u8, make_u8, unwrap_u8),
+        (U16, u16, make_u16, unwrap_u16),
+        (U32, u32, make_u32, unwrap_u32),
+        (U64, u64, make_u64, unwrap_u64),
+        (Char, char, make_char, unwrap_char)
+    );
+
+    fn make_float32(val: f32) -> Self {
+        let val = canonicalize_nan32(val);
+        Self(ValueEnum::Float32(val))
+    }
+
+    fn make_float64(val: f64) -> Self {
+        let val = canonicalize_nan64(val);
+        Self(ValueEnum::Float64(val))
+    }
+
+    fn make_string(val: std::borrow::Cow<str>) -> Self {
+        Self(ValueEnum::String(val.into()))
+    }
+
+    fn make_list(
+        ty: &Self::Type,
+        vals: impl IntoIterator<Item = Self>,
+    ) -> Result<Self, WasmValueError> {
+        ensure_type_kind(ty, WasmTypeKind::List)?;
+        let element_type = ty.list_element_type().unwrap();
+        let elements = vals
+            .into_iter()
+            .map(|v| check_type(&element_type, v))
+            .collect::<Result<_, _>>()?;
+        let ty = maybe_unwrap_type!(&ty.0, TypeEnum::List).unwrap().clone();
+        Ok(Self(ValueEnum::List(List { ty, elements })))
+    }
+
+    fn make_record<'a>(
+        ty: &Self::Type,
+        fields: impl IntoIterator<Item = (&'a str, Self)>,
+    ) -> Result<Self, WasmValueError> {
+        ensure_type_kind(ty, WasmTypeKind::Record)?;
+        let mut field_vals: HashMap<_, _> = fields.into_iter().collect();
+        let mut fields = Vec::with_capacity(field_vals.len());
+        for (name, ty) in ty.record_fields() {
+            let val = field_vals
+                .remove(&*name)
+                .ok_or_else(|| WasmValueError::MissingField(name.into()))?;
+            fields.push(check_type(&ty, val)?);
+        }
+        if let Some(unknown) = field_vals.into_keys().next() {
+            return Err(WasmValueError::UnknownField(unknown.into()));
+        }
+        let ty = maybe_unwrap_type!(&ty.0, TypeEnum::Record).unwrap().clone();
+        Ok(Self(ValueEnum::Record(Record { ty, fields })))
+    }
+
+    fn make_tuple(
+        ty: &Self::Type,
+        vals: impl IntoIterator<Item = Self>,
+    ) -> Result<Self, WasmValueError> {
+        ensure_type_kind(ty, WasmTypeKind::Tuple)?;
+        let types = ty.tuple_element_types().collect::<Vec<_>>();
+        let elements = Vec::from_iter(vals);
+        if types.len() != elements.len() {
+            return Err(WasmValueError::WrongNumberOfTupleValues {
+                want: types.len(),
+                got: elements.len(),
+            });
+        }
+        for (ty, val) in types.iter().zip(&elements) {
+            check_type2(ty, val)?;
+        }
+        let ty = maybe_unwrap_type!(&ty.0, TypeEnum::Tuple).unwrap().clone();
+        Ok(Self(ValueEnum::Tuple(Tuple { ty, elements })))
+    }
+
+    fn make_variant(
+        ty: &Self::Type,
+        case_name: &str,
+        val: Option<Self>,
+    ) -> Result<Self, WasmValueError> {
+        ensure_type_kind(ty, WasmTypeKind::Variant)?;
+        let (case, payload_type) = ty
+            .variant_cases()
+            .enumerate()
+            .find_map(|(idx, (name, ty))| (name == case_name).then_some((idx, ty)))
+            .ok_or_else(|| WasmValueError::UnknownCase(case_name.into()))?;
+        let payload = check_payload_type(case_name, &payload_type, val)?;
+        let ty = maybe_unwrap_type!(&ty.0, TypeEnum::Variant)
+            .unwrap()
+            .clone();
+        Ok(Self(ValueEnum::Variant(Variant { ty, case, payload })))
+    }
+
+    fn make_enum(ty: &Self::Type, case: &str) -> Result<Self, WasmValueError> {
+        ensure_type_kind(ty, WasmTypeKind::Enum)?;
+        let case = ty
+            .enum_cases()
+            .position(|name| name == case)
+            .ok_or_else(|| WasmValueError::UnknownCase(case.into()))?;
+        let ty = maybe_unwrap_type!(&ty.0, TypeEnum::Enum).unwrap().clone();
+        Ok(Self(ValueEnum::Enum(Enum { ty, case })))
+    }
+
+    fn make_option(ty: &Self::Type, val: Option<Self>) -> Result<Self, WasmValueError> {
+        ensure_type_kind(ty, WasmTypeKind::Option)?;
+        let value = match val {
+            Some(val) => Some(Box::new(check_type(&ty.option_some_type().unwrap(), val)?)),
+            None => None,
+        };
+        let ty = maybe_unwrap_type!(&ty.0, TypeEnum::Option).unwrap().clone();
+        Ok(Self(ValueEnum::Option(OptionValue { ty, value })))
+    }
+
+    fn make_result(
+        ty: &Self::Type,
+        val: Result<Option<Self>, Option<Self>>,
+    ) -> Result<Self, WasmValueError> {
+        ensure_type_kind(ty, WasmTypeKind::Result)?;
+        let (ok_type, err_type) = ty.result_types().unwrap();
+        let value = match val {
+            Ok(ok) => Ok(check_payload_type("ok", &ok_type, ok)?),
+            Err(err) => Err(check_payload_type("err", &err_type, err)?),
+        };
+        let ty = maybe_unwrap_type!(&ty.0, TypeEnum::Result).unwrap().clone();
+        Ok(Self(ValueEnum::Result(ResultValue { ty, value })))
+    }
+
+    fn make_flags<'a>(
+        ty: &Self::Type,
+        names: impl IntoIterator<Item = &'a str>,
+    ) -> Result<Self, WasmValueError> {
+        ensure_type_kind(ty, WasmTypeKind::Flags)?;
+        let flag_names = ty.flags_names().collect::<Vec<_>>();
+        let mut flags = names
+            .into_iter()
+            .map(|name| {
+                flag_names
+                    .iter()
+                    .position(|flag| flag == name)
+                    .ok_or_else(|| WasmValueError::UnknownCase(name.into()))
+            })
+            .collect::<Result<Vec<_>, WasmValueError>>()?;
+        // Flags values don't logically contain an ordering of the flags. Sort
+        // the flags values so that equivalent flags values compare equal.
+        flags.sort();
+        let ty = maybe_unwrap_type!(&ty.0, TypeEnum::Flags).unwrap().clone();
+        Ok(Self(ValueEnum::Flags(Flags { ty, flags })))
+    }
+
+    fn unwrap_float32(&self) -> f32 {
+        let val = *unwrap_val!(&self.0, ValueEnum::Float32, "float32");
+        canonicalize_nan32(val)
+    }
+
+    fn unwrap_float64(&self) -> f64 {
+        let val = *unwrap_val!(&self.0, ValueEnum::Float64, "float64");
+        canonicalize_nan64(val)
+    }
+
+    fn unwrap_string(&self) -> std::borrow::Cow<str> {
+        unwrap_val!(&self.0, ValueEnum::String, "string")
+            .as_ref()
+            .into()
+    }
+    fn unwrap_list(&self) -> Box<dyn Iterator<Item = Cow<Self>> + '_> {
+        let list = unwrap_val!(&self.0, ValueEnum::List, "list");
+        Box::new(list.elements.iter().map(cow))
+    }
+    fn unwrap_record(&self) -> Box<dyn Iterator<Item = (Cow<str>, Cow<Self>)> + '_> {
+        let record = unwrap_val!(&self.0, ValueEnum::Record, "record");
+        Box::new(
+            record
+                .ty
+                .fields
+                .iter()
+                .map(|(name, _)| cow(name.as_ref()))
+                .zip(record.fields.iter().map(cow)),
+        )
+    }
+    fn unwrap_tuple(&self) -> Box<dyn Iterator<Item = Cow<Self>> + '_> {
+        let tuple = unwrap_val!(&self.0, ValueEnum::Tuple, "tuple");
+        Box::new(tuple.elements.iter().map(cow))
+    }
+    fn unwrap_variant(&self) -> (Cow<str>, Option<Cow<Self>>) {
+        let variant = unwrap_val!(&self.0, ValueEnum::Variant, "variant");
+        let (ref name, _) = variant.ty.cases[variant.case];
+        (cow(name.as_ref()), variant.payload.as_deref().map(cow))
+    }
+    fn unwrap_enum(&self) -> Cow<str> {
+        let enum_ = unwrap_val!(&self.0, ValueEnum::Enum, "enum");
+        cow(enum_.ty.cases[enum_.case].as_ref())
+    }
+    fn unwrap_option(&self) -> Option<Cow<Self>> {
+        unwrap_val!(&self.0, ValueEnum::Option, "option")
+            .value
+            .as_ref()
+            .map(|v| cow(v.as_ref()))
+    }
+    fn unwrap_result(&self) -> Result<Option<Cow<Self>>, Option<Cow<Self>>> {
+        match &unwrap_val!(&self.0, ValueEnum::Result, "result").value {
+            Ok(val) => Ok(val.as_deref().map(cow)),
+            Err(val) => Err(val.as_deref().map(cow)),
+        }
+    }
+    fn unwrap_flags(&self) -> Box<dyn Iterator<Item = Cow<str>> + '_> {
+        let flags = unwrap_val!(&self.0, ValueEnum::Flags, "flags");
+        Box::new(
+            flags
+                .flags
+                .iter()
+                .map(|idx| cow(flags.ty.flags[*idx].as_ref())),
+        )
+    }
+}
+
+fn cow<T: ToOwned + ?Sized>(t: &T) -> Cow<T> {
+    Cow::Borrowed(t)
+}
+
+fn check_type(expected: &Type, val: Value) -> Result<Value, WasmValueError> {
+    check_type2(expected, &val)?;
+    Ok(val)
+}
+
+fn check_type2(expected: &Type, val: &Value) -> Result<(), WasmValueError> {
+    let wrong_value_type =
+        || -> Result<(), WasmValueError> { Err(WasmValueError::wrong_value_type(expected, val)) };
+
+    match (&val.0, expected) {
+        (ValueEnum::Bool(_), &Type::BOOL) => {}
+        (ValueEnum::S8(_), &Type::S8) => {}
+        (ValueEnum::S16(_), &Type::S16) => {}
+        (ValueEnum::S32(_), &Type::S32) => {}
+        (ValueEnum::S64(_), &Type::S64) => {}
+        (ValueEnum::U8(_), &Type::U8) => {}
+        (ValueEnum::U16(_), &Type::U16) => {}
+        (ValueEnum::U32(_), &Type::U32) => {}
+        (ValueEnum::U64(_), &Type::U64) => {}
+        (ValueEnum::Float32(_), &Type::FLOAT32) => {}
+        (ValueEnum::Float64(_), &Type::FLOAT64) => {}
+        (ValueEnum::Char(_), &Type::CHAR) => {}
+        (ValueEnum::String(_), &Type::STRING) => {}
+        (ValueEnum::List(list), _) => {
+            if let TypeEnum::List(list_type) = &expected.0 {
+                let ty = &list_type.element;
+                if ty != &list.ty.element {
+                    return wrong_value_type();
+                }
+                for v in &list.elements {
+                    check_type2(ty, v)?;
+                }
+            } else {
+                return wrong_value_type();
+            }
+        }
+        (ValueEnum::Record(record), _) => {
+            if let TypeEnum::Record(record_type) = &expected.0 {
+                if record.ty.as_ref() != record_type.as_ref() {
+                    return wrong_value_type();
+                }
+                let expected_element_types = &record_type.fields;
+                if expected_element_types != &record.ty.fields {
+                    return wrong_value_type();
+                }
+                if expected_element_types.len() != record.fields.len() {
+                    return wrong_value_type();
+                }
+
+                for (field_ty, val) in expected_element_types.as_ref().iter().zip(&record.fields) {
+                    check_type2(&field_ty.1, val)?;
+                }
+            } else {
+                return wrong_value_type();
+            }
+        }
+        (ValueEnum::Tuple(tuple), _) => {
+            if let TypeEnum::Tuple(tuple_type) = &expected.0 {
+                let expected_element_types = &tuple_type.elements;
+                if expected_element_types != &tuple.ty.elements {
+                    return wrong_value_type();
+                }
+                if expected_element_types.len() != tuple.elements.len() {
+                    return wrong_value_type();
+                }
+
+                for (ty, val) in expected_element_types.as_ref().iter().zip(&tuple.elements) {
+                    check_type2(ty, val)?;
+                }
+            } else {
+                return wrong_value_type();
+            }
+        }
+        (ValueEnum::Variant(variant), _) => {
+            if let TypeEnum::Variant(variant_type) = &expected.0 {
+                if variant.ty.cases != variant_type.cases {
+                    return wrong_value_type();
+                }
+                if variant.case >= variant.ty.cases.len() {
+                    return wrong_value_type();
+                }
+                match (&variant.ty.cases[variant.case].1, &variant.payload) {
+                    (None, None) => {}
+                    (Some(t), Some(v)) => check_type2(t, v)?,
+                    _ => return wrong_value_type(),
+                }
+            } else {
+                return wrong_value_type();
+            }
+        }
+        (ValueEnum::Enum(enm), _) => {
+            if let TypeEnum::Enum(enum_type) = &expected.0 {
+                if enm.case >= enm.ty.cases.len() {
+                    return wrong_value_type();
+                }
+                if enm.ty.cases != enum_type.cases {
+                    return wrong_value_type();
+                }
+            } else {
+                return wrong_value_type();
+            }
+        }
+        (ValueEnum::Option(option), _) => {
+            if let TypeEnum::Option(option_type) = &expected.0 {
+                if option.ty.as_ref().some != option_type.some {
+                    return wrong_value_type();
+                }
+                if let Some(v) = option.value.as_ref() {
+                    check_type2(&option_type.some, v)?;
+                }
+            } else {
+                return wrong_value_type();
+            }
+        }
+        (ValueEnum::Result(result), _) => {
+            if let TypeEnum::Result(result_type) = &expected.0 {
+                if result.ty.as_ref() != result_type.as_ref() {
+                    return wrong_value_type();
+                }
+                match &result.value {
+                    Ok(o) => match (&o, &result_type.ok) {
+                        (None, None) => {}
+                        (Some(v), Some(t)) => check_type2(t, v)?,
+                        _ => return wrong_value_type(),
+                    },
+                    Err(e) => match (&e, &result_type.err) {
+                        (None, None) => {}
+                        (Some(v), Some(t)) => check_type2(t, v)?,
+                        _ => return wrong_value_type(),
+                    },
+                }
+            } else {
+                return wrong_value_type();
+            }
+        }
+        (ValueEnum::Flags(flags), _) => {
+            if let TypeEnum::Flags(flags_type) = &expected.0 {
+                if flags.ty.as_ref() != flags_type.as_ref() {
+                    return wrong_value_type();
+                }
+                for flag in &flags.flags {
+                    if *flag >= flags.ty.as_ref().flags.len() {
+                        return wrong_value_type();
+                    }
+                }
+            } else {
+                return wrong_value_type();
+            }
+        }
+        (_, _) => return wrong_value_type(),
+    };
+    Ok(())
+}
+
+fn check_payload_type(
+    name: &str,
+    expected: &Option<Type>,
+    val: Option<Value>,
+) -> Result<Option<Box<Value>>, WasmValueError> {
+    match (expected, val) {
+        (Some(payload_type), Some(val)) => Ok(Some(Box::new(check_type(payload_type, val)?))),
+        (None, None) => Ok(None),
+        (Some(_), None) => Err(WasmValueError::MissingPayload(name.into())),
+        (None, Some(_)) => Err(WasmValueError::UnexpectedPayload(name.into())),
+    }
+}
diff --git a/crates/wasm-wave/src/value/ty.rs b/crates/wasm-wave/src/value/ty.rs
new file mode 100644
index 00000000..5c01fa91
--- /dev/null
+++ b/crates/wasm-wave/src/value/ty.rs
@@ -0,0 +1,267 @@
+use std::{borrow::Cow, sync::Arc};
+
+use crate::wasm::{maybe_unwrap_type, WasmType, WasmTypeKind};
+
+/// The [`WasmType`] of a [`Value`](super::Value).
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Type(pub(super) TypeEnum);
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(super) enum TypeEnum {
+    Simple(SimpleType),
+    List(Arc<ListType>),
+    Record(Arc<RecordType>),
+    Tuple(Arc<TupleType>),
+    Variant(Arc<VariantType>),
+    Enum(Arc<EnumType>),
+    Option(Arc<OptionType>),
+    Result(Arc<ResultType>),
+    Flags(Arc<FlagsType>),
+}
+
+#[allow(missing_docs)]
+impl Type {
+    pub const BOOL: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::Bool)));
+    pub const S8: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::S8)));
+    pub const S16: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::S16)));
+    pub const S32: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::S32)));
+    pub const S64: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::S64)));
+    pub const U8: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::U8)));
+    pub const U16: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::U16)));
+    pub const U32: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::U32)));
+    pub const U64: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::U64)));
+    pub const FLOAT32: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::Float32)));
+    pub const FLOAT64: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::Float64)));
+    pub const CHAR: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::Char)));
+    pub const STRING: Self = Self(TypeEnum::Simple(SimpleType(WasmTypeKind::String)));
+
+    /// Returns the simple type of the given `kind`. Returns None if the kind
+    /// represents a parameterized type.
+    pub fn simple(kind: WasmTypeKind) -> Option<Self> {
+        is_simple(kind).then_some(Self(TypeEnum::Simple(SimpleType(kind))))
+    }
+
+    #[doc(hidden)]
+    pub const fn must_simple(kind: WasmTypeKind) -> Self {
+        if !is_simple(kind) {
+            panic!("kind is not simple");
+        }
+        Self(TypeEnum::Simple(SimpleType(kind)))
+    }
+
+    /// Returns a list type with the given element type.
+    pub fn list(element_type: impl Into<Self>) -> Self {
+        let element = element_type.into();
+        Self(TypeEnum::List(Arc::new(ListType { element })))
+    }
+
+    /// Returns a record type with the given field types. Returns None if
+    /// `fields` is empty.
+    pub fn record<T: Into<Box<str>>>(
+        field_types: impl IntoIterator<Item = (T, Self)>,
+    ) -> Option<Self> {
+        let fields = field_types
+            .into_iter()
+            .map(|(name, ty)| (name.into(), ty))
+            .collect::<Box<[_]>>();
+        if fields.is_empty() {
+            return None;
+        }
+        Some(Self(TypeEnum::Record(Arc::new(RecordType { fields }))))
+    }
+
+    /// Returns a tuple type with the given element types. Returns None if
+    /// `element_types` is empty.
+    pub fn tuple(element_types: impl Into<Box<[Self]>>) -> Option<Self> {
+        let elements = element_types.into();
+        if elements.is_empty() {
+            return None;
+        }
+        Some(Self(TypeEnum::Tuple(Arc::new(TupleType { elements }))))
+    }
+
+    /// Returns a variant type with the given case names and optional payloads.
+    /// Returns None if `cases` is empty.
+    pub fn variant<T: Into<Box<str>>>(
+        cases: impl IntoIterator<Item = (T, Option<Self>)>,
+    ) -> Option<Self> {
+        let cases = cases
+            .into_iter()
+            .map(|(name, ty)| (name.into(), ty))
+            .collect::<Box<[_]>>();
+        if cases.is_empty() {
+            return None;
+        }
+        Some(Self(TypeEnum::Variant(Arc::new(VariantType { cases }))))
+    }
+
+    /// Returns an enum type with the given case names. Returns None if `cases`
+    /// is empty.
+    pub fn enum_ty<T: Into<Box<str>>>(cases: impl IntoIterator<Item = T>) -> Option<Self> {
+        let cases = cases.into_iter().map(Into::into).collect::<Box<[_]>>();
+        if cases.is_empty() {
+            return None;
+        }
+        Some(Self(TypeEnum::Enum(Arc::new(EnumType { cases }))))
+    }
+
+    /// Returns an option type with the given "some" type.
+    pub fn option(some: Self) -> Self {
+        Self(TypeEnum::Option(Arc::new(OptionType { some })))
+    }
+
+    /// Returns a result type with the given optional "ok" and "err" payloads.
+    pub fn result(ok: Option<Self>, err: Option<Self>) -> Self {
+        Self(TypeEnum::Result(Arc::new(ResultType { ok, err })))
+    }
+
+    /// Returns a flags type with the given flag names. Returns None if `flags`
+    /// is empty.
+    pub fn flags<T: Into<Box<str>>>(flags: impl IntoIterator<Item = T>) -> Option<Self> {
+        let flags = flags.into_iter().map(Into::into).collect::<Box<[_]>>();
+        if flags.is_empty() {
+            return None;
+        }
+        Some(Self(TypeEnum::Flags(Arc::new(FlagsType { flags }))))
+    }
+
+    /// Returns a [`Type`] matching the given [`WasmType`]. Returns None if the
+    /// given type is unsupported or otherwise invalid.
+    pub fn from_wasm_type(ty: &impl WasmType) -> Option<Self> {
+        super::convert::from_wasm_type(ty)
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SimpleType(WasmTypeKind);
+
+const fn is_simple(kind: WasmTypeKind) -> bool {
+    use WasmTypeKind::*;
+    matches!(
+        kind,
+        Bool | S8 | S16 | S32 | S64 | U8 | U16 | U32 | U64 | Float32 | Float64 | Char | String
+    )
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ListType {
+    pub(super) element: Type,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct RecordType {
+    pub(super) fields: Box<[(Box<str>, Type)]>,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct TupleType {
+    pub(super) elements: Box<[Type]>,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct VariantType {
+    pub(super) cases: Box<[(Box<str>, Option<Type>)]>,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct EnumType {
+    pub(super) cases: Box<[Box<str>]>,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct OptionType {
+    pub(super) some: Type,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct ResultType {
+    pub(super) ok: Option<Type>,
+    pub(super) err: Option<Type>,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+pub struct FlagsType {
+    pub(super) flags: Box<[Box<str>]>,
+}
+
+impl WasmType for Type {
+    fn kind(&self) -> WasmTypeKind {
+        match self.0 {
+            TypeEnum::Simple(simple) => simple.0,
+            TypeEnum::List(_) => WasmTypeKind::List,
+            TypeEnum::Record(_) => WasmTypeKind::Record,
+            TypeEnum::Tuple(_) => WasmTypeKind::Tuple,
+            TypeEnum::Variant(_) => WasmTypeKind::Variant,
+            TypeEnum::Enum(_) => WasmTypeKind::Enum,
+            TypeEnum::Option(_) => WasmTypeKind::Option,
+            TypeEnum::Result(_) => WasmTypeKind::Result,
+            TypeEnum::Flags(_) => WasmTypeKind::Flags,
+        }
+    }
+
+    fn list_element_type(&self) -> Option<Self> {
+        let list = maybe_unwrap_type!(&self.0, TypeEnum::List)?;
+        Some(list.element.clone())
+    }
+
+    fn record_fields(&self) -> Box<dyn Iterator<Item = (Cow<str>, Self)> + '_> {
+        let TypeEnum::Record(record) = &self.0 else {
+            return Box::new(std::iter::empty());
+        };
+        Box::new(
+            record
+                .fields
+                .iter()
+                .map(|(name, ty)| (name.as_ref().into(), ty.clone())),
+        )
+    }
+
+    fn tuple_element_types(&self) -> Box<dyn Iterator<Item = Self> + '_> {
+        let TypeEnum::Tuple(tuple) = &self.0 else {
+            return Box::new(std::iter::empty());
+        };
+        Box::new(tuple.elements.iter().cloned())
+    }
+
+    fn variant_cases(&self) -> Box<dyn Iterator<Item = (Cow<str>, Option<Self>)> + '_> {
+        let TypeEnum::Variant(variant) = &self.0 else {
+            return Box::new(std::iter::empty());
+        };
+        Box::new(
+            variant
+                .cases
+                .iter()
+                .map(|(name, ty)| (name.as_ref().into(), ty.clone())),
+        )
+    }
+
+    fn enum_cases(&self) -> Box<dyn Iterator<Item = Cow<str>> + '_> {
+        let TypeEnum::Enum(enum_) = &self.0 else {
+            return Box::new(std::iter::empty());
+        };
+        Box::new(enum_.cases.iter().map(|name| name.as_ref().into()))
+    }
+
+    fn option_some_type(&self) -> Option<Self> {
+        let option = maybe_unwrap_type!(&self.0, TypeEnum::Option)?;
+        Some(option.some.clone())
+    }
+
+    fn result_types(&self) -> Option<(Option<Self>, Option<Self>)> {
+        let result = maybe_unwrap_type!(&self.0, TypeEnum::Result)?;
+        Some((result.ok.clone(), result.err.clone()))
+    }
+
+    fn flags_names(&self) -> Box<dyn Iterator<Item = Cow<str>> + '_> {
+        let TypeEnum::Flags(flags) = &self.0 else {
+            return Box::new(std::iter::empty());
+        };
+        Box::new(flags.flags.iter().map(|name| name.as_ref().into()))
+    }
+}
+
+impl std::fmt::Display for Type {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        crate::wasm::DisplayType(self).fmt(f)
+    }
+}
diff --git a/crates/wasm-wave/src/value/wit.rs b/crates/wasm-wave/src/value/wit.rs
new file mode 100644
index 00000000..73ab19d4
--- /dev/null
+++ b/crates/wasm-wave/src/value/wit.rs
@@ -0,0 +1,218 @@
+use wit_parser::{
+    Enum, Flags, Function, Record, Resolve, Result_, Tuple, Type, TypeDefKind, TypeId, Variant,
+};
+
+use crate::{value, wasm::WasmValueError};
+
+/// Resolves a [`value::Type`] from the given [`wit_parser::Resolve`] and [`TypeId`].
+/// # Panics
+/// Panics if `type_id` is not valid in `resolve`.
+pub fn resolve_wit_type(resolve: &Resolve, type_id: TypeId) -> Result<value::Type, WasmValueError> {
+    TypeResolver { resolve }.resolve_type_id(type_id)
+}
+
+/// Resolves a [`value::FuncType`] from the given [`wit_parser::Resolve`] and [`Function`].
+/// # Panics
+/// Panics if `function`'s types are not valid in `resolve`.
+pub fn resolve_wit_func_type(
+    resolve: &Resolve,
+    function: &Function,
+) -> Result<value::FuncType, WasmValueError> {
+    let resolver = TypeResolver { resolve };
+    let params = resolver.resolve_params(&function.params)?;
+    let results = match &function.results {
+        wit_parser::Results::Named(results) => resolver.resolve_params(results)?,
+        wit_parser::Results::Anon(ty) => vec![("".into(), resolver.resolve_type(*ty)?)],
+    };
+    value::FuncType::new(params, results)
+}
+
+struct TypeResolver<'a> {
+    resolve: &'a Resolve,
+}
+
+type ValueResult = Result<value::Type, WasmValueError>;
+
+impl<'a> TypeResolver<'a> {
+    fn resolve_type_id(&self, type_id: TypeId) -> ValueResult {
+        self.resolve(&self.resolve.types.get(type_id).unwrap().kind)
+    }
+
+    fn resolve_type(&self, ty: Type) -> ValueResult {
+        self.resolve(&TypeDefKind::Type(ty))
+    }
+
+    fn resolve_params(
+        &self,
+        params: &[(String, Type)],
+    ) -> Result<Vec<(String, value::Type)>, WasmValueError> {
+        params
+            .iter()
+            .map(|(name, ty)| {
+                let ty = self.resolve_type(*ty)?;
+                Ok((name.clone(), ty))
+            })
+            .collect()
+    }
+
+    fn resolve(&self, mut kind: &'a TypeDefKind) -> ValueResult {
+        // Recursively resolve any type defs.
+        while let &TypeDefKind::Type(Type::Id(id)) = kind {
+            kind = &self.resolve.types.get(id).unwrap().kind;
+        }
+
+        match kind {
+            TypeDefKind::Record(record) => self.resolve_record(record),
+            TypeDefKind::Flags(flags) => self.resolve_flags(flags),
+            TypeDefKind::Tuple(tuple) => self.resolve_tuple(tuple),
+            TypeDefKind::Variant(variant) => self.resolve_variant(variant),
+            TypeDefKind::Enum(enum_) => self.resolve_enum(enum_),
+            TypeDefKind::Option(some_type) => self.resolve_option(some_type),
+            TypeDefKind::Result(result) => self.resolve_result(result),
+            TypeDefKind::List(element_type) => self.resolve_list(element_type),
+            TypeDefKind::Type(Type::Bool) => Ok(value::Type::BOOL),
+            TypeDefKind::Type(Type::U8) => Ok(value::Type::U8),
+            TypeDefKind::Type(Type::U16) => Ok(value::Type::U16),
+            TypeDefKind::Type(Type::U32) => Ok(value::Type::U32),
+            TypeDefKind::Type(Type::U64) => Ok(value::Type::U64),
+            TypeDefKind::Type(Type::S8) => Ok(value::Type::S8),
+            TypeDefKind::Type(Type::S16) => Ok(value::Type::S16),
+            TypeDefKind::Type(Type::S32) => Ok(value::Type::S32),
+            TypeDefKind::Type(Type::S64) => Ok(value::Type::S64),
+            TypeDefKind::Type(Type::F32) => Ok(value::Type::FLOAT32),
+            TypeDefKind::Type(Type::F64) => Ok(value::Type::FLOAT64),
+            TypeDefKind::Type(Type::Char) => Ok(value::Type::CHAR),
+            TypeDefKind::Type(Type::String) => Ok(value::Type::STRING),
+            TypeDefKind::Type(Type::Id(_)) => unreachable!(),
+            other => Err(WasmValueError::UnsupportedType(other.as_str().into())),
+        }
+    }
+
+    fn resolve_record(&self, record: &Record) -> ValueResult {
+        let fields = record
+            .fields
+            .iter()
+            .map(|f| Ok((f.name.as_str(), self.resolve_type(f.ty)?)))
+            .collect::<Result<Vec<_>, _>>()?;
+        Ok(value::Type::record(fields).unwrap())
+    }
+
+    fn resolve_flags(&self, flags: &Flags) -> ValueResult {
+        let names = flags.flags.iter().map(|f| f.name.as_str());
+        Ok(value::Type::flags(names).unwrap())
+    }
+
+    fn resolve_tuple(&self, tuple: &Tuple) -> ValueResult {
+        let types = tuple
+            .types
+            .iter()
+            .map(|ty| self.resolve_type(*ty))
+            .collect::<Result<Vec<_>, _>>()?;
+        Ok(value::Type::tuple(types).unwrap())
+    }
+
+    fn resolve_variant(&self, variant: &Variant) -> ValueResult {
+        let cases = variant
+            .cases
+            .iter()
+            .map(|case| {
+                Ok((
+                    case.name.as_str(),
+                    case.ty.map(|ty| self.resolve_type(ty)).transpose()?,
+                ))
+            })
+            .collect::<Result<Vec<_>, _>>()?;
+        Ok(value::Type::variant(cases).unwrap())
+    }
+
+    fn resolve_enum(&self, enum_: &Enum) -> ValueResult {
+        let cases = enum_.cases.iter().map(|c| c.name.as_str());
+        Ok(value::Type::enum_ty(cases).unwrap())
+    }
+
+    fn resolve_option(&self, some_type: &Type) -> ValueResult {
+        let some = self.resolve_type(*some_type)?;
+        Ok(value::Type::option(some))
+    }
+
+    fn resolve_result(&self, result: &Result_) -> ValueResult {
+        let ok = result.ok.map(|ty| self.resolve_type(ty)).transpose()?;
+        let err = result.err.map(|ty| self.resolve_type(ty)).transpose()?;
+        Ok(value::Type::result(ok, err))
+    }
+
+    fn resolve_list(&self, element_type: &Type) -> ValueResult {
+        let element_type = self.resolve_type(*element_type)?;
+        Ok(value::Type::list(element_type))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use wit_parser::UnresolvedPackageGroup;
+
+    use super::*;
+
+    #[test]
+    fn resolve_wit_type_smoke_test() {
+        let UnresolvedPackageGroup {
+            mut packages,
+            source_map,
+        } = UnresolvedPackageGroup::parse(
+            "test.wit",
+            r#"
+            package test:types;
+            interface types {
+                type uint8 = u8;
+            }
+        "#,
+        )
+        .unwrap();
+        let mut resolve = Resolve::new();
+        resolve.push(packages.remove(0), &source_map).unwrap();
+
+        let (type_id, _) = resolve.types.iter().next().unwrap();
+        let ty = resolve_wit_type(&resolve, type_id).unwrap();
+        assert_eq!(ty, value::Type::U8);
+    }
+
+    #[test]
+    fn resolve_wit_func_type_smoke_test() {
+        let UnresolvedPackageGroup {
+            mut packages,
+            source_map,
+        } = UnresolvedPackageGroup::parse(
+            "test.wit",
+            r#"
+            package test:types;
+            interface types {
+                type uint8 = u8;
+                no-results: func(a: uint8, b: string);
+                one-result: func(c: uint8, d: string) -> uint8;
+                named-results: func(e: uint8, f: string) -> (x: u8, y: string);
+            }
+        "#,
+        )
+        .unwrap();
+        let mut resolve = Resolve::new();
+        resolve.push(packages.remove(0), &source_map).unwrap();
+
+        for (func_name, expected_display) in [
+            ("no-results", "func(a: u8, b: string)"),
+            ("one-result", "func(c: u8, d: string) -> u8"),
+            (
+                "named-results",
+                "func(e: u8, f: string) -> (x: u8, y: string)",
+            ),
+        ] {
+            let function = resolve
+                .interfaces
+                .iter()
+                .flat_map(|(_, i)| &i.functions)
+                .find_map(|(name, function)| (name == func_name).then_some(function))
+                .unwrap();
+            let ty = resolve_wit_func_type(&resolve, function).unwrap();
+            assert_eq!(ty.to_string(), expected_display, "for {function:?}");
+        }
+    }
+}
diff --git a/crates/wasm-wave/src/wasm/fmt.rs b/crates/wasm-wave/src/wasm/fmt.rs
new file mode 100644
index 00000000..1cfc8c32
--- /dev/null
+++ b/crates/wasm-wave/src/wasm/fmt.rs
@@ -0,0 +1,225 @@
+use std::fmt::Display;
+
+use crate::{
+    wasm::{WasmFunc, WasmType, WasmTypeKind},
+    writer::Writer,
+    WasmValue,
+};
+
+/// Implements a WAVE-formatted [`Display`] for a [`WasmType`].
+pub struct DisplayType<'a, T: WasmType>(pub &'a T);
+
+impl<'a, T: WasmType> Display for DisplayType<'a, T> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let ty = self.0;
+        match ty.kind() {
+            WasmTypeKind::List => {
+                write!(f, "list<{}>", DisplayType(&ty.list_element_type().unwrap()))
+            }
+            WasmTypeKind::Record => {
+                f.write_str("record { ")?;
+                for (idx, (name, field_type)) in ty.record_fields().enumerate() {
+                    if idx != 0 {
+                        f.write_str(", ")?;
+                    }
+                    write!(f, "{name}: {}", DisplayType(&field_type))?;
+                }
+                f.write_str(" }")
+            }
+            WasmTypeKind::Tuple => {
+                f.write_str("tuple<")?;
+                for (idx, ty) in ty.tuple_element_types().enumerate() {
+                    if idx != 0 {
+                        f.write_str(", ")?;
+                    }
+                    write!(f, "{}", DisplayType(&ty))?;
+                }
+                f.write_str(">")
+            }
+            WasmTypeKind::Variant => {
+                f.write_str("variant { ")?;
+                for (idx, (name, payload)) in ty.variant_cases().enumerate() {
+                    if idx != 0 {
+                        f.write_str(", ")?;
+                    }
+                    f.write_str(name.as_ref())?;
+                    if let Some(ty) = payload {
+                        write!(f, "({})", DisplayType(&ty))?;
+                    }
+                }
+                f.write_str(" }")
+            }
+            WasmTypeKind::Enum => {
+                f.write_str("enum { ")?;
+                for (idx, name) in ty.enum_cases().enumerate() {
+                    if idx != 0 {
+                        f.write_str(", ")?;
+                    }
+                    f.write_str(name.as_ref())?;
+                }
+                f.write_str(" }")
+            }
+            WasmTypeKind::Option => {
+                write!(
+                    f,
+                    "option<{}>",
+                    DisplayType(&ty.option_some_type().unwrap())
+                )
+            }
+            WasmTypeKind::Result => {
+                f.write_str("result")?;
+                match ty.result_types().unwrap() {
+                    (None, None) => Ok(()),
+                    (None, Some(err)) => write!(f, "<_, {}>", DisplayType(&err)),
+                    (Some(ok), None) => write!(f, "<{}>", DisplayType(&ok)),
+                    (Some(ok), Some(err)) => {
+                        write!(f, "<{}, {}>", DisplayType(&ok), DisplayType(&err))
+                    }
+                }
+            }
+            WasmTypeKind::Flags => {
+                f.write_str("flags { ")?;
+                for (idx, name) in ty.flags_names().enumerate() {
+                    if idx != 0 {
+                        f.write_str(", ")?;
+                    }
+                    f.write_str(name.as_ref())?;
+                }
+                f.write_str(" }")
+            }
+            simple => Display::fmt(&simple, f),
+        }
+    }
+}
+
+/// Implements a WAVE-formatted [`Display`] for a [`WasmValue`].
+pub struct DisplayValue<'a, T: WasmValue>(pub &'a T);
+
+impl<'a, T: WasmValue> Display for DisplayValue<'a, T> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let mut buf = vec![];
+        Writer::new(&mut buf)
+            .write_value(self.0)
+            .map_err(|_| std::fmt::Error)?;
+        f.write_str(String::from_utf8_lossy(&buf).as_ref())
+    }
+}
+
+/// Implements a WAVE-formatted [`Display`] for a [`WasmFunc`].
+pub struct DisplayFunc<T: WasmFunc>(pub T);
+
+impl<T: WasmFunc> Display for DisplayFunc<T> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str("func(")?;
+        let mut param_names = self.0.param_names();
+        for (idx, ty) in self.0.params().enumerate() {
+            if idx != 0 {
+                f.write_str(", ")?;
+            }
+            if let Some(name) = param_names.next() {
+                write!(f, "{name}: ")?;
+            }
+            DisplayType(&ty).fmt(f)?
+        }
+        f.write_str(")")?;
+
+        let results = self.0.results().collect::<Vec<_>>();
+        if results.is_empty() {
+            return Ok(());
+        }
+
+        let mut result_names = self.0.result_names();
+        if results.len() == 1 {
+            let ty = DisplayType(&results.into_iter().next().unwrap()).to_string();
+            if let Some(name) = result_names.next() {
+                write!(f, " -> ({name}: {ty})")
+            } else {
+                write!(f, " -> {ty}")
+            }
+        } else {
+            f.write_str(" -> (")?;
+            for (idx, ty) in results.into_iter().enumerate() {
+                if idx != 0 {
+                    f.write_str(", ")?;
+                }
+                if let Some(name) = result_names.next() {
+                    write!(f, "{name}: ")?;
+                }
+                DisplayType(&ty).fmt(f)?;
+            }
+            f.write_str(")")
+        }
+    }
+}
+
+/// Implements a WAVE-formatted [`Display`] for [`WasmValue`] func arguments.
+pub struct DisplayFuncArgs<'a, T: WasmValue>(pub &'a [T]);
+
+impl<'a, T: WasmValue> Display for DisplayFuncArgs<'a, T> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str("(")?;
+        for (idx, v) in self.0.iter().enumerate() {
+            if idx != 0 {
+                f.write_str(", ")?;
+            }
+            DisplayValue(v).fmt(f)?;
+        }
+        f.write_str(")")
+    }
+}
+
+/// Implements a WAVE-formatted [`Display`] for [`WasmValue`] func results.
+pub struct DisplayFuncResults<'a, T: WasmValue>(pub &'a [T]);
+
+impl<'a, T: WasmValue> Display for DisplayFuncResults<'a, T> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        if self.0.len() == 1 {
+            DisplayValue(&self.0[0]).fmt(f)
+        } else {
+            DisplayFuncArgs(self.0).fmt(f)
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::value::Type;
+
+    #[test]
+    fn test_type_display() {
+        for (ty, expected) in [
+            (Type::U8, "u8"),
+            (Type::list(Type::U8), "list<u8>"),
+            (
+                Type::record([("a", Type::U8), ("b", Type::STRING)]).unwrap(),
+                "record { a: u8, b: string }",
+            ),
+            (
+                Type::tuple([Type::U8, Type::BOOL]).unwrap(),
+                "tuple<u8, bool>",
+            ),
+            (
+                Type::variant([("off", None), ("on", Some(Type::U8))]).unwrap(),
+                "variant { off, on(u8) }",
+            ),
+            (
+                Type::enum_ty(["east", "west"]).unwrap(),
+                "enum { east, west }",
+            ),
+            (Type::option(Type::U8), "option<u8>"),
+            (Type::result(None, None), "result"),
+            (Type::result(Some(Type::U8), None), "result<u8>"),
+            (Type::result(None, Some(Type::STRING)), "result<_, string>"),
+            (
+                Type::result(Some(Type::U8), Some(Type::STRING)),
+                "result<u8, string>",
+            ),
+            (
+                Type::flags(["read", "write"]).unwrap(),
+                "flags { read, write }",
+            ),
+        ] {
+            assert_eq!(ty.to_string(), expected);
+        }
+    }
+}
diff --git a/crates/wasm-wave/src/wasm/func.rs b/crates/wasm-wave/src/wasm/func.rs
new file mode 100644
index 00000000..171ec12d
--- /dev/null
+++ b/crates/wasm-wave/src/wasm/func.rs
@@ -0,0 +1,33 @@
+use std::borrow::Cow;
+
+use crate::wasm::WasmType;
+
+// TODO: Given recent versions of rustc we may want to take a look at changing the returns of boxed
+// iterators to -> impl Iterator
+
+/// The WasmFunc trait may be implemented to represent Wasm func type
+/// signatures to be (de)serialized with WAVE.
+pub trait WasmFunc {
+    /// A type representing types of these params and results.
+    type Type: WasmType;
+
+    /// Returns an iterator of the func's parameter types.
+    fn params(&self) -> Box<dyn Iterator<Item = Self::Type> + '_>;
+
+    /// Returns an iterator of the func's parameter names. Must be the same
+    /// length as the iterator returned by `params` or empty if this WasmFunc
+    /// impl does not support param names.
+    fn param_names(&self) -> Box<dyn Iterator<Item = Cow<str>> + '_> {
+        Box::new(std::iter::empty())
+    }
+
+    /// Returns an iterator of the func's result types.
+    fn results(&self) -> Box<dyn Iterator<Item = Self::Type> + '_>;
+
+    /// Returns an iterator of the func's result names. Must be the same
+    /// length as the iterator returned by `results` or empty if there are no
+    /// named results or if this WasmFunc impl does not support result names.
+    fn result_names(&self) -> Box<dyn Iterator<Item = Cow<str>> + '_> {
+        Box::new(std::iter::empty())
+    }
+}
diff --git a/crates/wasm-wave/src/wasm/mod.rs b/crates/wasm-wave/src/wasm/mod.rs
new file mode 100644
index 00000000..237ccba7
--- /dev/null
+++ b/crates/wasm-wave/src/wasm/mod.rs
@@ -0,0 +1,78 @@
+//! Wasm type and value types
+
+mod fmt;
+mod func;
+mod ty;
+mod val;
+
+pub use fmt::{DisplayFunc, DisplayFuncArgs, DisplayFuncResults, DisplayType, DisplayValue};
+pub use func::WasmFunc;
+pub use ty::{WasmType, WasmTypeKind};
+pub use val::WasmValue;
+
+pub(crate) use ty::maybe_unwrap_type;
+pub(crate) use val::unwrap_val;
+
+/// Returns an error if the given [`WasmType`] is not of the given [`WasmTypeKind`].
+pub fn ensure_type_kind(ty: &impl WasmType, kind: WasmTypeKind) -> Result<(), WasmValueError> {
+    if ty.kind() == kind {
+        Ok(())
+    } else {
+        Err(WasmValueError::WrongTypeKind {
+            ty: DisplayType(ty).to_string(),
+            kind,
+        })
+    }
+}
+
+/// An error from creating a [`WasmValue`].
+#[derive(Debug)]
+#[allow(missing_docs)]
+pub enum WasmValueError {
+    MissingField(String),
+    MissingPayload(String),
+    UnexpectedPayload(String),
+    UnknownCase(String),
+    UnknownField(String),
+    UnsupportedType(String),
+    WrongNumberOfTupleValues { want: usize, got: usize },
+    WrongTypeKind { kind: WasmTypeKind, ty: String },
+    WrongValueType { ty: String, val: String },
+    Other(String),
+}
+
+impl WasmValueError {
+    pub(crate) fn wrong_value_type(ty: &impl WasmType, val: &impl WasmValue) -> Self {
+        Self::WrongValueType {
+            ty: DisplayType(ty).to_string(),
+            val: DisplayValue(val).to_string(),
+        }
+    }
+}
+
+impl std::fmt::Display for WasmValueError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::MissingField(name) => {
+                write!(f, "missing field {name:?}")
+            }
+            Self::MissingPayload(name) => write!(f, "missing payload for {name:?} case"),
+            Self::UnexpectedPayload(name) => write!(f, "unexpected payload for {name:?} case"),
+            Self::UnknownCase(name) => write!(f, "unknown case {name:?}"),
+            Self::UnknownField(name) => write!(f, "unknown field {name:?}"),
+            Self::UnsupportedType(ty) => write!(f, "unsupported type {ty}"),
+            Self::WrongNumberOfTupleValues { want, got } => {
+                write!(f, "expected {want} tuple elements; got {got}")
+            }
+            Self::WrongTypeKind { kind, ty } => {
+                write!(f, "expected a {kind}; got {ty}")
+            }
+            Self::WrongValueType { ty, val } => {
+                write!(f, "expected a {ty}; got {val}")
+            }
+            Self::Other(msg) => write!(f, "{msg}"),
+        }
+    }
+}
+
+impl std::error::Error for WasmValueError {}
diff --git a/crates/wasm-wave/src/wasm/ty.rs b/crates/wasm-wave/src/wasm/ty.rs
new file mode 100644
index 00000000..b9baea45
--- /dev/null
+++ b/crates/wasm-wave/src/wasm/ty.rs
@@ -0,0 +1,137 @@
+use std::{borrow::Cow, fmt::Debug};
+
+/// The kind of a [`WasmType`]. These correspond to the value types defined by the
+/// [Component Model design](https://github.com/WebAssembly/component-model/blob/673d5c43c3cc0f4aeb8996a5c0931af623f16808/design/mvp/WIT.md).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[allow(missing_docs)]
+#[non_exhaustive]
+pub enum WasmTypeKind {
+    Bool,
+    S8,
+    S16,
+    S32,
+    S64,
+    U8,
+    U16,
+    U32,
+    U64,
+    Float32,
+    Float64,
+    Char,
+    String,
+    List,
+    Record,
+    Tuple,
+    Variant,
+    Enum,
+    Option,
+    Result,
+    Flags,
+    #[doc(hidden)]
+    Unsupported,
+}
+
+impl std::fmt::Display for WasmTypeKind {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(match self {
+            WasmTypeKind::Bool => "bool",
+            WasmTypeKind::S8 => "s8",
+            WasmTypeKind::S16 => "s16",
+            WasmTypeKind::S32 => "s32",
+            WasmTypeKind::S64 => "s64",
+            WasmTypeKind::U8 => "u8",
+            WasmTypeKind::U16 => "u16",
+            WasmTypeKind::U32 => "u32",
+            WasmTypeKind::U64 => "u64",
+            WasmTypeKind::Float32 => "float32",
+            WasmTypeKind::Float64 => "float64",
+            WasmTypeKind::Char => "char",
+            WasmTypeKind::String => "string",
+            WasmTypeKind::List => "list",
+            WasmTypeKind::Record => "record",
+            WasmTypeKind::Tuple => "tuple",
+            WasmTypeKind::Variant => "variant",
+            WasmTypeKind::Enum => "enum",
+            WasmTypeKind::Option => "option",
+            WasmTypeKind::Result => "result",
+            WasmTypeKind::Flags => "flags",
+            WasmTypeKind::Unsupported => "<<UNSUPPORTED>>",
+        })
+    }
+}
+
+/// The WasmType trait may be implemented to represent types to be
+/// (de)serialized with WAVE, notably [`value::Type`](crate::value::Type).
+/// The [`wasmtime`] crate provides an impl for [`wasmtime::component::Type`].
+///
+/// The `Self`-returning methods should be called only for corresponding
+/// [`WasmTypeKind`]s.
+pub trait WasmType: Clone + Sized {
+    /// Returns the [`WasmTypeKind`] of this type.
+    fn kind(&self) -> WasmTypeKind;
+
+    /// Returns the list element type or `None` if `self` is not a list type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn list_element_type(&self) -> Option<Self> {
+        unimplemented!()
+    }
+    /// Returns an iterator of the record's field names and Types. The
+    /// iterator will be empty iff `self` is not a record type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn record_fields(&self) -> Box<dyn Iterator<Item = (Cow<str>, Self)> + '_> {
+        unimplemented!()
+    }
+    /// Returns an iterator of the tuple's field Types. The iterator will be
+    /// empty iff `self` is not a tuple type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn tuple_element_types(&self) -> Box<dyn Iterator<Item = Self> + '_> {
+        unimplemented!()
+    }
+    /// Returns an iterator of the variant's case names and optional payload
+    /// Types. The iterator will be empty iff `self` is not a tuple type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn variant_cases(&self) -> Box<dyn Iterator<Item = (Cow<str>, Option<Self>)> + '_> {
+        unimplemented!()
+    }
+    /// Returns an iterator of the enum's case names. The iterator will be
+    /// empty iff `self` is not an enum type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn enum_cases(&self) -> Box<dyn Iterator<Item = Cow<str>> + '_> {
+        unimplemented!()
+    }
+    /// Returns the option's "some" type or `None` if `self` is not an option type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn option_some_type(&self) -> Option<Self> {
+        unimplemented!()
+    }
+    /// Returns the result's optional "ok" and "err" Types or `None` if `self`
+    /// is not a result type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn result_types(&self) -> Option<(Option<Self>, Option<Self>)> {
+        unimplemented!()
+    }
+    /// Returns an iterator of the flags' names. The iterator will be empty iff
+    /// `self` is not a flags type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn flags_names(&self) -> Box<dyn Iterator<Item = Cow<str>> + '_> {
+        unimplemented!()
+    }
+}
+
+macro_rules! maybe_unwrap_type {
+    ($ty:expr, $case:path) => {
+        match $ty {
+            $case(v) => Some(v),
+            _ => None,
+        }
+    };
+}
+pub(crate) use maybe_unwrap_type;
diff --git a/crates/wasm-wave/src/wasm/val.rs b/crates/wasm-wave/src/wasm/val.rs
new file mode 100644
index 00000000..69413c54
--- /dev/null
+++ b/crates/wasm-wave/src/wasm/val.rs
@@ -0,0 +1,329 @@
+use std::borrow::Cow;
+
+use crate::wasm::{WasmType, WasmTypeKind, WasmValueError};
+
+/// The WasmValue trait may be implemented to represent values to be
+/// (de)serialized with WAVE, notably [`value::Value`](crate::value::Value).
+/// The `wasmtime` crate provides an impl for [`wasmtime::component::Val`].
+///
+/// The `make_*` and `unwrap_*` methods should be called only for corresponding
+/// [`WasmTypeKind`](crate::wasm::WasmTypeKind)s.
+#[allow(unused_variables)]
+pub trait WasmValue: Clone + Sized {
+    /// A type representing types of these values.
+    type Type: WasmType;
+
+    /// The kind of type of this value.
+    fn kind(&self) -> WasmTypeKind;
+
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_bool(val: bool) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_s8(val: i8) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_s16(val: i16) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_s32(val: i32) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_s64(val: i64) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_u8(val: u8) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_u16(val: u16) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_u32(val: u32) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_u64(val: u64) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    ///
+    /// The Rust `f32` type has many distinct NaN bitpatterns, however the
+    /// component-model `float32` type only has a single NaN value, so this
+    /// function does not preserve NaN bitpatterns.
+    ///
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_float32(val: f32) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    ///
+    /// The Rust `f64` type has many distinct NaN bitpatterns, however the
+    /// component-model `float64` type only has a single NaN value, so this
+    /// function does not preserve NaN bitpatterns.
+    ///
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_float64(val: f64) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_char(val: char) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_string(val: Cow<str>) -> Self {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_list(
+        ty: &Self::Type,
+        vals: impl IntoIterator<Item = Self>,
+    ) -> Result<Self, WasmValueError> {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    ///
+    /// The fields provided by `fields` are not necessarily sorted; the callee
+    /// should perform sorting itself if needed.
+    ///
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_record<'a>(
+        ty: &Self::Type,
+        fields: impl IntoIterator<Item = (&'a str, Self)>,
+    ) -> Result<Self, WasmValueError> {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_tuple(
+        ty: &Self::Type,
+        vals: impl IntoIterator<Item = Self>,
+    ) -> Result<Self, WasmValueError> {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_variant(
+        ty: &Self::Type,
+        case: &str,
+        val: Option<Self>,
+    ) -> Result<Self, WasmValueError> {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_enum(ty: &Self::Type, case: &str) -> Result<Self, WasmValueError> {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_option(ty: &Self::Type, val: Option<Self>) -> Result<Self, WasmValueError> {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_result(
+        ty: &Self::Type,
+        val: Result<Option<Self>, Option<Self>>,
+    ) -> Result<Self, WasmValueError> {
+        unimplemented!()
+    }
+    /// Returns a new WasmValue of the given type.
+    ///
+    /// The strings provided by `names` are not necessarily sorted; the callee
+    /// should perform sorting itself if needed.
+    ///
+    /// # Panics
+    /// Panics if the type is not implemented (the trait default).
+    fn make_flags<'a>(
+        ty: &Self::Type,
+        names: impl IntoIterator<Item = &'a str>,
+    ) -> Result<Self, WasmValueError> {
+        unimplemented!()
+    }
+
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_bool(&self) -> bool {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_s8(&self) -> i8 {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_s16(&self) -> i16 {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_s32(&self) -> i32 {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_s64(&self) -> i64 {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_u8(&self) -> u8 {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_u16(&self) -> u16 {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_u32(&self) -> u32 {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_u64(&self) -> u64 {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    ///
+    /// The Rust `f32` type has many distinct NaN bitpatterns, however the
+    /// component-model `float64` type only has a single NaN value, so this
+    /// function does not preserve NaN bitpatterns.
+    ///
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_float32(&self) -> f32 {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    ///
+    /// The Rust `f64` type has many distinct NaN bitpatterns, however the
+    /// component-model `float64` type only has a single NaN value, so this
+    /// function does not preserve NaN bitpatterns.
+    ///
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_float64(&self) -> f64 {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_char(&self) -> char {
+        unimplemented!()
+    }
+    /// Returns the underlying value of the WasmValue, panicing if it's the wrong type.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_string(&self) -> Cow<str> {
+        unimplemented!()
+    }
+    /// Returns an iterator of the element Vals of the list.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_list(&self) -> Box<dyn Iterator<Item = Cow<Self>> + '_> {
+        unimplemented!()
+    }
+    /// Returns an iterator of the field names and Vals of the record.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_record(&self) -> Box<dyn Iterator<Item = (Cow<str>, Cow<Self>)> + '_> {
+        unimplemented!()
+    }
+    /// Returns an iterator of the field Vals of the tuple.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_tuple(&self) -> Box<dyn Iterator<Item = Cow<Self>> + '_> {
+        unimplemented!()
+    }
+    /// Returns the variant case name and optional payload WasmValue of the variant.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_variant(&self) -> (Cow<str>, Option<Cow<Self>>) {
+        unimplemented!()
+    }
+    /// Returns the case name of the enum.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_enum(&self) -> Cow<str> {
+        unimplemented!()
+    }
+    /// Returns the optional WasmValue.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_option(&self) -> Option<Cow<Self>> {
+        unimplemented!()
+    }
+    /// Returns Ok(_) or Err(_) with the optional payload WasmValue.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_result(&self) -> Result<Option<Cow<Self>>, Option<Cow<Self>>> {
+        unimplemented!()
+    }
+    /// Returns an iterator of the names of the flags WasmValue.
+    /// # Panics
+    /// Panics if `self` is not of the right type.
+    fn unwrap_flags(&self) -> Box<dyn Iterator<Item = Cow<str>> + '_> {
+        unimplemented!()
+    }
+}
+
+macro_rules! unwrap_val {
+    ($val:expr, $case:path, $name:expr) => {
+        match $val {
+            $case(v) => v,
+            _ => panic!("called unwrap_{name} on non-{name} value", name = $name),
+        }
+    };
+}
+pub(crate) use unwrap_val;
diff --git a/crates/wasm-wave/src/writer.rs b/crates/wasm-wave/src/writer.rs
new file mode 100644
index 00000000..f857624c
--- /dev/null
+++ b/crates/wasm-wave/src/writer.rs
@@ -0,0 +1,200 @@
+//! WAVE writer
+
+use std::{fmt::Debug, io::Write};
+
+use thiserror::Error;
+
+use crate::{
+    lex::Keyword,
+    wasm::{WasmTypeKind, WasmValue},
+};
+
+/// A Web Assembly Value Encoding writer.
+///
+/// Writes to the wrapped `W` writer.
+pub struct Writer<W> {
+    inner: W,
+}
+
+impl<W: Write> Writer<W> {
+    /// Returns a new Writer for the given [`std::io::Write`].
+    pub fn new(w: W) -> Self {
+        Self { inner: w }
+    }
+
+    /// WAVE-encodes and writes the given [`WasmValue`] to the underlying writer.
+    pub fn write_value<V>(&mut self, val: &V) -> Result<(), WriterError>
+    where
+        V: WasmValue,
+    {
+        match val.kind() {
+            WasmTypeKind::Bool => self.write_str(if val.unwrap_bool() { "true" } else { "false" }),
+            WasmTypeKind::S8 => self.write_display(val.unwrap_s8()),
+            WasmTypeKind::S16 => self.write_display(val.unwrap_s16()),
+            WasmTypeKind::S32 => self.write_display(val.unwrap_s32()),
+            WasmTypeKind::S64 => self.write_display(val.unwrap_s64()),
+            WasmTypeKind::U8 => self.write_display(val.unwrap_u8()),
+            WasmTypeKind::U16 => self.write_display(val.unwrap_u16()),
+            WasmTypeKind::U32 => self.write_display(val.unwrap_u32()),
+            WasmTypeKind::U64 => self.write_display(val.unwrap_u64()),
+            WasmTypeKind::Float32 => {
+                let f = val.unwrap_float32();
+                if f.is_nan() {
+                    self.write_str("nan") // Display is "NaN"
+                } else {
+                    self.write_display(f)
+                }
+            }
+            WasmTypeKind::Float64 => {
+                let f = val.unwrap_float64();
+                if f.is_nan() {
+                    self.write_str("nan") // Display is "NaN"
+                } else {
+                    self.write_display(f)
+                }
+            }
+            WasmTypeKind::Char => {
+                self.write_str("'")?;
+                self.write_char(val.unwrap_char())?;
+                self.write_str("'")
+            }
+            WasmTypeKind::String => {
+                self.write_str("\"")?;
+                for ch in val.unwrap_string().chars() {
+                    self.write_char(ch)?;
+                }
+                self.write_str("\"")
+            }
+            WasmTypeKind::List => {
+                self.write_str("[")?;
+                for (idx, val) in val.unwrap_list().enumerate() {
+                    if idx != 0 {
+                        self.write_str(", ")?;
+                    }
+                    self.write_value(&*val)?;
+                }
+                self.write_str("]")
+            }
+            WasmTypeKind::Record => {
+                self.write_str("{")?;
+                let mut first = true;
+                for (name, val) in val.unwrap_record() {
+                    if !matches!(val.kind(), WasmTypeKind::Option) || val.unwrap_option().is_some()
+                    {
+                        if first {
+                            first = false;
+                        } else {
+                            self.write_str(", ")?;
+                        }
+                        self.write_str(name)?;
+                        self.write_str(": ")?;
+                        self.write_value(&*val)?;
+                    }
+                }
+                if first {
+                    self.write_str(":")?;
+                }
+                self.write_str("}")
+            }
+            WasmTypeKind::Tuple => {
+                self.write_str("(")?;
+                for (idx, val) in val.unwrap_tuple().enumerate() {
+                    if idx != 0 {
+                        self.write_str(", ")?;
+                    }
+                    self.write_value(&*val)?;
+                }
+                self.write_str(")")
+            }
+            WasmTypeKind::Variant => {
+                let (name, val) = val.unwrap_variant();
+                if Keyword::decode(&name).is_some() {
+                    self.write_char('%')?;
+                }
+                self.write_str(name)?;
+                if let Some(val) = val {
+                    self.write_str("(")?;
+                    self.write_value(&*val)?;
+                    self.write_str(")")?;
+                }
+                Ok(())
+            }
+            WasmTypeKind::Enum => {
+                let case = val.unwrap_enum();
+                if Keyword::decode(&case).is_some() {
+                    self.write_char('%')?;
+                }
+                self.write_str(case)
+            }
+            WasmTypeKind::Option => match val.unwrap_option() {
+                Some(val) => {
+                    self.write_str("some(")?;
+                    self.write_value(&*val)?;
+                    self.write_str(")")
+                }
+                None => self.write_str("none"),
+            },
+            WasmTypeKind::Result => {
+                let (name, val) = match val.unwrap_result() {
+                    Ok(val) => ("ok", val),
+                    Err(val) => ("err", val),
+                };
+                self.write_str(name)?;
+                if let Some(val) = val {
+                    self.write_str("(")?;
+                    self.write_value(&*val)?;
+                    self.write_str(")")?;
+                }
+                Ok(())
+            }
+            WasmTypeKind::Flags => {
+                self.write_str("{")?;
+                for (idx, name) in val.unwrap_flags().enumerate() {
+                    if idx != 0 {
+                        self.write_str(", ")?;
+                    }
+                    self.write_str(name)?;
+                }
+                self.write_str("}")?;
+                Ok(())
+            }
+            WasmTypeKind::Unsupported => panic!("unsupported value type"),
+        }
+    }
+
+    fn write_str(&mut self, s: impl AsRef<str>) -> Result<(), WriterError> {
+        self.inner.write_all(s.as_ref().as_bytes())?;
+        Ok(())
+    }
+
+    fn write_display(&mut self, d: impl std::fmt::Display) -> Result<(), WriterError> {
+        write!(self.inner, "{d}")?;
+        Ok(())
+    }
+
+    fn write_char(&mut self, ch: char) -> Result<(), WriterError> {
+        if "\\\"\'\t\r\n".contains(ch) {
+            write!(self.inner, "{}", ch.escape_default())?;
+        } else if ch.is_control() {
+            write!(self.inner, "{}", ch.escape_unicode())?;
+        } else {
+            write!(self.inner, "{}", ch.escape_debug())?;
+        }
+        Ok(())
+    }
+}
+
+impl<W> AsMut<W> for Writer<W> {
+    fn as_mut(&mut self) -> &mut W {
+        &mut self.inner
+    }
+}
+
+/// A Writer error.
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum WriterError {
+    /// An error from the underlying writer
+    #[error("write failed: {0}")]
+    Io(#[from] std::io::Error),
+}
diff --git a/crates/wasm-wave/wave_ebnf.md b/crates/wasm-wave/wave_ebnf.md
new file mode 100644
index 00000000..7eb3790e
--- /dev/null
+++ b/crates/wasm-wave/wave_ebnf.md
@@ -0,0 +1,84 @@
+# WAVE EBNF
+
+A WAVE value is defined by the `value` rule below. Many applications may allow
+whitespace around the value, equivalent to the `value-ws` rule.
+
+> Note that Bool, Variant, Enum, Option and Result values are combined under
+> the `variant-case` rule because these cannot be distinguished without type
+> information.
+
+```ebnf
+value ::= number
+        | char
+        | string
+        | variant-case
+        | tuple
+        | list
+        | flags
+        | record
+
+value-ws ::= ws value ws
+ws ::= ([ \t\n\r]* comment?)*
+comment ::= '//' [^\n]*
+
+number ::= number_finite
+         | 'nan'
+         | 'inf'
+         | '-inf'
+number_finite ::= integer number-fraction? number-exponent?
+integer ::= unsigned-integer
+          | '-' unsigned-integer
+unsigned-integer ::= '0'
+                   | [1-9] [0-9]*
+number-fraction ::= '.' [0-9]+
+number-exponent ::= [eE] [+-]? unsigned-integer
+
+char ::= ['] char-char [']
+char-char ::= common-char | '"'
+
+string ::= '"' string-char* '"'
+string-char ::= common-char | [']
+
+multiline-string ::= '"""' line-break multiline-string-line* [ ]* '"""'
+multiline-string-line ::= [ ]* multiline-string-char* line-break
+multiline-string-char ::= common-char | ['"]
+
+line-break ::= '\r\n' | '\n'
+
+common-char ::= <any Unicode Scalar Value except ['"\n\\]>
+              | '\' escape
+escape ::= ['"tnr\\] | escape-unicode
+escape-unicode ::= 'u{' [0-9a-fA-F]+ '}'
+
+variant-case ::= label ws variant-case-payload?
+variant-case-payload ::= '(' value-ws ')'
+
+tuple ::= '(' values-seq ','? ws ')'
+
+list ::= '[' ws ']'
+       | '[' values-seq ','? ws ']'
+
+values-seq ::= value-ws
+             | values ',' values-ws
+
+flags ::= '{' ws '}'
+        | '{' flags-seq ','? ws '}'
+flags-seq ::= ws label ws
+            | flags-seq ',' label
+
+record ::= '{' ws ':' ws '}'
+         | '{' record-fields ','? ws '}'
+record-fields ::= ws record-field ws
+                | record-fields ',' record-field
+record-field ::= label ws ':' ws value
+
+label ::= '%'? inner-label
+inner-label ::= word
+              | inner-label '-' word
+word ::= [a-z][a-z0-9]*
+       | [A-Z][A-Z0-9]*
+```
+
+* "`Unicode scalar value`" is defined by Unicode
+* `escape-unicode` must identify a valid Unicode scalar value.
+* `multiline-string-line` must not contain `"""`
\ No newline at end of file


Test patch:
diff --git a/crates/wasm-wave/src/value/tests.rs b/crates/wasm-wave/src/value/tests.rs
new file mode 100644
index 00000000..0a4dc9e8
--- /dev/null
+++ b/crates/wasm-wave/src/value/tests.rs
@@ -0,0 +1,154 @@
+use crate::WasmValue;
+
+use super::{Type, Value};
+
+#[test]
+fn simple_value_round_trips() {
+    for val in [
+        Value::make_bool(true),
+        Value::make_u8(u8::MAX),
+        Value::make_u16(u16::MAX),
+        Value::make_u32(u32::MAX),
+        Value::make_u64(u64::MAX),
+        Value::make_s8(i8::MIN),
+        Value::make_s16(i16::MIN),
+        Value::make_s32(i32::MIN),
+        Value::make_s64(i64::MIN),
+        Value::make_char('☃'),
+        Value::make_string("str".into()),
+    ] {
+        test_value_round_trip(val)
+    }
+}
+
+#[test]
+fn float_round_trips() {
+    for (float32, float64) in [
+        (0.0, 0.0),
+        (f32::MIN, f64::MIN),
+        (f32::MIN_POSITIVE, f64::MIN_POSITIVE),
+        (f32::MAX, f64::MAX),
+        (f32::EPSILON, f64::EPSILON),
+        (f32::INFINITY, f64::INFINITY),
+        (f32::NEG_INFINITY, f64::NEG_INFINITY),
+    ] {
+        test_value_round_trip(Value::make_float32(float32));
+        test_value_round_trip(Value::make_float64(float64));
+    }
+}
+
+#[test]
+fn list_round_trips() {
+    let ty = Type::list(Type::U8);
+    test_value_round_trip(Value::make_list(&ty, []).unwrap());
+    test_value_round_trip(Value::make_list(&ty, [Value::make_u8(1)]).unwrap());
+    test_value_round_trip(Value::make_list(&ty, [Value::make_u8(1), Value::make_u8(2)]).unwrap());
+}
+
+#[test]
+fn record_round_trip() {
+    let option_ty = Type::option(Type::U8);
+    let record_ty =
+        Type::record([("field-a", Type::BOOL), ("field-b", option_ty.clone())]).unwrap();
+    let record_val = Value::make_record(
+        &record_ty,
+        [
+            ("field-a", Value::make_bool(true)),
+            ("field-b", Value::make_option(&option_ty, None).unwrap()),
+        ],
+    )
+    .unwrap();
+    test_value_round_trip(record_val)
+}
+
+#[test]
+fn tuple_round_trip() {
+    let ty = Type::tuple([Type::BOOL, Type::U8]).unwrap();
+    let val = Value::make_tuple(&ty, [Value::make_bool(true), Value::make_u8(1)]).unwrap();
+    test_value_round_trip(val);
+}
+
+#[test]
+fn variant_round_trips() {
+    let ty = Type::variant([("off", None), ("on", Some(Type::U8))]).unwrap();
+    test_value_round_trip(Value::make_variant(&ty, "off", None).unwrap());
+    test_value_round_trip(Value::make_variant(&ty, "on", Some(Value::make_u8(1))).unwrap());
+}
+
+#[test]
+fn enum_round_trips() {
+    let ty = Type::enum_ty(["north", "east", "south", "west"]).unwrap();
+    test_value_round_trip(Value::make_enum(&ty, "north").unwrap());
+    test_value_round_trip(Value::make_enum(&ty, "south").unwrap());
+}
+
+#[test]
+fn option_round_trips() {
+    let ty = Type::option(Type::U8);
+    test_value_round_trip(Value::make_option(&ty, Some(Value::make_u8(1))).unwrap());
+    test_value_round_trip(Value::make_option(&ty, None).unwrap());
+}
+
+#[test]
+fn result_round_trips() {
+    let no_payloads = Type::result(None, None);
+    let both_payloads = Type::result(Some(Type::U8), Some(Type::STRING));
+    let ok_only = Type::result(Some(Type::U8), None);
+    let err_only = Type::result(None, Some(Type::STRING));
+    for (ty, payload) in [
+        (&no_payloads, Ok(None)),
+        (&no_payloads, Err(None)),
+        (&both_payloads, Ok(Some(Value::make_u8(1)))),
+        (&both_payloads, Err(Some(Value::make_string("oops".into())))),
+        (&ok_only, Ok(Some(Value::make_u8(1)))),
+        (&ok_only, Err(None)),
+        (&err_only, Ok(None)),
+        (&err_only, Err(Some(Value::make_string("oops".into())))),
+    ] {
+        let val = Value::make_result(ty, payload).unwrap();
+        test_value_round_trip(val);
+    }
+}
+
+#[test]
+fn flags_round_trips() {
+    let ty = Type::flags(["read", "write", "execute"]).unwrap();
+    test_value_round_trip(Value::make_flags(&ty, []).unwrap());
+    test_value_round_trip(Value::make_flags(&ty, ["write"]).unwrap());
+    test_value_round_trip(Value::make_flags(&ty, ["read", "execute"]).unwrap());
+}
+
+fn local_ty(val: &Value) -> Type {
+    use crate::value::{TypeEnum, ValueEnum};
+    match &val.0 {
+        ValueEnum::Bool(_) => Type::BOOL,
+        ValueEnum::S8(_) => Type::S8,
+        ValueEnum::S16(_) => Type::S16,
+        ValueEnum::S32(_) => Type::S32,
+        ValueEnum::S64(_) => Type::S64,
+        ValueEnum::U8(_) => Type::U8,
+        ValueEnum::U16(_) => Type::U16,
+        ValueEnum::U32(_) => Type::U32,
+        ValueEnum::U64(_) => Type::U64,
+        ValueEnum::Float32(_) => Type::FLOAT32,
+        ValueEnum::Float64(_) => Type::FLOAT64,
+        ValueEnum::Char(_) => Type::CHAR,
+        ValueEnum::String(_) => Type::STRING,
+        ValueEnum::List(inner) => Type(TypeEnum::List(inner.ty.clone())),
+        ValueEnum::Record(inner) => Type(TypeEnum::Record(inner.ty.clone())),
+        ValueEnum::Tuple(inner) => Type(TypeEnum::Tuple(inner.ty.clone())),
+        ValueEnum::Variant(inner) => Type(TypeEnum::Variant(inner.ty.clone())),
+        ValueEnum::Enum(inner) => Type(TypeEnum::Enum(inner.ty.clone())),
+        ValueEnum::Option(inner) => Type(TypeEnum::Option(inner.ty.clone())),
+        ValueEnum::Result(inner) => Type(TypeEnum::Result(inner.ty.clone())),
+        ValueEnum::Flags(inner) => Type(TypeEnum::Flags(inner.ty.clone())),
+    }
+}
+
+fn test_value_round_trip(val: Value) {
+    let ty = local_ty(&val);
+    let serialized = crate::to_string(&val).unwrap();
+    let deserialized: Value = crate::from_str(&ty, &serialized)
+        .unwrap_or_else(|err| panic!("failed to deserialize {serialized:?}: {err}"));
+    assert_eq!(deserialized, val, "for {val:?}");
+}
diff --git a/crates/wasm-wave/tests/nan.rs b/crates/wasm-wave/tests/nan.rs
new file mode 100644
index 00000000..18609cca
--- /dev/null
+++ b/crates/wasm-wave/tests/nan.rs
@@ -0,0 +1,61 @@
+//! Test that NaN bitpatterns are not propagated through Wave values.
+//!
+//! The component-model floating-point types only have a single NaN value, to
+//! make it easier to exchange values with source languages and protocols where
+//! there is only one NaN value. To help users avoid depending on NaN bits being
+//! propagated, we canonicalize NaNs.
+
+use std::{f32, f64};
+
+use wasm_wave::wasm::WasmValue;
+
+#[test]
+fn nan() {
+    for bits in [
+        0,
+        i32::MIN as u32,
+        1.0_f32.to_bits(),
+        (-f32::consts::TAU).to_bits(),
+        0xffffffff,
+        0x7fff0f0f,
+        0x8f800000,
+        f32::NAN.to_bits(),
+    ] {
+        let val = f32::from_bits(bits);
+        let expected = if val.is_nan() { 0x7fc00000 } else { bits };
+
+        {
+            use wasm_wave::value::Value;
+            assert_eq!(
+                Value::make_float32(val).unwrap_float32().to_bits(),
+                expected
+            );
+        }
+    }
+
+    for bits in [
+        0,
+        i64::MIN as u64,
+        1.0_f64.to_bits(),
+        (-f64::consts::TAU).to_bits(),
+        0xffffffffffffffff,
+        0x7fff0f0f0f0f0f0f,
+        0x8ff0000000000000,
+        f64::NAN.to_bits(),
+    ] {
+        let val = f64::from_bits(bits);
+        let expected = if val.is_nan() {
+            0x7ff8000000000000
+        } else {
+            bits
+        };
+
+        {
+            use wasm_wave::value::Value;
+            assert_eq!(
+                Value::make_float64(val).unwrap_float64().to_bits(),
+                expected
+            );
+        }
+    }
+}
diff --git a/crates/wasm-wave/tests/types.wit b/crates/wasm-wave/tests/types.wit
new file mode 100644
index 00000000..24d930d8
--- /dev/null
+++ b/crates/wasm-wave/tests/types.wit
@@ -0,0 +1,44 @@
+// Regenerate types.wasm:
+// wasm-tools component embed --dummy types.wit | wasm-tools component new -o types.wasm
+
+package tests:tests
+
+world tests {
+    export bools: func() -> tuple<bool, bool>
+    export sints: func() -> tuple<s8, s16, s32, s64>
+    export uints: func() -> tuple<u8, u16, u32, u64>
+    export floats: func() -> tuple<float32, float64>
+    export options: func() -> tuple<option<u8>, option<option<s8>>>
+    export list-chars: func() -> list<char>
+    export list-strings: func() -> list<string>
+    export result-ok-only: func() -> result<u8>
+    export result-err-only: func() -> result<_, s8>
+    export result-no-payloads: func() -> result
+    export result-both-payloads: func() -> result<u8, s8>
+
+    record record-type {
+        required: u8,
+        optional: option<u8>,
+    }
+    export %record: func() -> record-type
+
+    variant variant-type {
+        without-payload,
+        with-payload(u8),
+    }
+    export %variant: func() -> variant-type
+
+    enum enum-type {
+        first,
+        second,
+    }
+    export %enum: func() -> enum-type
+
+    flags flags-type {
+        read,
+        write,
+    }
+    export %flags: func() -> flags-type
+
+    export func-type: func(a: bool, b: enum-type) -> result<u8>
+}
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui.rs b/crates/wasm-wave/tests/ui.rs
new file mode 100644
index 00000000..4157c349
--- /dev/null
+++ b/crates/wasm-wave/tests/ui.rs
@@ -0,0 +1,119 @@
+use anyhow::{Context, Result};
+use std::{collections::HashMap, fmt::Write, fs, path::Path, sync::OnceLock};
+
+use wasm_wave::{
+    parser::ParserError,
+    untyped::UntypedFuncCall,
+    value::{resolve_wit_func_type, FuncType, Value},
+    wasm::{DisplayValue, WasmFunc},
+};
+use wit_parser::Resolve;
+
+fn test(path: &Path) -> Result<String> {
+    let filename = path.file_name().unwrap().to_string_lossy();
+    let inputs = std::fs::read_to_string(path)?;
+    let mut output = String::new();
+    let out = &mut output;
+    let inputs = inputs.trim_end().trim_end_matches(';');
+    for mut input in inputs.split(";\n") {
+        // Copy leading comments into the output
+        while input.starts_with("//") {
+            let Some((comment, remainder)) = input.split_once('\n') else {
+                break;
+            };
+            input = remainder;
+            writeln!(out, "{comment}")?;
+            continue;
+        }
+
+        fn parse_func_call(
+            input: &str,
+        ) -> Result<(String, &'static FuncType, Vec<Value>), ParserError> {
+            let untyped_call = UntypedFuncCall::parse(input)?;
+            let func_name = untyped_call.name().to_string();
+            let func_type = get_func_type(&func_name).unwrap_or_else(|| {
+                panic!("unknown test func {func_name:?}");
+            });
+            let param_types = func_type.params().collect::<Vec<_>>();
+            let values = untyped_call.to_wasm_params::<Value>(&param_types)?;
+            Ok((func_name, func_type, values))
+        }
+
+        match parse_func_call(input) {
+            Ok((func_name, func_type, values)) => {
+                assert!(
+                    !filename.starts_with("reject-"),
+                    "accepted input {input:?} in {filename}"
+                );
+                write!(out, "{func_name}(")?;
+                let mut first = true;
+                for (name, value) in func_type.param_names().zip(values) {
+                    if first {
+                        first = false;
+                    } else {
+                        write!(out, ", ")?;
+                    }
+                    write!(out, "{name}: {value}", value = DisplayValue(&value))?;
+                }
+                writeln!(out, ")")?;
+            }
+            Err(err) => {
+                assert!(
+                    !filename.starts_with("accept-"),
+                    "rejected input {input:?} in {filename}: {err:#}"
+                );
+                writeln!(out, "{err}")?;
+            }
+        }
+    }
+    Ok(output)
+}
+
+fn get_func_type(func_name: &str) -> Option<&'static FuncType> {
+    static FUNC_TYPES: OnceLock<HashMap<String, FuncType>> = OnceLock::new();
+    FUNC_TYPES
+        .get_or_init(|| {
+            let mut resolve = Resolve::new();
+            resolve.push_path("tests/ui/ui.wit").unwrap();
+            resolve
+                .interfaces
+                .iter()
+                .flat_map(|(_, i)| &i.functions)
+                .map(|(name, func)| (name.clone(), resolve_wit_func_type(&resolve, func).unwrap()))
+                .collect::<HashMap<_, _>>()
+        })
+        .get(func_name)
+}
+
+// TODO: this ought to be be migrated to use `libtest-mimic`, which other crates in this workspace
+// use as well
+#[test]
+fn ui() -> Result<()> {
+    for entry in fs::read_dir("tests/ui")? {
+        let path = entry?.path();
+        if path.extension().is_none() {
+            continue;
+        }
+        if path.extension().unwrap() != "waves" {
+            continue;
+        }
+
+        println!("testing {path:?}");
+        let actual = test(&path)?;
+        let expected = path.with_extension("out");
+        if std::env::var_os("BLESS").is_some() {
+            fs::write(&expected, actual)
+                .with_context(|| format!("failed to write {expected:?}"))?;
+        } else {
+            let expected = fs::read_to_string(&expected)
+                .with_context(|| format!("failed to read {expected:?}"))?;
+            assert_eq!(
+                expected, actual,
+                "expectation `{}` did not match actual `{}`",
+                expected, actual,
+            );
+        }
+    }
+
+    Ok(())
+}
diff --git a/crates/wasm-wave/tests/ui/README.md b/crates/wasm-wave/tests/ui/README.md
new file mode 100644
index 00000000..5dbf8a9f
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/README.md
@@ -0,0 +1,13 @@
+# UI ("golden files") Tests
+
+Each `.waves` (WAVE Script) file in this directory contains a set of test inputs; corresponding outputs are in the matching `.out` file (e.g. `test.wave` &rarr; `test.out`).
+
+Each test input looks like `<func-name>(<WAVE value>, ...);\n` where `<func-name>` is a function defined in the `ui.wit` file. For each input the WAVE value arguments are parsed and type-checked against the function parameter types.  The result of this parsing - either a re-encoded copy of the input or an error - is written to the output.
+
+Note that each test input must end with `;\n`. This is not part of WAVE itself; inputs are split on this exact substring so it may not appear anywhere else in inputs.
+
+Comments at the start of a test case are copied into the output.
+
+## Updating Outputs
+
+By default, running the `ui` tests will check outputs against the contents of the existing `.out` files. When updating tests, the `.out` files can be overwritten by setting the environment variable `BLESS=1` when running the tests. Updated outputs should be committed and reviewed.
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/accept-chars.out b/crates/wasm-wave/tests/ui/accept-chars.out
new file mode 100644
index 00000000..5cf7bbb9
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-chars.out
@@ -0,0 +1,6 @@
+list-chars(vals: ['W', 'A', 'V', 'E', '🌊'])
+list-chars(vals: ['\\', '\'', '\"', '\t', '\n', '\r'])
+list-chars(vals: ['\u{0}', '\u{0}'])
+list-chars(vals: ['x', 'x', 'x'])
+list-chars(vals: ['☃', '☃', '☃'])
+list-chars(vals: ['\"', '\"', '\"'])
diff --git a/crates/wasm-wave/tests/ui/accept-chars.waves b/crates/wasm-wave/tests/ui/accept-chars.waves
new file mode 100644
index 00000000..ff83fb54
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-chars.waves
@@ -0,0 +1,6 @@
+list-chars(['W', 'A', 'V', 'E', '🌊']);
+list-chars(['\\', '\'', '\"', '\t', '\n', '\r']);
+list-chars(['\u{0}', '\u{000000}']);
+list-chars(['x', '\u{78}', '\u{000078}']);
+list-chars(['☃', '\u{2603}', '\u{002603}']);
+list-chars(['"', '\"', '\u{22}']);
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/accept-comments.out b/crates/wasm-wave/tests/ui/accept-comments.out
new file mode 100644
index 00000000..d9deb3e1
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-comments.out
@@ -0,0 +1,2 @@
+basic-record(rec: {required: 1, optional: some(1)})
+basic-record(rec: {required: 1, optional: some(1)})
diff --git a/crates/wasm-wave/tests/ui/accept-comments.waves b/crates/wasm-wave/tests/ui/accept-comments.waves
new file mode 100644
index 00000000..7f8c1893
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-comments.waves
@@ -0,0 +1,19 @@
+basic-record(       // comment
+  {                 // comment
+    required: 1,    // comment
+    optional: some( // comment
+      1             // comment
+    ),              // comment
+  }                 // comment
+)                   // comment;
+basic-record(
+  // comment
+  {
+    // comment
+    required: 1,
+    // comment
+    optional: some(1)
+    // comment
+  }
+  // comment
+);
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/accept-enums.out b/crates/wasm-wave/tests/ui/accept-enums.out
new file mode 100644
index 00000000..1c203d5f
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-enums.out
@@ -0,0 +1,10 @@
+hand-enum(enm: left)
+hand-enum(enm: left)
+keyword-cases-enum(enm: %true)
+keyword-cases-enum(enm: %false)
+keyword-cases-enum(enm: %some)
+keyword-cases-enum(enm: %none)
+keyword-cases-enum(enm: %ok)
+keyword-cases-enum(enm: %err)
+keyword-cases-enum(enm: %inf)
+keyword-cases-enum(enm: %nan)
diff --git a/crates/wasm-wave/tests/ui/accept-enums.waves b/crates/wasm-wave/tests/ui/accept-enums.waves
new file mode 100644
index 00000000..45d6dd11
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-enums.waves
@@ -0,0 +1,10 @@
+hand-enum(left);
+hand-enum(%left);
+keyword-cases-enum(%true);
+keyword-cases-enum(%false);
+keyword-cases-enum(%some);
+keyword-cases-enum(%none);
+keyword-cases-enum(%ok);
+keyword-cases-enum(%err);
+keyword-cases-enum(%inf);
+keyword-cases-enum(%nan);
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/accept-flags.out b/crates/wasm-wave/tests/ui/accept-flags.out
new file mode 100644
index 00000000..7a42c54e
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-flags.out
@@ -0,0 +1,7 @@
+permission-flags(flgs: {})
+permission-flags(flgs: {read})
+permission-flags(flgs: {read})
+permission-flags(flgs: {write})
+permission-flags(flgs: {read, write})
+permission-flags(flgs: {read, write})
+permission-flags(flgs: {read, write})
diff --git a/crates/wasm-wave/tests/ui/accept-flags.waves b/crates/wasm-wave/tests/ui/accept-flags.waves
new file mode 100644
index 00000000..1f0bbbc4
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-flags.waves
@@ -0,0 +1,7 @@
+permission-flags({});
+permission-flags({ read });
+permission-flags({ read, });
+permission-flags({ write });
+permission-flags({ read, write });
+permission-flags({ write, read });
+permission-flags({ read, write, });
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/accept-floats.out b/crates/wasm-wave/tests/ui/accept-floats.out
new file mode 100644
index 00000000..5f682c47
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-floats.out
@@ -0,0 +1,13 @@
+list-float32(vals: [0, 0, 0, 0, 0, 0, 0])
+list-float32(vals: [-3.1415, -100000, -0])
+list-float32(vals: [nan, inf, -inf])
+// Largest normal f32
+float32(val: 340282350000000000000000000000000000000)
+// Truncated precision
+float32(val: 340282350000000000000000000000000000000)
+// Too large; infinity
+float32(val: inf)
+// Smallest positive non-zero f32
+float32(val: 0.000000000000000000000000000000000000000000001)
+// Too small; round to zero
+float32(val: 0)
diff --git a/crates/wasm-wave/tests/ui/accept-floats.waves b/crates/wasm-wave/tests/ui/accept-floats.waves
new file mode 100644
index 00000000..01d4d486
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-floats.waves
@@ -0,0 +1,13 @@
+list-float32([0, 0.0, 0e0, 0.0e0, 0e-1, 0e+1, 0.000e100]);
+list-float32([-3.1415, -100000, -0.0e-0]);
+list-float32([nan, inf, -inf]);
+// Largest normal f32
+float32(3.4028234664e38);
+// Truncated precision
+float32(3.4028234664123e38);
+// Too large; infinity
+float32(3.4028234664e39);
+// Smallest positive non-zero f32
+float32(1.4012984643e-45);
+// Too small; round to zero
+float32(1.4012984643e-46);
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/accept-records.out b/crates/wasm-wave/tests/ui/accept-records.out
new file mode 100644
index 00000000..cc67ee25
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-records.out
@@ -0,0 +1,14 @@
+basic-record(rec: {required: 1})
+basic-record(rec: {required: 1})
+basic-record(rec: {required: 1})
+basic-record(rec: {required: 1})
+basic-record(rec: {required: 1, optional: some(2)})
+basic-record(rec: {required: 1, optional: some(2)})
+optional-fields-record(rec: {:})
+optional-fields-record(rec: {:})
+optional-fields-record(rec: {:})
+optional-fields-record(rec: {:})
+optional-fields-record(rec: {a: some(1)})
+optional-fields-record(rec: {b: some(2)})
+optional-fields-record(rec: {a: some(1), b: some(2)})
+keyword-fields-record(rec: {true: true, false: false, some: some(1), ok: ok, err: err, inf: inf, nan: nan})
diff --git a/crates/wasm-wave/tests/ui/accept-records.waves b/crates/wasm-wave/tests/ui/accept-records.waves
new file mode 100644
index 00000000..a70b1811
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-records.waves
@@ -0,0 +1,14 @@
+basic-record({ required: 1 });
+basic-record({ required: 1, optional: none });
+basic-record({ required: 1, optional: none, });
+basic-record({ optional: none, required: 1 });
+basic-record({ required: 1, optional: some(2) });
+basic-record({ optional: some(2), required: 1, });
+optional-fields-record({:});
+optional-fields-record({ : });
+optional-fields-record({a:none,b:none});
+optional-fields-record({b:none,});
+optional-fields-record({a:some(1)});
+optional-fields-record({b:some(2)});
+optional-fields-record({b:some(2),a:some(1)});
+keyword-fields-record({true: true, false: false, some: some(1), none: none, ok: ok, err: err, inf: inf, nan: nan});
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/accept-strings.out b/crates/wasm-wave/tests/ui/accept-strings.out
new file mode 100644
index 00000000..1b4b2824
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-strings.out
@@ -0,0 +1,13 @@
+string(val: "")
+string(val: "WAVE🌊")
+string(val: "\\\'\"\t\n\r")
+string(val: "\'\'\'")
+string(val: "☃☃☃")
+// Multiline: empty
+string(val: "")
+// Multiline: escapes
+string(val: "Break up double quote sequences: \"\"\"\"\nOther escapes work: \\\'\"\t\n\r☃")
+// Multiline: indent behavior
+string(val: "  Trailing indent\ndetermines dedent\n    behavior.")
+// Multiline: empty lines
+string(val: "\nEmpty leading and trailing lines are retained\n")
diff --git a/crates/wasm-wave/tests/ui/accept-strings.waves b/crates/wasm-wave/tests/ui/accept-strings.waves
new file mode 100644
index 00000000..a45df90d
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/accept-strings.waves
@@ -0,0 +1,25 @@
+string("");
+string("WAVE🌊");
+string("\\\'\"\t\n\r");
+string("'\'\u{27}");
+string("☃\u{2603}\u{002603}");
+// Multiline: empty
+string("""
+""");
+// Multiline: escapes
+string("""
+Break up double quote sequences: ""\""
+Other escapes work: \\\'\"\t\n\r\u{2603}
+""");
+// Multiline: indent behavior
+string("""
+    Trailing indent
+  determines dedent
+      behavior.
+  """);
+// Multiline: empty lines
+string("""
+
+Empty leading and trailing lines are retained
+
+""")
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/reject-chars.out b/crates/wasm-wave/tests/ui/reject-chars.out
new file mode 100644
index 00000000..6ae4b719
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-chars.out
@@ -0,0 +1,27 @@
+// Wrong length
+invalid token at 5..6
+invalid token at 5..9
+invalid token at 5..9
+invalid token at 5..6
+// Invalid escapes
+invalid token at 5..6
+invalid token at 5..6
+invalid token at 5..6
+invalid token at 5..10
+invalid token at 5..6
+invalid token at 5..6
+invalid token at 5..6
+invalid token at 5..10
+// Out of range
+invalid character escape at 5..17
+// Unpaired surrogates
+invalid character escape at 5..15
+invalid character escape at 5..15
+// Invalid delimiters
+invalid token at 5..6
+invalid token at 6..7
+invalid token at 5..7
+// Missing mandatory escapes
+invalid token at 5..6
+invalid token at 5..6
+invalid token at 5..6
diff --git a/crates/wasm-wave/tests/ui/reject-chars.waves b/crates/wasm-wave/tests/ui/reject-chars.waves
new file mode 100644
index 00000000..cf83d90b
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-chars.waves
@@ -0,0 +1,28 @@
+// Wrong length
+char('');
+char('ab');
+char('☃☃');
+char('\n\n');
+// Invalid escapes
+char('\z');
+char('\x1f');
+char('\u1f');
+char('\u{1f');
+char('\u1f}');
+char('\u{}');
+char('\u{x}');
+char('\u{12345678}');
+// Out of range
+char('\u{110000}');
+// Unpaired surrogates
+char('\u{D800}');
+char('\u{DFFF}');
+// Invalid delimiters
+char(');
+char(x');
+char('x);
+// Missing mandatory escapes
+char('\');
+char(''');
+char('
+');
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/reject-comments.out b/crates/wasm-wave/tests/ui/reject-comments.out
new file mode 100644
index 00000000..16939c26
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-comments.out
@@ -0,0 +1,5 @@
+unexpected end of input at 32..32
+unexpected end of input at 33..33
+unexpected token: Colon at 28..29
+unexpected token: ParenClose at 39..40
+error converting Wasm value: missing field "required" at 13..52
diff --git a/crates/wasm-wave/tests/ui/reject-comments.waves b/crates/wasm-wave/tests/ui/reject-comments.waves
new file mode 100644
index 00000000..fef5abad
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-comments.waves
@@ -0,0 +1,14 @@
+basic-record(// { required: 1 });
+basic-record({ required: 1 } // );
+basic-record(// {
+  required: 1,
+});
+basic-record(
+  {
+    required: 1,
+//}
+);
+basic-record({
+  // required: 1,
+  optional: none,
+});
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/reject-enums.out b/crates/wasm-wave/tests/ui/reject-enums.out
new file mode 100644
index 00000000..efb20daa
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-enums.out
@@ -0,0 +1,8 @@
+invalid value type at 19..23
+invalid value type at 19..24
+unexpected token: ParenClose at 23..24
+invalid value type at 19..23
+invalid value type at 19..21
+invalid value type at 19..22
+invalid value type at 19..22
+invalid value type at 19..22
diff --git a/crates/wasm-wave/tests/ui/reject-enums.waves b/crates/wasm-wave/tests/ui/reject-enums.waves
new file mode 100644
index 00000000..fa9724bc
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-enums.waves
@@ -0,0 +1,8 @@
+keyword-cases-enum(true);
+keyword-cases-enum(false);
+keyword-cases-enum(some);
+keyword-cases-enum(none);
+keyword-cases-enum(ok);
+keyword-cases-enum(err);
+keyword-cases-enum(inf);
+keyword-cases-enum(nan);
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/reject-flags.out b/crates/wasm-wave/tests/ui/reject-flags.out
new file mode 100644
index 00000000..cb4b320d
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-flags.out
@@ -0,0 +1,6 @@
+// Duplicate flags.
+duplicate flag: "read" at 25..29
+duplicate flag: "write" at 26..31
+duplicate flag: "read" at 32..36
+// Unrecognized flag.
+error converting Wasm value: unknown case "execute" at 17..41
diff --git a/crates/wasm-wave/tests/ui/reject-flags.waves b/crates/wasm-wave/tests/ui/reject-flags.waves
new file mode 100644
index 00000000..f360389c
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-flags.waves
@@ -0,0 +1,6 @@
+// Duplicate flags.
+permission-flags({ read, read });
+permission-flags({ write, write });
+permission-flags({ read, write, read });
+// Unrecognized flag.
+permission-flags({ read, write, execute });
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/reject-floats.out b/crates/wasm-wave/tests/ui/reject-floats.out
new file mode 100644
index 00000000..6489eb92
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-floats.out
@@ -0,0 +1,19 @@
+// Reject "-nan".
+invalid token at 8..9
+invalid token at 8..9
+unexpected token: LabelOrKeyword at 9..10
+unexpected token: LabelOrKeyword at 9..10
+// Reject "infinity", "-infinity", and uppercase variations.
+invalid value type at 8..16
+unexpected token: LabelOrKeyword at 12..17
+invalid value type at 8..16
+invalid token at 8..9
+unexpected token: LabelOrKeyword at 9..16
+invalid token at 8..9
+unexpected token: LabelOrKeyword at 9..16
+invalid token at 8..9
+// Reject mixed case variations of "inf" and "nan".
+unexpected token: LabelOrKeyword at 9..11
+invalid token at 8..9
+invalid value type at 8..11
+invalid token at 8..9
diff --git a/crates/wasm-wave/tests/ui/reject-floats.waves b/crates/wasm-wave/tests/ui/reject-floats.waves
new file mode 100644
index 00000000..672a01c5
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-floats.waves
@@ -0,0 +1,19 @@
+// Reject "-nan".
+float32(-nan);
+float64(-nan);
+float32(NaN);
+float64(NaN);
+// Reject "infinity", "-infinity", and uppercase variations.
+float32(infinity);
+float64(-infinity);
+float32(INFINITY);
+float64(-INFINITY);
+float32(Infinity);
+float64(-Infinity);
+float32(Infinity);
+float64(-Infinity);
+// Reject mixed case variations of "inf" and "nan".
+float32(Inf);
+float64(-Inf);
+float32(INF);
+float64(-INF);
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/reject-lists.out b/crates/wasm-wave/tests/ui/reject-lists.out
new file mode 100644
index 00000000..d16d8af9
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-lists.out
@@ -0,0 +1,7 @@
+unexpected token: Comma at 22..23
+unexpected token: Comma at 14..15
+unexpected token: Comma at 14..15
+unexpected token: ParenClose at 14..15
+unexpected token: BracketClose at 14..15
+unexpected token: BraceClose at 14..15
+unexpected token: BracketClose at 14..15
diff --git a/crates/wasm-wave/tests/ui/reject-lists.waves b/crates/wasm-wave/tests/ui/reject-lists.waves
new file mode 100644
index 00000000..4fd5af3e
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-lists.waves
@@ -0,0 +1,7 @@
+list-strings(["\u{0}",,]);
+list-strings([,"\u{0}"]);
+list-strings([,]);
+list-strings([));
+list-strings((]);
+list-strings([});
+list-strings({]);
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/reject-options.out b/crates/wasm-wave/tests/ui/reject-options.out
new file mode 100644
index 00000000..3db40e2c
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-options.out
@@ -0,0 +1,11 @@
+unexpected token: ParenClose at 14..15
+unexpected token: ParenClose at 15..16
+unexpected token: ParenClose at 19..20
+unexpected token: ParenClose at 20..21
+invalid value type at 15..22
+invalid value type at 15..28
+unexpected token: ParenOpen at 14..15
+unexpected token: ParenOpen at 19..20
+unexpected token: ParenOpen at 14..15
+unexpected token: ParenOpen at 19..20
+unexpected token: ParenOpen at 14..15
diff --git a/crates/wasm-wave/tests/ui/reject-options.waves b/crates/wasm-wave/tests/ui/reject-options.waves
new file mode 100644
index 00000000..01b0c366
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-options.waves
@@ -0,0 +1,11 @@
+option-u8(some);
+option-u8(some());
+option-u8(some(some));
+option-u8(some(some()));
+option-u8(some(some(0)));
+option-u8(some(some(some(0))));
+option-u8(none());
+option-u8(some(none()));
+option-u8(none(0));
+option-u8(some(none(0)));
+option-u8(none(some(0)));
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/reject-records.out b/crates/wasm-wave/tests/ui/reject-records.out
new file mode 100644
index 00000000..4c0f5862
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-records.out
@@ -0,0 +1,18 @@
+// Missing `required`.
+error converting Wasm value: missing field "required" at 13..31
+error converting Wasm value: missing field "required" at 13..34
+// Duplicate `required`.
+duplicate field: "required" at 28..36
+duplicate field: "required" at 28..36
+duplicate field: "required" at 28..36
+// Duplicate `optional`.
+duplicate field: "optional" at 44..52
+duplicate field: "optional" at 44..52
+duplicate field: "optional" at 47..55
+duplicate field: "optional" at 47..55
+// Bad commas.
+unexpected token: Comma at 27..28
+unexpected token: Comma at 15..16
+unexpected token: Comma at 15..16
+// Invalid empty record syntax.
+invalid value type at 23..25
diff --git a/crates/wasm-wave/tests/ui/reject-records.waves b/crates/wasm-wave/tests/ui/reject-records.waves
new file mode 100644
index 00000000..3c3bea15
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-records.waves
@@ -0,0 +1,18 @@
+// Missing `required`.
+basic-record({ optional: none });
+basic-record({ optional: some(0) });
+// Duplicate `required`.
+basic-record({ required: 0, required: 0 });
+basic-record({ required: 0, required: 0, optional: none });
+basic-record({ required: 0, required: 0, optional: some(0) });
+// Duplicate `optional`.
+basic-record({ required: 0, optional: none, optional: none });
+basic-record({ required: 0, optional: none, optional: some(0) });
+basic-record({ required: 0, optional: some(0), optional: none });
+basic-record({ required: 0, optional: some(0), optional: some(0) });
+// Bad commas.
+basic-record({ required: 0,, });
+basic-record({ , required: 0, });
+basic-record({ ,, required: 0 });
+// Invalid empty record syntax.
+optional-fields-record({});
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/reject-results.out b/crates/wasm-wave/tests/ui/reject-results.out
new file mode 100644
index 00000000..42436aaf
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-results.out
@@ -0,0 +1,20 @@
+error converting Wasm value: missing payload for "ok" case at 13..15
+unexpected token: ParenClose at 16..17
+invalid value type at 13..17
+error converting Wasm value: unexpected payload for "err" case at 13..19
+error converting Wasm value: missing payload for "err" case at 14..17
+unexpected token: ParenClose at 18..19
+invalid value type at 14..18
+error converting Wasm value: unexpected payload for "ok" case at 14..19
+unexpected token: ParenClose at 22..23
+invalid value type at 19..23
+error converting Wasm value: unexpected payload for "ok" case at 19..24
+unexpected token: ParenClose at 23..24
+invalid value type at 19..23
+error converting Wasm value: unexpected payload for "err" case at 19..25
+error converting Wasm value: missing payload for "ok" case at 15..17
+unexpected token: ParenClose at 18..19
+invalid value type at 15..19
+error converting Wasm value: missing payload for "err" case at 15..18
+unexpected token: ParenClose at 19..20
+invalid value type at 15..19
diff --git a/crates/wasm-wave/tests/ui/reject-results.waves b/crates/wasm-wave/tests/ui/reject-results.waves
new file mode 100644
index 00000000..283cad25
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-results.waves
@@ -0,0 +1,20 @@
+result-ok-u8(ok);
+result-ok-u8(ok());
+result-ok-u8(o(0));
+result-ok-u8(err(0));
+result-err-u8(err);
+result-err-u8(err());
+result-err-u8(e(0));
+result-err-u8(ok(0));
+result-no-payloads(ok());
+result-no-payloads(o(0));
+result-no-payloads(ok(0));
+result-no-payloads(err());
+result-no-payloads(e(0));
+result-no-payloads(err(0));
+result-both-u8(ok);
+result-both-u8(ok());
+result-both-u8(o(0));
+result-both-u8(err);
+result-both-u8(err());
+result-both-u8(e(0));
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/reject-strings.out b/crates/wasm-wave/tests/ui/reject-strings.out
new file mode 100644
index 00000000..78982ec7
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-strings.out
@@ -0,0 +1,32 @@
+// Reject surrogates.
+invalid character escape at 8..9
+invalid character escape at 8..9
+invalid character escape at 8..9
+invalid character escape at 8..9
+invalid character escape at 8..9
+// Reject invalid values.
+invalid character escape at 8..9
+invalid character escape at 8..9
+invalid character escape at 8..9
+// Reject invalid syntax.
+invalid token at 7..8
+invalid token at 7..8
+// Missing mandatory escapes
+invalid token at 7..10
+invalid token at 7..11
+invalid token at 7..8
+// Multiline missing mandatory line breaks
+invalid multiline string: opening """ must be followed immediately by newline at 10..11
+invalid multiline string: opening """ must be followed immediately by newline at 10..11
+// Multiline invalid delimiters
+invalid multiline string: opening """ must be followed immediately by newline at 10..11
+invalid token at 7..10
+invalid token at 14..16
+invalid multiline string: opening """ must be followed immediately by newline at 10..11
+invalid multiline string: closing """ must be on a line preceded only by spaces at 13..16
+// Multiline double quote triplet in content
+invalid token at 19..22
+unexpected token: LabelOrKeyword at 25..30
+// Multiline invalid escapes
+invalid character escape at 30..31
+invalid character escape at 27..28
diff --git a/crates/wasm-wave/tests/ui/reject-strings.waves b/crates/wasm-wave/tests/ui/reject-strings.waves
new file mode 100644
index 00000000..4d0bc4bb
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/reject-strings.waves
@@ -0,0 +1,46 @@
+// Reject surrogates.
+string("\u{d800}");
+string("\u{dbff}");
+string("\u{dc00}");
+string("\u{dcff}");
+string("\u{d800}\\u{dc00}");
+// Reject invalid values.
+string("\u{110000}");
+string("\u{ffffffff}");
+string("\u{80000000}");
+// Reject invalid syntax.
+string("\u{-1}");
+string("\u{+1}");
+// Missing mandatory escapes
+string(""");
+string("\");
+string("
+");
+// Multiline missing mandatory line breaks
+string("""""");
+string(""" """);
+// Multiline invalid delimiters
+string(""""
+""");
+string("""
+"");
+string("""
+"""");
+string("""extra
+""");
+string("""
+extra""");
+// Multiline double quote triplet in content
+string("""
+  """
+  """);
+string("""
+  Between """ words
+  """);
+// Multiline invalid escapes
+string("""
+Invalid surrogate: \u{d800}
+""");
+string("""
+Invalid escape: \j
+""");
\ No newline at end of file
diff --git a/crates/wasm-wave/tests/ui/ui.wit b/crates/wasm-wave/tests/ui/ui.wit
new file mode 100644
index 00000000..435a765b
--- /dev/null
+++ b/crates/wasm-wave/tests/ui/ui.wit
@@ -0,0 +1,65 @@
+package ui:tests;
+
+interface ui {
+    %float32: func(val: f32);
+    %list-float32: func(vals: list<f32>);
+    %float64: func(val: f64);
+    %list-float64: func(vals: list<f64>);
+    %char: func(val: char);
+    list-chars: func(vals: list<char>);
+    %string: func(val: string);
+    list-strings: func(vals: list<string>);
+    option-u8: func(opt: option<u8>);
+    result-ok-u8: func(res: result<u8>);
+    result-err-u8: func(res: result<_, u8>);
+    result-both-u8: func(res: result<u8, u8>);
+    result-no-payloads: func(res: result);
+
+    record basic {
+        required: u8,
+        optional: option<u8>,
+    }
+    basic-record: func(rec: basic);
+
+    record optional-fields {
+        a: option<u8>,
+        b: option<u8>,
+    }
+    optional-fields-record: func(rec: optional-fields);
+
+    record keyword-fields {
+        true: bool,
+        false: bool,
+        some: option<u8>,
+        none: option<u8>,
+        ok: result,
+        err: result,
+        inf: f32,
+        nan: f32,
+    }
+    keyword-fields-record: func(rec: keyword-fields);
+
+    enum hand {
+        left,
+        right,
+    }
+    hand-enum: func(enm: hand);
+
+    enum keyword-cases {
+        true,
+        false,
+        some,
+        none,
+        ok,
+        err,
+        inf,
+        nan,
+    }
+    keyword-cases-enum: func(enm: keyword-cases);
+
+    flags permissions {
+        read,
+        write,
+    }
+    permission-flags: func(flgs: permissions);
+}
\ No newline at end of file