plum
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
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`.
|