Verdict
The type story is roughly coherent but the draft hand-waves the most
load-bearing decision: whether T? and Option<T> are the same type or
two views of the same idea. The doc says “alias,” then the lowerings imply
they are not the same (a nullable record slot is not a tagged variant),
which means the prose is wrong wherever it’s checked carefully — including
the unique-rows triad in §6.5, the §5.1.1 xs[i] -> Option<T> story, and
anything that ever sees an Option<Option<T>>. Pick a story and commit:
either T? is sugar that desugars to Option<T> before type-checking
(my recommendation, and what I edited toward), or it’s a genuinely
distinct nullable-slot type and Option<T> is the explicit form used
when nesting matters. The other rough edges — error-union composition,
derive Key for unbounded fields, .expect on stacked monads,
map<text> key-width overflow — are smaller and now spelled out.
Underlying instincts are good; the doc just needed the corner cases
nailed down before someone writes a checker against them.
Strong points
- §6.5’s “intent is encoded at the call site, not the catch site” is the
best single design call in the doc. The
.first()/.one_or_none()/.one()/.expect()quartet is genuinely better than what every other PL/SQL-targeting language has tried. - §6.5.1’s uncatchable invariant panics, coupled with
finally(notcatch) for log-and-rethrow, is exactly right and matches the user’s stated preference. - §5.1.4 forcing an explicit
.keys()/.values()copy at the map-to-SQL boundary is the right ergonomic friction. - §9’s closed annotation set with target validation is the right move for a tightly-scoped v1.
Concerns
- §5:
T?vsOption<T>identity. Originally claimed an alias relationship that the rest of the doc quietly violated.T??was undefined. Now nailed down:T?is parse-time sugar forOption<T>,T??is a parse error, and the lowering distinguishesOption<Option<T>>cleanly via anoption_T(tag, val)object type with an optional bare-nullable-slot optimization where provably safe. - §4.3 / §5: error-union structural identity. The doc never said
whether
A | B | AandB | Aare the same type, or whetherResult<T, A>widens automatically toResult<T, A | B>at a call site. Now: structural, deduplicated, sorted; widening is inserted by the typer at the call site, not a subtype relation. - §4.3
?onOption. The original parenthetical claimed.first()returnsResult<Row, NotFound>(contradicting §5.1.1 and §6.5, which returnOption<Row>). Reworked:?on anOptionrequires an explicit prelude@from_none_ofmapping, so the conversion isn’t implicit-from-whatever-variant-is-in-scope. - §4.4: exhaustive match against an open callee. Adding an error
variant to a callee silently turns every caller non-exhaustive on
recompile. Documented as a deliberate hard-error (breaking change),
with the explicit
Err(_) ->escape hatch noted. - §5
.intowas referenced but undefined. Added §5.1.5: derived, field-subset, name+type matching, withT → Option<T>widening; no user-writtenimpl. - §5.1.3
derive Keyfor nested records, lists, json. The doc permittedrecordkeys without saying what’s encodable. Added §5.1.6: length-prefixed canonical encoding, type-identity-prefixed (so different records with identical fields don’t collide), and an explicit ban onlist/map/set/Option/jsonfield types. - §5.1.3
map<text>keys > N. Was completely undefined. Now an invariant panic, with rationale (truncation aliases keys, and aResult<…, KeyTooLong>on every map op would poison ergonomics). - §6.5
.expecton stacked types. A user calling.expect()on aResult<Option<T>, E>would reasonably expect eitherTor a type error; the doc didn’t say. Now: peels exactly one layer. Table added. - §9.4
@must_useandOption<T>/T?. Result was defaulted to must-use; Option was unspecified. Now both default to must-use, withlet _ = …as the explicit-discard form. - §5.1.1
xs.first()and generics. §11 says “no generics in v1,” butlist<T>.first()is generic on its face. Reframed as compiler-intrinsic monomorphization of a closed set of built-ins, with §11.3 updated to match — and an explicit note that adding user generics in v2 needs its own design pass because PL/SQL type-name explosion across packages is unbounded otherwise.
Proposed edits (made in this worktree)
- §5: Replaced the “alias” claim with a precise rule:
T?desugars toOption<T>before type-checking;T??is a parse error;Option<Option<T>>is legal and has a well-defined two-tag lowering; bare-nullable-slot lowering is an optional optimization. Why: every downstream §5/§5.1/§6.5 claim implicitly depends on this. - §5: Pinned error unions as structural / deduplicated / sorted /
not a subtype lattice; added empty-union (
Result<T, !>). Why: the composition question (“A calls B and adds E2”) had no documented answer. - §5: Reframed “no generics except built-ins” as “built-ins are compiler-intrinsic monomorphization, not user generics.” Why: the doc was internally contradictory.
- §4.3: Replaced the muddled
Option-via-?parenthetical with an explicit prelude@from_none_of(…)rule. Why: implicit None-to-variant conversion based on what’s-in-scope is a sharp edge. - §4.4: Documented the exhaustive-match-vs-open-union hazard as intentional, with the escape hatch. Why: future callers will hit this and need to know it’s not a bug.
- §5.1.3: Defined
map<text>key-overflow as an invariant panic, not silent truncation. Why: previously undefined; truncation aliases keys, which defeats the whole point. - §5.1.5 (new): Defined
.intoconcretely. Why: referenced but never specified. - §5.1.6 (new): Defined
derive Keyencoding, type-identity prefix, and the ban on unbounded field types. Why: previously hand-wavy; silent collisions are the worst possible failure mode for a map key. - §6.5: Added the
.expectpeel-one-layer table coveringResult<Option<T>, E>andOption<Result<T, E>>. Why: the most common stacked-monad case was undefined. - §9.4: Extended
@must_usedefault toOption<T>andT?. Why: the asymmetry withResultwas unjustified. - §11.3: Clarified that built-ins are intrinsic, not evidence of generics, and named the cross-package type-name explosion problem any v2 proposal must solve. Why: keeps the v2 conversation honest.
Open questions for Shaun
Option<T>lowering shape. I documented anoption_T(tag, val)object type with an optimization to a bare-nullable slot when provably safe. Picking the opposite default — always lower to a bare nullable slot, and only promote to the object form when nesting is detected — gives smaller emitted SQL and friendlier%TYPEinterop but makesOption<Option<T>>a special case. Strong preference??onOption<T>. The@from_none_ofrule is the cleanest story I could find, but it does mean the prelude has to enumerate everyOption-bearing iterator method with its associated error variant. Alternative: forbid?onOption<T>outright, force callers to write.ok_or(NotFound{…})?. More verbose, no magic. Which?- Error-union widening. I declared this typer-inserted at the call
site, not a subtype relation. The subtype-relation version is simpler
in a paper but causes inference surprises — calling
fn id<E>(r: Result<T, E>) -> Result<T, E>on aResult<T, A>value inside aResult<T, A | B>context infersE = AorE = A | B? Worth a sentence in §5 if you want to pin it now, or leave to M1. derive Keyfor records containingOption<T>fields. I banned them. Real code (aUserwith an optional middle name used as a map key) might want this. Allowing it requires picking aNone-sentinel byte that can’t appear in any encoded value — doable but worth thinking about before M1 lands.- §4.5
sql!{}row type. The doc treatsRowas if it’s a structural anonymous record, but §5 says all records are nominal. IsRowa third category? Or does eachsql!{}synthesize a fresh nominal record at compile time? I didn’t touch this because it arguably belongs to a SQL-embedding reviewer, but flagging it.