From 1393d817b0f218146f27fc22c36432b9aa736f31 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 22:44:12 +0700 Subject: [PATCH] The return type stops being a guess, and recur beats silent TCO --- NEXT.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/NEXT.md b/NEXT.md index 4aed672..b87af78 100644 --- a/NEXT.md +++ b/NEXT.md @@ -678,6 +678,48 @@ destination is always honest about what it is. Drop Clojure's `:eduction` branch — that is the pass-around case, and the one part that would need runtime machinery. +## Queued: the return type is mandatory, and `loop`/`recur` + +**1. A `defn` must always state its return type, and unit is written `()`.** + +Today the slot is optional and the parser decides return-type-from-body by looking the symbol up in a set of known type +names. It is *sound* only because there is one top-level namespace, so a name cannot be both a type and a value — and +it is brittle because the set has to be complete. **It has been wrong twice in one day**, both from the same root: the +prelude's type names were not in the set, so `Form` in return position read as an unknown value; and fixing that +exposed a second arm reading the same set, which had been parsing `(defn f [] (Rune {.code 65}) (bar))` as a function +*returning* a `Rune` with a one-form body — **silently, in every file in the language**. The diagnostic is poor too: +`(defn f [] f65 0.0)` says *unknown name* rather than *did you mean f64*. + +A silent misparse is the worst failure class available, and macros now generate definitions, which widens it. +**Mandatory removes the guess entirely** — the slot after the parameters is unconditionally a type, the parser stops +consulting a table, and the typo case gets a real message. The cost is `(defn main [] () ...)` on void functions, +against `plan.org`'s deliberate short form. Take the cost. + +**Unit is `()`**, ML's spelling: it is the honest name, and it cannot collide because an empty call is not a valid +expression anyway. + +Alternatives considered and rejected: a separate signature form, Typed Racket's `(: foo (-> i32 i32))` — removes the +ambiguity equally but splits the signature from the body and gives two things to keep in sync. Return type *before* +the parameters — does not work, because a fixed-array type is spelled with brackets, so `(defn foo [4 i32] ...)` could +be either. Inferring the return type — it is local and therefore cheap, but it only helps if annotating is +*forbidden*, and recursion forces an annotation back anyway, which makes it optional, which is where we started. + +Note the function *type* spelling is already decided and needs no arrows: `(Fn [i32 i32] i32)`, mirroring `defn`. +Arrows would imply currying, which this language does not do. + +**2. `loop` and `recur`.** Both are already refused by name in `parse.ml`. There is **no TCO** — nothing emits tail +calls, and `plan.org` mentions them only as something the current backend choice *could* control (LLVM's `musttail` is +there if wanted). + +`recur` is the better answer than silent TCO, and not only because it is cheaper. **It is checked**: the compiler +verifies the call is in tail position and turns it into a jump, so breaking tail position is a compile error rather +than a stack overflow at run time. Clojure adopted it because the JVM lacks TCO and it turned out to be the better +design. + +Cheap here — a jump to the top of a `loop`, which is the machinery `while` and the new labelled `break`/`continue` +already have. What it does **not** give is mutual recursion between two functions; that needs real tail calls, and is +a separate question if it is ever wanted. + ## The next batch, in order Agreed at the end of 2026-09-12. Ordered by priority, not by size. Items 1-3 and 5-6 want the compiler core and should