Skip to content

Commit 7dce9f5

Browse files
committed
Implement tryAs
1 parent 3cd3dd4 commit 7dce9f5

15 files changed

Lines changed: 633 additions & 43 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `tryAs` checked cast: `<value> tryAs <type>` validates the value against the
13+
type at runtime and pushes a `Maybe``just value` when it conforms, `none`
14+
when it does not. Unlike `as` (a static-only checker hint), `tryAs` is meant
15+
for data crossing a trust boundary such as `parseJson` output: one check at
16+
the boundary and everything downstream is precisely typed. Compose with the
17+
existing `?` unwrap for a die-loud form, e.g.
18+
`"pkgs.json" parseJson tryAs Manifest ? :packages? (:name?) map`.
19+
A `tryAs` whose source type could never be the target is a type error.
20+
1221
- CLI completions for `cargo`: subcommands (including installed third-party
1322
ones via `cargo --list`), per-subcommand options, and dynamic values for
1423
`--target`, `--features`, `-p`/`--package`, `--bin`/`--example`/`--test`/`--bench`

ai/json_boundary_casts.md

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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`.

doc/mshell.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,33 @@ type Row = [Cell]
564564
{ "name": "Ada", "age": 36 } as Person :age? 1 +
565565
```
566566

567+
`as` is a static-only assertion: a hint to the checker with no runtime work.
568+
Use it when the value originates inside typed code and the checker just needs help — typing an empty literal (`[] as [str]`), narrowing an inferred union, or tagging a literal into a named type.
569+
570+
`tryAs` is the checked cast for data crossing a trust boundary (parseJson output, process output, spreadsheet rows).
571+
It validates the value against the type at runtime and pushes a `Maybe`: `just value` when it conforms, `none` when it does not.
572+
This is the "parse, don't validate" pattern: one check at the boundary, and everything downstream is precisely typed with no match boilerplate.
573+
Compose with the existing `?` unwrap for the die-loud form.
574+
575+
```mshell
576+
type Manifest = {packages: [{name: str}]}
577+
578+
# Recoverable: handle bad input with one match at the boundary.
579+
"pkgs.json" parseJson tryAs Manifest match
580+
just m : @m :packages? (:name?) map,
581+
none : [],
582+
end
583+
584+
# Die-loud: unwrap immediately when malformed input should stop the script.
585+
"pkgs.json" parseJson tryAs Manifest ? :packages? (:name?) map
586+
```
587+
588+
A `tryAs` whose source type can never be the target (e.g. `5 tryAs str`) is flagged by the checker, since it would always produce `none`.
589+
Shape validation allows extra keys, requires all non-optional fields, and validates optional fields only when present.
590+
A `none` conforms to `Maybe[T]` for every `T`; quotation types are checked by kind only (signatures are not verified at runtime).
591+
Named types must be declared before the cast runs.
592+
Note that `parseJson` produces a `float` for every JSON number, so validate JSON numbers as `float`, not `int`.
593+
567594
Dictionary types are split into homogeneous dictionaries and shapes.
568595
A homogeneous dictionary is for dynamic keys where every value has the same type.
569596
In a type expression, write `{str: int}`.
@@ -1060,7 +1087,7 @@ end wl # Output: 11
10601087
- `gridValues`: Extract Grid or GridView cell values as row-major lists, without a header row and without coercing cell types. (`Grid|GridView -- [[a]]`)
10611088
- `toCsvCell`: Escape a single CSV cell. If the value contains `,`, `"`, or a newline, wraps the value in double quotes and doubles any embedded quotes; otherwise returns the input unchanged. (`str -- str`)
10621089
- `toCsv`: Serialize a list of rows to a CSV string. Each cell is escaped with `toCsvCell`, cells are joined with `,`, and rows are joined with `\n`. (`[[str]] -- str`)
1063-
- `parseJson`: Parse JSON from a string, binary, or file path into mshell objects. JSON `null` becomes the `null` type (distinct from `none`). (`path|str|binary -- list|dict|numeric|str|bool|null`)
1090+
- `parseJson`: Parse JSON from a string, binary, or file path into mshell objects. JSON `null` becomes the `null` type (distinct from `none`); every JSON number becomes a `float`. Narrow the result with `tryAs` (checked, boundary data) or `as` (static hint). (`path|str|binary -- list|dict|numeric|str|bool|null`)
10641091
- `parseExcel`: Parse an `.xlsx` (OOXML) spreadsheet into a list of sheets in workbook (tab) order. Each sheet is a dict with a `name` key (the worksheet name), a `data` key holding a rectangular list of rows (list of lists), a `hidden` key (bool; `true` for hidden or veryHidden sheets), and a `visibility` key (`"visible"`, `"hidden"`, or `"veryHidden"`). Cell values are typed: numbers become floats (dates appear as Excel serial floats), strings become strings (shared, inline, and formula-string results all resolved), booleans become booleans, error cells (e.g. `#DIV/0!`) become `none`, and empty/padding cells are the empty string. Chartsheets are skipped; hidden worksheets are included. Dates are returned as raw Excel serial floats; apply `fromOleDate` at the call site to convert. `parseExcel` assumes the default 1900-based date system, which matches `fromOleDate`'s OLE epoch (1899-12-30). Workbooks saved with the 1904 date system (`<workbookPr date1904="true"/>`, seen on some files originally authored on older Mac Excel or with the "Use 1904 date system" option enabled) have serials offset by 1462 days; on those files, add 1462 to each serial before calling `fromOleDate`, e.g. `@wb :0: :data? :3: :0: 1462 + fromOleDate`. (`path|binary -- list`)
10651092
- `seq`: Generate a list of integers, starting from 0. Exclusive end to integer on stack. `2 seq` produces `[0 1]`. `(int -- [int])`
10661093
- `repeat`: Create a list containing the provided value repeated `n` times. `(a int -- [a])`

doc/type_system.inc.html

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,44 @@ <h1 id="type-system-type-expressions">Type Expressions <a class="section-link" h
153153

154154
<pre><code>{ "name": "Ada", "age": 36 } as Person</code></pre>
155155

156+
<h2 id="type-system-tryas">Checked casts with <code>tryAs</code> <a class="section-link" href="#type-system-tryas" aria-label="Permalink">§</a></h2>
157+
158+
<p>
159+
<code>as</code> is a static-only assertion: a hint to the checker with no runtime work.
160+
That is the right tool when the value originates inside typed code — typing an empty literal (<code>[] as [str]</code>), narrowing an inferred union, or tagging a literal into a named type — because the checker can already see everything it needs.
161+
</p>
162+
163+
<p>
164+
<code>tryAs</code> is the checked cast for data crossing a trust boundary, such as <code>parseJson</code> output, process output, or spreadsheet rows.
165+
It validates the value against the type expression at runtime and pushes a <code>Maybe</code>: <code>just value</code> when the value conforms, <code>none</code> when it does not.
166+
This is the "parse, don't validate" pattern: one check at the boundary hands the checker a precise type, so everything downstream needs no further matching.
167+
The existing <code>?</code> unwrap composes for the die-loud form.
168+
</p>
169+
170+
<pre><code>type Manifest = {packages: [{name: str}]}
171+
172+
# Recoverable: handle bad input with one match at the boundary.
173+
"pkgs.json" parseJson tryAs Manifest match
174+
just m : @m :packages? (:name?) map,
175+
none : [],
176+
end
177+
178+
# Die-loud: unwrap immediately when malformed input should stop the script.
179+
"pkgs.json" parseJson tryAs Manifest ? :packages? (:name?) map</code></pre>
180+
181+
<p>
182+
A <code>tryAs</code> whose source type can never be the target (for example <code>5 tryAs str</code>) is a type error, since the runtime check would always produce <code>none</code>.
183+
</p>
184+
185+
<p>
186+
Validation semantics mirror the <code>match</code> block's type arms.
187+
<code>int</code> matches an integer only — note that <code>parseJson</code> produces a <code>float</code> for every JSON number, so validate JSON numbers as <code>float</code>.
188+
Shape validation allows extra keys (width subtyping), requires all non-optional fields, and validates optional fields only when present.
189+
A <code>none</code> conforms to <code>Maybe[T]</code> for every <code>T</code>; a <code>just</code> validates its inner value.
190+
Quotation types are checked by kind only — signatures are not verified at runtime.
191+
A named type must have been declared before the cast runs.
192+
</p>
193+
156194
<h1 id="type-system-dictionaries">Dictionaries <a class="section-link" href="#type-system-dictionaries" aria-label="Permalink">§</a> <a class="back-to-top" href="#type-system-top">Back to top</a></h1>
157195

158196
<p>

mshell/Evaluator.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,11 @@ type EvalState struct {
396396

397397
defIndex map[string]int
398398
defIndexLen int
399+
400+
// typeDefs is the runtime type environment: `type Name = <typeExpr>`
401+
// declarations register their body AST here so `tryAs Name` can
402+
// resolve named types during validation.
403+
typeDefs map[string]TypeExpression
399404
}
400405

401406
// RebuildDefinitionIndex records the first index for each name, matching
@@ -906,18 +911,54 @@ func (state *EvalState) processToken(token MShellParseItem, frame *EvaluationFra
906911
return state.processTokenToken(t, frame, frames)
907912

908913
case *MShellTypeDecl:
909-
// Static-only: type declarations have no runtime effect by design.
914+
// Registers the declaration for `tryAs`; otherwise static-only.
915+
state.registerTypeDecl(t)
910916
return SimpleSuccess()
911917

912918
case *MShellAsCast:
913919
// Static-only: `as` is a checker hint; no runtime work.
914920
return SimpleSuccess()
915921

922+
case *MShellTryAsCast:
923+
return state.processTryAsCast(t, stack)
924+
916925
default:
917926
return state.FailWithMessage(fmt.Sprintf("Unknown token type: %T\n", token))
918927
}
919928
}
920929

930+
// registerTypeDecl records a `type Name = <typeExpr>` declaration in the
931+
// runtime type environment for `tryAs`. The checker flags duplicate
932+
// declarations; at runtime the latest evaluation wins.
933+
func (state *EvalState) registerTypeDecl(decl *MShellTypeDecl) {
934+
if state.typeDefs == nil {
935+
state.typeDefs = make(map[string]TypeExpression, 8)
936+
}
937+
state.typeDefs[decl.Name] = decl.Body
938+
}
939+
940+
// processTryAsCast implements the runtime of `tryAs`: pop a value,
941+
// validate it structurally against the type expression, and push
942+
// `just value` when it conforms or `none` when it does not. The
943+
// validation error return is reserved for malformed casts (an unknown
944+
// type name), which fail the script rather than producing a `none`.
945+
func (state *EvalState) processTryAsCast(cast *MShellTryAsCast, stack *MShellStack) EvalResult {
946+
obj, err := stack.Pop()
947+
if err != nil {
948+
return state.FailWithMessage(fmt.Sprintf("%d:%d: 'tryAs' requires a value on the stack.\n", cast.TryAsToken.Line, cast.TryAsToken.Column))
949+
}
950+
conforms, verr := cast.Target.validateObj(state, obj)
951+
if verr != nil {
952+
return state.FailWithMessage(fmt.Sprintf("%d:%d: %s\n", cast.TryAsToken.Line, cast.TryAsToken.Column, verr.Error()))
953+
}
954+
if conforms {
955+
stack.Push(&Maybe{obj: obj})
956+
} else {
957+
stack.Push(&Maybe{obj: nil})
958+
}
959+
return SimpleSuccess()
960+
}
961+
921962
// callDefinition handles calling a definition with TCO support
922963
func (state *EvalState) callDefinition(def MShellDefinition, token Token, frame *EvaluationFrame, frames *[]EvaluationFrame) EvalResult {
923964
newContext := frame.Context.CloneLessVariables()
@@ -3222,8 +3263,14 @@ func (state *EvalState) evaluateItems(objects []MShellParseItem, stack *MShellSt
32223263
}
32233264
case *MShellAsCast:
32243265
// Static-only: `as` is a checker hint; no runtime work.
3266+
case *MShellTryAsCast:
3267+
result := state.processTryAsCast(t, stack)
3268+
if result.ShouldPassResultUpStack() {
3269+
return result
3270+
}
32253271
case *MShellTypeDecl:
3226-
// Static-only: type declarations have no runtime effect by design.
3272+
// Registers the declaration for `tryAs`; otherwise static-only.
3273+
state.registerTypeDecl(t)
32273274
default:
32283275
return state.FailWithMessage(fmt.Sprintf("We haven't implemented the type '%T' yet.\n", t))
32293276
}

mshell/Lexer.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ const (
109109
TRY
110110
FAIL_KEYWORD
111111
PURE
112+
TRYAS
112113
)
113114

114115
func (t TokenType) String() string {
@@ -283,6 +284,8 @@ func (t TokenType) String() string {
283284
return "AS"
284285
case TYPE:
285286
return "TYPE"
287+
case TRYAS:
288+
return "TRYAS"
286289
case TRY:
287290
return "TRY"
288291
case FAIL_KEYWORD:
@@ -621,6 +624,9 @@ func (l *Lexer) literalOrKeywordType() TokenType {
621624
case 'u':
622625
return l.checkKeyword(3, "e", TRUE)
623626
case 'y':
627+
if l.curLen() > 3 {
628+
return l.checkKeyword(3, "As", TRYAS)
629+
}
624630
return l.checkKeyword(3, "", TRY)
625631
}
626632
}

mshell/Lexer_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func TestTypeCheckerKeywords(t *testing.T) {
1313
{"as", AS},
1414
{"type", TYPE},
1515
{"try", TRY},
16+
{"tryAs", TRYAS},
1617
{"fail", FAIL_KEYWORD},
1718
{"pure", PURE},
1819
// Make sure neighbors still tokenize as before.
@@ -23,6 +24,8 @@ func TestTypeCheckerKeywords(t *testing.T) {
2324
{"types", LITERAL},
2425
{"asx", LITERAL},
2526
{"trying", LITERAL},
27+
{"tryAsX", LITERAL},
28+
{"tryas", LITERAL},
2629
{"failed", LITERAL},
2730
{"purest", LITERAL},
2831
}

mshell/Parser.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,8 @@ func (parser *MShellParser) ParseItem() (MShellParseItem, error) {
11021102
return parser.ParsePrefixQuote()
11031103
case AS:
11041104
return parser.ParseAsCast()
1105+
case TRYAS:
1106+
return parser.ParseTryAsCast()
11051107
default:
11061108
return parser.ParseSimple(), nil
11071109
}

mshell/TypeCast.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,37 @@ func (c *Checker) Cast(target TypeId, callSite Token) {
151151
c.stack.Push(target)
152152
}
153153

154+
// TryCast implements `<value> tryAs <target>`. It pops the top of the
155+
// stack and pushes Maybe[target]. The same compatibility rule as Cast
156+
// applies, but for the opposite reason: a source that could never be the
157+
// target makes the runtime check always-none, which is almost certainly
158+
// a bug, so it is reported. The runtime narrowing itself (wide union in,
159+
// precise type out wrapped in Maybe) is exactly the case Cast's generous
160+
// direction already blesses.
161+
func (c *Checker) TryCast(target TypeId, callSite Token) {
162+
if c.stack.Len() == 0 {
163+
c.errors = append(c.errors, TypeError{
164+
Kind: TErrStackUnderflow,
165+
Pos: callSite,
166+
Hint: "tryAs",
167+
})
168+
c.stack.Push(c.arena.MakeMaybe(target))
169+
return
170+
}
171+
top := c.stack.items[len(c.stack.items)-1]
172+
c.stack.items = c.stack.items[:len(c.stack.items)-1]
173+
174+
if !c.castOk(top, target) {
175+
c.errors = append(c.errors, TypeError{
176+
Kind: TErrInvalidCast,
177+
Pos: callSite,
178+
Expected: target,
179+
Actual: top,
180+
})
181+
}
182+
c.stack.Push(c.arena.MakeMaybe(target))
183+
}
184+
154185
// castOk reports whether `src` can be cast to `dst`.
155186
//
156187
// Trials, in order (each isolated by a substitution checkpoint):

0 commit comments

Comments
 (0)