plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


b0ca30b — Peter John 2026-10-26T21:14:16+05:30
docs(plans): Zig-style errors — ?, !, catch, fail; no tag unions or Try
Files changed (1) hide show
  1. docs/plans/2026-10-27-zig-errors.md +87 -0
docs/plans/2026-10-27-zig-errors.md ADDED
@@ -0,0 +1,87 @@
1
+ # Zig-style error handling: `!T`, postfix `?` and `!`, `catch`, `fail`
2
+
3
+ **Status:** running (user approved). Before E1, an audit of the docs, samples, highlighting and LSP for the for/extend/Seq changes.
4
+ **Goal:** replace the overlapping spellings (`Try`/`Ok`/`Err`, `Int ! [..]`, postfix `?`, `??`, `? |e| X`, auto-wrapped bare tags) with one Zig-style model. The runtime stays the same: an error union is still a Try-shaped value whose error side is a structural tag union.
5
+
6
+ ## Design (user decisions: postfix `?` propagates and postfix `!` crashes, replacing the earlier `try`-prefix choice; catch default / catch e block; inferred `!T` with optional explicit set)
7
+
8
+ 1. **Types.** `!T` means "T or an error", with the set inferred from the body. `!T [A, B(Str)]` pins the set. `T?` stays as shorthand for `!T [Missing]`. `Try`, `Ok` and `Err` leave the user surface. `Try` stays an internal name, so diagnostics may say "error union".
9
+ 2. **Returning.** A plain `T` value (tail or `return v`) is the success value. `fail Tag` / `fail Tag(payload)` returns an error. Auto-wrapping a bare tag is removed, so `return NegativeNum` is now a type error with the hint "write `fail NegativeNum`".
10
+ 3. **Propagating and crashing.**
11
+ - `expr?` unwraps the success value or returns the error from the enclosing fn. The fn's set includes `expr`'s set, either inferred or checked against the pinned set with today's subset rule.
12
+ - `expr!` unwraps the success value, or calls `crash` with the message `<callee> failed with <Tag> at <file>:<line>:<col>`. It does not add to the fn's error set. It replaces `.unwrap()`/`.expect()`, which are removed.
13
+ - Lexing: `!=` wins by maximal munch, so write `{f()!} == x`. Postfix `!` (after a call, attribute, var or paren) is unambiguous against prefix-not and the `!T` type prefix.
14
+ 4. **Handling in place:**
15
+ - `expr catch default` gives the success value, or `default` on any error. `default` must have type T, or may be `fail X` or `return …`.
16
+ - `expr catch e` plus an indented block runs the block on error with `e` bound to the error tag value. The block's last expression must have type T, or the block may diverge (`return`, `fail`, `crash`). `match e` inside it is exhaustive over the exact set.
17
+ - Mapping an error is `x := parseInt(a) catch fail InvalidFirst`, which means "on any error, fail with InvalidFirst". It is just `catch default` whose default is a `fail`, so it needs no extra syntax, and it replaces `? |e| Tag`.
18
+ - `??` is removed (hint: "write `catch`").
19
+ 5. **Nullables.** `T?` values use the same `?` / `!` / `catch`. `?.` stays: `a?.b` on `T?` or `!T` gives `!U` with the same set, flattened. A value of type `T` in a `T?` slot is accepted as-is, and absence is written `fail Missing`, the same rule as every other error.
20
+ 6. **Inference.**
21
+ - The set of a `!T` fn is the union of: every `fail X` in the body; the sets of every `try`'d expression not caught; and `catch` default branches that `fail`.
22
+ - Callers see the exact inferred set, so `match e` is exhaustive. It is computed per fn to a fixpoint over the call graph.
23
+ - **Recursive cycles, `extern fun`s and exported/test-root fns must pin their set.** The error is `fn 'f' is recursive: pin its error set (-> !T [..])`.
24
+ 7. **Match.** Matching a `!T` value directly with Ok/Err patterns is removed in favour of `catch e`. A stored `!T` value (`r := f()`) is handled later with `r?`, `r!` or `r catch …`. `match e` on the bare error value (a tag union) works as today.
25
+ 8. **Std and migration.**
26
+ - Every std signature moves to `!T` (pinned sets on std APIs, e.g. `fun parseInt(s: Str) -> !Int [BadNumStr]`).
27
+ - `Try.plum` shrinks to internal helpers or is deleted. `Try.map`/`mapErr`/`andThen` are removed, since `try`/`catch` cover them.
28
+ - Examples, README (Error handling section rewritten) and tests are all migrated.
29
+ - `legacySyntaxHint` gains migration hints for `?`, `??`, `Ok(`/`Err(` construction and `? |e|`.
30
+ 9. **Grammar.** postfix `?` and `!` (bind like calls), postfix `catch` with an optional binder and block, the `fail` statement/expression, the `!T` type prefix with an optional `[..]` set, and `T?` kept. Regenerate with `--disable-optimizations`; update corpus, highlights, format.scm, the VS Code grammar and the LSP keywords (`try`, `catch`, `fail`).
31
+
32
+ ## Tasks (sequential)
33
+
34
+ - **E1 grammar and parser:** `!T` type prefix, postfix `!`, `catch` (optional binder and block), `fail`, legacy hints (`??`, `Ok(`/`Err(`, `? |e|`, `.unwrap()`), formatter and highlights.
35
+ - **E2 checker:** `!T` types, `fail`, `try`, `catch` default and block, error-set inference fixpoint, pinned-set checks, and nullable unification. Tests:
36
+ - `inferred_set_union` (two `try`s plus a `fail` give the exact set);
37
+ - `pinned_set_rejects_extra`;
38
+ - `recursive_needs_pinned`;
39
+ - `catch_default_type`;
40
+ - `catch_block_exhaustive_match`;
41
+ - `catch_fail_maps_tag`, `bang_crashes_with_message`, `bang_on_nullable`, `bang_not_in_error_set`, `bang_vs_neq_lexing`;
42
+ - `return_bare_tag_hint`;
43
+ - - `elvis_removed_hint`.
44
+ - **E3 codegen:** reuse the Try lowering, with `catch` as an inline branch and `?` as an early return with the rewrap that exists today, `!` as a crash branch with the formatted message. The nullable `ref null` path stays. Tests:
45
+ - `catch_default_runtime`;
46
+ - `catch_block_binds_error`;
47
+ - `try_propagates_payload_tag`;
48
+ - `nullable_catch`;
49
+ - `safe_nav_on_error_union`.
50
+ - **E4 std, examples and docs migration.** The full suites must stay green, plus a README rewrite of the Error handling section.
51
+
52
+ Success criteria:
53
+ - No `Ok(`, `Err(`, `Try[`, `??`, `? |`, `.unwrap()` or `.expect(` remains in plum-std, plum-examples or the README, except in legacy-hint tests.
54
+ - Both suites and `tree-sitter test` are green.
55
+
56
+ ## Revision (user decisions): remove tag unions and Try entirely, no error payloads, enums for case lists
57
+
58
+ 1. **Errors are names, Zig-style.**
59
+ - `fail NegativeNum`. Error names are global, need no declaration, and carry no payload (`fail BadRequest("x")` is an error with the hint "errors carry no data; return details in a normal value").
60
+ - An error set is a set of names: `!T [BadNumStr, NegativeNum]`, or `!T` inferred.
61
+ - `catch e` binds `e` to the error name, and `match e` over the set stays exhaustive. `e.name() -> Str` is used for messages, e.g. the crash text for `!`.
62
+ 2. **Removed from the language:**
63
+ - tag-union types `[A, B(Str)]` as ordinary types (params, fields, returns, locals);
64
+ - tag payloads;
65
+ - `Try[T, E]`, `Ok`, `Err`, and `plum-std/Try.plum`;
66
+ - the `$Tag` struct and payload boxes, and nested tag patterns in codegen;
67
+ - `TTags` payload support in the checker. An error set becomes a plain set of names.
68
+ 3. **Case lists become ordinary enums.** Every existing use of a tag union as a plain type (`isRetryable(e: [Timeout, Refused, BadRequest(Str)])` in the README, examples and tests) is rewritten as an `enum`.
69
+ 4. **Runtime.**
70
+ - Each error name gets a global i32 code, interned and sorted, with 0 meaning success.
71
+ - A `!T` result is a **multi-value return `(T…, i32)`**: T flattened as value enums are, plus the code. There is no allocation on success or failure.
72
+ - When a `!T` is stored in a heap slot (field, List element, generic container), it is boxed into a `{code: i32, value}` struct.
73
+ - `T?` = `!T [Missing]` keeps the nullable-ref layout for ref T (null = Missing, no code word needed); primitive or value T uses `(T…, i32)`.
74
+ 5. **Std migration.**
75
+ - `Json.parse` loses `InvalidJson(Str)` and becomes `!JsonValue [InvalidJson]`. Where the old payload carried useful detail, expose it as a normal value: `Json.parseWithPos(s) -> !JsonValue [InvalidJson]` plus `Json.errorPos()`, or return a result value enum. Ruling: pick per API, and list each change.
76
+ - `Os`/`Http`/`Uuid` keep `Str` messages as normal return values where they had them, e.g. `Http.request -> !Response [HttpFailed]` with the details on `Response`.
77
+
78
+ Tests:
79
+ - `no_payload_errors`;
80
+ - `fail_with_payload_rejected_hint`;
81
+ - `catch_e_name`;
82
+ - `tag_union_type_removed_hint` (`x: [A, B]` gives "use an enum");
83
+ - `try_ok_err_removed_hints`;
84
+ - `error_union_multi_value` (the wasm fn returns `(i64, i32)` for `!Int`);
85
+ - `error_union_boxed_in_list`;
86
+ - `nullable_ref_still_null`;
87
+ - `bang_crash_message_uses_name`.