|
| 1 | +# Checked casts at the JSON boundary (`tryAs`) |
| 2 | + |
| 3 | +Status: implemented on branch `try-as` (2026-07-09). Doc pattern + `tryAs` |
| 4 | +both landed. One finding from implementation: `parseJson` maps every JSON |
| 5 | +number to `MShellFloat` (Go's default unmarshal), so `tryAs {str: int}` on |
| 6 | +JSON counts is always `none` — validate JSON numbers as `float`. Whether |
| 7 | +`parseJson` should produce `int` for integral numbers is an open follow-up. |
| 8 | + |
| 9 | +## Problem |
| 10 | + |
| 11 | +Drilling into parsed JSON under the type checker is painful. |
| 12 | +Extracting `.packages[].name` took four nested `match` blocks, |
| 13 | +because `parseJson` returns the wide union `list|dict|numeric|str|bool|null` |
| 14 | +and every union member must be handled at every level. |
| 15 | +Each `match dict:` arm proves a fact, uses it once, and throws the proof away — |
| 16 | +boilerplate scales with nesting depth. |
| 17 | + |
| 18 | +## What already works (and the doc gap) |
| 19 | + |
| 20 | +The static side of the fix already exists: |
| 21 | + |
| 22 | +```mshell |
| 23 | +type Manifest = {packages: [{name: str}]} |
| 24 | +
|
| 25 | +"pkgs.json" parseJson as Manifest :packages? (:name?) map |
| 26 | +``` |
| 27 | + |
| 28 | +This type-checks and runs today. `type_system.inc.html` even recommends the |
| 29 | +pattern ("prefer adding a named type and an `as` assertion near the boundary"), |
| 30 | +but `doc/mshell.md` — the doc agents actually read — never shows |
| 31 | +`parseJson ... as T`. **Cheapest immediate action: document this pattern in |
| 32 | +`doc/mshell.md`.** |
| 33 | + |
| 34 | +## The gap: `as` is static-only |
| 35 | + |
| 36 | +`as` is a checker hint with no runtime work (`Evaluator.go`, `MShellAsCast` |
| 37 | +case). When the JSON doesn't match the assertion, the failure surfaces late |
| 38 | +and badly: `{"packages": [{"name": 42}]}` through the cast above dies with |
| 39 | +`Cannot get length of a Float.` at a downstream call site, not at the boundary. |
| 40 | + |
| 41 | +## Design: `tryAs` — parse, don't validate |
| 42 | + |
| 43 | +One new checked cast that returns proof as a more precise type: |
| 44 | + |
| 45 | +```mshell |
| 46 | +"pkgs.json" parseJson tryAs Manifest # ( -- Maybe[Manifest]), recoverable |
| 47 | +"pkgs.json" parseJson tryAs Manifest ? # unwrap-or-die, composes with existing ? |
| 48 | +``` |
| 49 | + |
| 50 | +- Runtime: structural walk of the value against the reified type expression. |
| 51 | + JSON values are only dicts/lists/scalars, so the validator is a small |
| 52 | + recursive function and structural checking is complete in this domain. |
| 53 | +- Success: `just` wrapping the value, statically typed `T`. |
| 54 | + Downstream `:field?` / `map` need no further matching. |
| 55 | +- Failure: `none`. |
| 56 | + |
| 57 | +### Naming rationale |
| 58 | + |
| 59 | +- `as?` rejected: `?` consistently means *unwrap* a Maybe (`?`, `:field?`), |
| 60 | + so a Maybe-*returning* `as?` reads backwards. |
| 61 | +- `as!` rejected: `!` means *store to variable* (`name!`). |
| 62 | +- `tryAs` follows the getter convention: base word returns Maybe, |
| 63 | + existing `?` unwraps. Die-loud form is derived (`tryAs T ?`), not a |
| 64 | + second primitive. |
| 65 | + |
| 66 | +### Division of labor with static `as` |
| 67 | + |
| 68 | +Both stay; the dividing line is trust boundaries: |
| 69 | + |
| 70 | +- `as` — value originates inside typed code: empty/ambiguous literals |
| 71 | + (`[] as [str]`), narrowing inferred unions, branding. Checker can see |
| 72 | + everything; runtime check would verify the statically obvious. Free. |
| 73 | +- `tryAs` — value crosses in from outside (parseJson, process output, |
| 74 | + spreadsheets). Only the runtime can know the shape; pay one walk, |
| 75 | + get a `Maybe[T]` proof. |
| 76 | + |
| 77 | +Possible follow-on: once `tryAs` exists, a hint diagnostic when bare `as` |
| 78 | +narrows an external-data union (e.g. the parseJson result) to a shape — |
| 79 | +"did you mean tryAs?" — while leaving literal-hint uses untouched. |
| 80 | + |
| 81 | +## Deferred (deliberately) |
| 82 | + |
| 83 | +- **Path-precise error messages.** `tryAs ... ?` failing as a bare `none` |
| 84 | + loses ".packages[3].name: expected str, got float". A future Result type |
| 85 | + with an error string is the planned home for this; the validator computes |
| 86 | + the message internally anyway, so a Result-returning variant just exposes |
| 87 | + it. The "no message on bad unwrap" problem is bigger than JSON and is not |
| 88 | + being solved here. |
| 89 | +- **`dig` with typed default** (`json ['packages' 0 'name'] "unknown" dig`, |
| 90 | + signature `(json [str|int] a -- a)`, default's type = result type via |
| 91 | + existing generics). Nice for one-off drills without declaring a type. |
| 92 | + If built: no `'*'` wildcard segments (they change the result shape and |
| 93 | + break `a -- a`); `tryAs` + `map` owns extract-many. Conflates missing |
| 94 | + with the sentinel value. |
| 95 | +- **Recursive types** (`type Json = int | str | [Json]`) don't work today — |
| 96 | + the name isn't in scope inside its own body. A first-class "any JSON" |
| 97 | + type would need checker work; with checked casts at the boundary it's |
| 98 | + rarely needed. |
| 99 | + |
| 100 | +## Implementation notes |
| 101 | + |
| 102 | +Structure (after review feedback on exhaustiveness): there is no separate |
| 103 | +validator file. `TypeExpr.go` defines a `TypeExpression` interface |
| 104 | +(`MShellParseItem` + `validateObj`) that every type-expression node must |
| 105 | +implement, and the parser productions return it — so a new node kind |
| 106 | +without runtime validation does not compile. Built-in named types (bytes, |
| 107 | +null, path, datetime, Grid, GridView, GridRow) live in one |
| 108 | +`builtinNamedTypes` table consulted by BOTH the checker's resolveTypeExpr |
| 109 | +and the runtime validator, so a type cannot be half-added; a name missing |
| 110 | +from the table does not exist statically either. `Maybe` (parametric) and |
| 111 | +`none` (constructor, not a type) are deliberately special-cased outside |
| 112 | +the table. |
| 113 | + |
| 114 | +- Type declarations are currently erased at runtime (`MShellTypeDecl` is |
| 115 | + static-only). `tryAs` needs type expressions reified into the evaluator |
| 116 | + so the validator can walk them. |
| 117 | +- The shape language already covers everything JSON needs: shapes, optional |
| 118 | + fields (`name?: T`), unions, `null` vs `none`, `Maybe`. |
0 commit comments