From e85e11e622169df7e2a891c972ca2c091f566207 Mon Sep 17 00:00:00 2001 From: Curtis Myzie Date: Wed, 28 Oct 2026 13:30:06 -0400 Subject: [PATCH 1/2] fix(check): require ?. for fields only one side of a join has Join turned a field only one side listed into `T?` on a closed object, so Check accepted `.x` on it. At run time the key is missing, not nil, and Run failed with `key "x" not found`. This reached any join of object types: list literals, if(...), ||, and host functions typed with Join. Type gains Absent, a set of fields whose key may be missing. Join marks one-sided fields absent instead of nil-able, and Check asks for ?. or ?[ to read them, as it already does for an open object's typed unlisted fields. A field both sides list stays present; Optional keeps meaning "present, may be nil". Against an open side, a one-sided field also joins that side's Elem, since the key may hold it. Co-Authored-By: Claude Opus 5.5 --- check.go | 30 ++++++++++++++------ check_test.go | 78 +++++++++++++++++++++++++++++++++++++++++++++++---- types.go | 70 ++++++++++++++++++++++++++++++++++----------- 3 files changed, 149 insertions(+), 29 deletions(-) diff --git a/check.go b/check.go index df2396b..db11b1b 100644 --- a/check.go +++ b/check.go @@ -436,16 +436,14 @@ func (c *checker) field(n, recvNode ast.Expr, recv *Type, name string, optional switch { case ok: f = orAny(f) // a nil field type is unknown, like a nil env entry + if recv.Absent[name] { + f = c.mayBeAbsent(n, name, f, optional) + } case recv.Open: - // An unlisted field may be absent, and selecting a missing key - // fails at run time; only ?. reads it safely, as nil. + // An unlisted field may be absent too, unless it is untyped. f = orAny(recv.Elem) - switch { - case f.isAny(): - case optional: - f = f.OrNil() - case c.lenient == 0: - c.report(n, "field %q may be absent; use ?.", name) + if !f.isAny() { + f = c.mayBeAbsent(n, name, f, optional) } default: names := make([]string, 0, len(recv.Fields)) @@ -462,6 +460,22 @@ func (c *checker) field(n, recvNode ast.Expr, recv *Type, name string, optional return f } +// mayBeAbsent types a read of a field whose key may be missing. Selecting +// a missing key fails at run time; only ?. and ?[ read it safely, as nil. +func (c *checker) mayBeAbsent(n ast.Expr, name string, f *Type, optional bool) *Type { + switch { + case optional: + return f.OrNil() + case c.lenient == 0: + use := "?." + if _, ok := n.(*ast.IndexExpr); ok { + use = "?[" + } + c.report(n, "field %q may be absent; use %s", name, use) + } + return f +} + func (c *checker) index(n, recvNode ast.Expr, recv *Type, idxNode ast.Expr, idx *Type, optional bool) *Type { if recv.IsUnion() { c.oneKind(recvNode, recv) diff --git a/check_test.go b/check_test.go index e18a7bd..8ed3851 100644 --- a/check_test.go +++ b/check_test.go @@ -122,7 +122,16 @@ func TestCheckTemplate(t *testing.T) { func TestJoin(t *testing.T) { a := ObjectOf(map[string]*Type{"verdict": String(), "body": String()}) b := ObjectOf(map[string]*Type{"verdict": EnumOf("APPROVE"), "extra": Number()}) - require.Equal(t, "{body: string?, extra: number?, verdict: string}", Join(a, b).String()) + // A field only one side has may be absent; one both sides have is + // present, and stays nil-able only if a side says so. + require.Equal(t, "{body?: string, extra?: number, verdict: string}", Join(a, b).String()) + c := ObjectOf(map[string]*Type{"verdict": String().OrNil(), "body": String()}) + require.Equal(t, "{body: string, verdict: string?}", Join(a, c).String()) + require.Equal(t, "{body?: string, extra?: number, verdict: string?}", Join(Join(a, b), c).String()) + // Against an open side, the field may also hold that side's Elem. + open := &Type{Kind: KindObject, Open: true, Elem: Number()} + require.Equal(t, "{body?: number | string, verdict?: number | string, ...}", Join(a, open).String()) + require.Equal(t, "{body?: any, verdict?: any, ...}", Join(a, OpenObject(nil)).String()) require.Equal(t, "string?", Join(String(), Null()).String()) require.Equal(t, "number | string", Join(String(), Number()).String()) } @@ -261,8 +270,19 @@ func TestCheckMatchesRun(t *testing.T) { "x": ListOf(item), "xss": ListOf(ListOf(item)), "map": Number(), + // Objects for joins: bx has every field of b, and y with + // another type; nb is nil at run time and c is false, so the + // joins below yield b, which lacks x. + "b": ObjectOf(map[string]*Type{"y": String()}), + "bx": ObjectOf(map[string]*Type{"x": String(), "y": Number()}), + "nb": ObjectOf(map[string]*Type{"x": String(), "y": Number()}).OrNil(), + "c": Bool(), + // A host may mark a field absent itself: draft's title is + // missing at run time. + "draft": &Type{Kind: KindObject, Fields: map[string]*Type{"title": String()}, Absent: map[string]bool{"title": true}}, } values := map[string]any{ + "b": map[string]any{"y": "s"}, "bx": map[string]any{"x": "v", "y": 1}, "nb": nil, "c": false, "draft": map[string]any{}, "s": nil, "w": "x", "xs": []any{1, 2}, "ys": nil, "a": map[string]any{"t": []any{"q"}, "n": nil}, "obj": map[string]any{"k": "v"}, "scores": map[string]any{"alice": 1}, "k": "bob", @@ -270,10 +290,20 @@ func TestCheckMatchesRun(t *testing.T) { } compile := func(src string) *Program { p, err := Compile(src, WithBuiltins(), WithFunctions(StringFuncs()), WithFunctions(MathFuncs()), - WithFunctions(CollectionFuncs()), WithFunctions(map[string]any{"count": func(xs []any) int { return len(xs) }})) + WithFunctions(CollectionFuncs()), WithFunctions(map[string]any{ + "count": func(xs []any) int { return len(xs) }, + "coalesce": coalesce, + })) require.NoError(t, err, src) return p } + coalesceType := WithFuncType("coalesce", func(args []*Type) (*Type, error) { + var t *Type + for _, a := range args { + t = Join(t, a.NonNil()) + } + return orAny(t), nil + }) for src, want := range map[string]string{ // B1 is in kwfield_test.go. B2: a union is not Any. `upper(s || 0)`: "may be number, and must be string", @@ -328,9 +358,22 @@ func TestCheckMatchesRun(t *testing.T) { `contains("abc", 1)`: "contains: looking in string needs a string", `contains(obj, 1)`: "contains: looking in an object needs a string", `first(x) != nil && any(xss, x, first(x).a > 1)`: "`first(x)`: may be nil", + // A2.1: a field only one side of a join has may be absent. + `[b, {"x": "q"}][0].x`: `field "x" may be absent; use ?.`, + `[b, bx][0].x`: `field "x" may be absent; use ?.`, + `[b, bx][0]["x"]`: `field "x" may be absent; use ?[`, + `if(c, bx, b).x`: `field "x" may be absent; use ?.`, + `(nb || b).x`: `field "x" may be absent; use ?.`, + `coalesce(nb, b).x`: `field "x" may be absent; use ?.`, + `upper(if(c, bx, b).x)`: `field "x" may be absent; use ?.`, + `[b, scores][0].alice`: `field "alice" may be absent; use ?.`, + `any([b, bx], it.x == "v")`: `field "x" may be absent; use ?.`, + `[[b], [bx]][0][0].x`: `field "x" may be absent; use ?.`, + `{"o": if(c, bx, b)}.o.x`: `field "x" may be absent; use ?.`, + `draft.title`: `field "title" may be absent; use ?.`, } { p := compile(src) - _, err := p.Check(env) + _, err := p.Check(env, coalesceType) require.Error(t, err, src) require.Contains(t, err.Error(), want, src) _, err = p.Run(context.Background(), values) @@ -369,9 +412,23 @@ func TestCheckMatchesRun(t *testing.T) { `try(scores[k], 0)`: "number", `{(w || "k"): 1}`: "{...}", `string(s) + upper(w || "")`: "string", + // A2.1: ?. and ?[ read a field that may be absent as nil. + `[b, {"x": "q"}][0]?.x`: `"q"?`, + `[b, bx][0]?["x"]`: "string?", + `if(c, bx, b)?.x`: "string?", + `(nb || b)?.x`: "string?", + `coalesce(nb, b)?.x`: "string?", + `try(if(c, bx, b).x, "none")`: "string", + `any([b, bx], it?.x == "v")`: "bool", + // Fields both sides have are present, whatever their types. + `if(c, bx, b).y`: "number | string", + `coalesce(nb, b).y`: "number | string", + `[b, {"y": "t"}][0].y`: "string", + `[b, scores][0]?.y`: "(number | string)?", + `draft?.title || "untitled"`: "string", } { p := compile(src) - got, err := p.Check(env) + got, err := p.Check(env, coalesceType) require.NoError(t, err, src) require.Equal(t, want, got.String(), src) _, err = p.Run(context.Background(), values) @@ -388,7 +445,7 @@ func TestCheckMatchesRun(t *testing.T) { // M2: a nil field type is Any when joined. require.Equal(t, "{k: any}", Join(ObjectOf(map[string]*Type{"k": nil}), ObjectOf(map[string]*Type{"k": String()})).String()) - require.Equal(t, "{k: any}", Join(ObjectOf(map[string]*Type{"k": nil}), ObjectOf(map[string]*Type{})).String()) + require.Equal(t, "{k?: any}", Join(ObjectOf(map[string]*Type{"k": nil}), ObjectOf(map[string]*Type{})).String()) } func TestCheckHints(t *testing.T) { @@ -413,3 +470,14 @@ func TestCheckIndexReportsOnce(t *testing.T) { _, err = checkSrc(t, `try(findings.items[nil], 0)`) require.NoError(t, err) } + +// coalesce returns its first non-nil argument, like the host function +// workflows register. +func coalesce(args ...any) any { + for _, a := range args { + if a != nil { + return a + } + } + return nil +} diff --git a/types.go b/types.go index ddfbbb1..3b461c6 100644 --- a/types.go +++ b/types.go @@ -34,8 +34,15 @@ type Type struct { // Elem is the element type of a list. For an open object it is the // type of fields not listed in Fields (nil means Any). Elem *Type - // Fields are an object's known fields. + // Fields are an object's known fields. Each is present in every value + // of the type unless Absent marks it; a field's Optional means its + // value may be nil, not that its key may be missing. Fields map[string]*Type + // Absent marks fields in Fields whose key may be missing from the + // object, as Join produces for a field only one side has. Reading one + // needs ?. or ?[, which give nil; plain . fails at run time when the + // key is missing. + Absent map[string]bool // Open allows fields beyond Fields. Selecting an unlisted field of a // closed object is an error. Open bool @@ -105,6 +112,7 @@ func (t *Type) isAny() bool { return t == nil || (t.Kind == KindAny && len(t.Var func (t *Type) IsUnion() bool { return t != nil && t.Kind == KindAny && len(t.Variants) > 0 } // String renders the type, for example `{kind: "bug_fix" | "feature", items: list}?`. +// A field that may be absent renders as `name?: type`. func (t *Type) String() string { if t == nil { return "any" @@ -142,7 +150,11 @@ func (t *Type) String() string { sort.Strings(names) parts := make([]string, 0, len(names)+1) for _, k := range names { - parts = append(parts, k+": "+t.Fields[k].String()) + sep := ": " + if t.Absent[k] { + sep = "?: " + } + parts = append(parts, k+sep+t.Fields[k].String()) } if t.Open { parts = append(parts, "...") @@ -266,20 +278,7 @@ func Join(a, b *Type) *Type { out.Elem = Join(orAny(a.Elem), orAny(b.Elem)) case KindObject: out.Open = a.Open || b.Open - out.Fields = map[string]*Type{} - for k, at := range a.Fields { - // A nil field type is Any, not "no type yet". - if bt, ok := b.Fields[k]; ok { - out.Fields[k] = Join(orAny(at), orAny(bt)) - } else { - out.Fields[k] = orAny(at).OrNil() - } - } - for k, bt := range b.Fields { - if _, ok := a.Fields[k]; !ok { - out.Fields[k] = orAny(bt).OrNil() - } - } + joinFields(out, a, b) switch { case a.Open && b.Open: out.Elem = Join(orAny(a.Elem), orAny(b.Elem)) @@ -299,3 +298,42 @@ func Join(a, b *Type) *Type { } return out } + +// joinFields sets the fields of out, the join of objects a and b. A field +// only one side lists may be absent, since the value may be the other +// side, which lacks it; if that side is open, the field may also hold +// that side's Elem. +func joinFields(out, a, b *Type) { + out.Fields = map[string]*Type{} + set := func(k string, t *Type, absent bool) { + out.Fields[k] = t + if absent { + if out.Absent == nil { + out.Absent = map[string]bool{} + } + out.Absent[k] = true + } + } + for k, at := range a.Fields { + // A nil field type is Any, not "no type yet". + if bt, ok := b.Fields[k]; ok { + set(k, Join(orAny(at), orAny(bt)), a.Absent[k] || b.Absent[k]) + } else { + set(k, unlistedJoin(at, b), true) + } + } + for k, bt := range b.Fields { + if _, ok := a.Fields[k]; !ok { + set(k, unlistedJoin(bt, a), true) + } + } +} + +// unlistedJoin is the type of a field listed with type t on one side of +// a join and not listed by other. +func unlistedJoin(t, other *Type) *Type { + if other.Open { + return Join(orAny(t), orAny(other.Elem)) + } + return orAny(t) +} From c64d4d0d8278bf89f460e371a0e8c11eda29f45d Mon Sep 17 00:00:00 2001 From: Curtis Myzie Date: Wed, 28 Oct 2026 13:31:17 -0400 Subject: [PATCH 2/2] docs(check): explain fields that may be absent after a join The guide, spec, env-design guide and llms.txt now say that a join of objects marks a field only one side has as absent (`name?: type`), that reading it needs ?. or ?[, and how that differs from a present field whose value may be nil (`name: type?`). The JSON Schema advice now says when to use .OrNil() and when to use Absent. The guide test covers the new examples. Co-Authored-By: Claude Opus 5.5 --- check_test.go | 2 ++ docs/guides/designing-an-env.md | 4 +++- docs/guides/type-checking.md | 36 +++++++++++++++++++++++++++------ docs/reference/spec.md | 14 ++++++++++--- docs_guides_test.go | 15 ++++++++------ llms.txt | 6 ++++-- 6 files changed, 59 insertions(+), 18 deletions(-) diff --git a/check_test.go b/check_test.go index 8ed3851..b911b3f 100644 --- a/check_test.go +++ b/check_test.go @@ -371,6 +371,7 @@ func TestCheckMatchesRun(t *testing.T) { `[[b], [bx]][0][0].x`: `field "x" may be absent; use ?.`, `{"o": if(c, bx, b)}.o.x`: `field "x" may be absent; use ?.`, `draft.title`: `field "title" may be absent; use ?.`, + `if(c, {"x": 1}, {}).x`: `field "x" may be absent; use ?.`, } { p := compile(src) _, err := p.Check(env, coalesceType) @@ -426,6 +427,7 @@ func TestCheckMatchesRun(t *testing.T) { `[b, {"y": "t"}][0].y`: "string", `[b, scores][0]?.y`: "(number | string)?", `draft?.title || "untitled"`: "string", + `if(c, {"x": 1}, {})?.x`: "1?", } { p := compile(src) got, err := p.Check(env, coalesceType) diff --git a/docs/guides/designing-an-env.md b/docs/guides/designing-an-env.md index d027cbf..9909630 100644 --- a/docs/guides/designing-an-env.md +++ b/docs/guides/designing-an-env.md @@ -182,7 +182,9 @@ If expressions are written ahead of time, describe the env's shape as a an expression is saved. The same choices carry over: a map whose keys you control is a closed `ObjectOf`; a JSON payload you only partly know is an `OpenObject`; a key you normalize to an explicit `nil` is a type -with `.OrNil()`, which the checker makes every read handle. +with `.OrNil()`, which the checker makes every read handle; and a key +you may leave out goes in the object's `Absent` set, so reads of it +need `?.`. ## When to preprocess in Go vs in the expression diff --git a/docs/guides/type-checking.md b/docs/guides/type-checking.md index 51720f7..1a9ca79 100644 --- a/docs/guides/type-checking.md +++ b/docs/guides/type-checking.md @@ -62,8 +62,11 @@ hurt. If your data is described by JSON Schema, convert the schema to a `Type`: `object` with `properties` becomes `ObjectOf` (or `OpenObject` -when `additionalProperties` is not false), `enum` becomes `EnumOf`, and -a property not in `required` becomes `.OrNil()`. +when `additionalProperties` is not false), and `enum` becomes `EnumOf`. +A property not in `required` becomes `.OrNil()` if your host sets every +missing property to nil before `Run`. If the host leaves the key out +instead, add the property to the object's `Absent` set +(`Absent: map[string]bool{"note": true}`), so reading it needs `?.`. ## Running it @@ -154,9 +157,29 @@ expression, so `review.verdict` is narrowed by `review != nil` but not by `r != nil` where `r` happens to hold the same value. A key may be absent from an object: an unlisted field of an open object -whose `Elem` is typed, or `scores[name]` where `name` is any string. -Reading a missing key fails, so `Check` asks for `?.` or `?[`, which -give nil instead. +whose `Elem` is typed, a field only some of the joined objects have (see +below), a field in the object's `Absent` set, or `scores[name]` where +`name` is any string. Reading a missing key fails, so `Check` asks for +`?.` or `?[`, which give nil instead. + +Objects are joined wherever a value may be one of several: `if(...)`, +`||`, a list literal, and a function typed with `Join`. A field that +every side has is present in the join. A field that only one side has +may be absent, and the type shows it as `name?: type`: + +```text +if(review != nil, review, {"verdict": "approve"}) + ok: {body?: string, verdict: "approve" | "reject"} +if(review != nil, review, {"verdict": "approve"}).body + `if(review != nil, review, {"verdict": "approve"}).body`: field "body" may be absent; use ?. +if(review != nil, review, {"verdict": "approve"})?.body + ok: string? +``` + +Absent and nil are different. A field `body?: string` may lack its key, +so reading it needs `?.`. A field `note: string?`, typed `.OrNil()`, +always has its key and its value may be nil: `.` reads it safely, and +only what you do with the value needs a nil check. ## `&&`, `||` and unions @@ -190,7 +213,8 @@ t, err := p.Check(env, expr.WithFuncType("concat", func(args []*expr.Type) (*exp The function receives the argument types and returns the result type, or an error to report a misuse. `Join(a, b)` is the type of a value that -is either `a` or `b`. +is either `a` or `b`. For two objects, a field only one of them has may +be absent in the join. Names resolve the way `Run` resolves them: env first, then registered functions, then the higher-order forms. Registering `count` replaces the diff --git a/docs/reference/spec.md b/docs/reference/spec.md index 6a71058..e29338e 100644 --- a/docs/reference/spec.md +++ b/docs/reference/spec.md @@ -733,7 +733,10 @@ the result type. `Template.Check` does the same for every `${...}`. A `Type` has a kind (any, nil, bool, number, string, list, object), an element type for lists, fields for objects (closed unless `Open`), an -`Optional` flag (the value may be nil), and an optional `Enum`. Literals +`Absent` set of fields whose key may be missing (a field not in it is +always present), an `Optional` flag (the value may be nil), and an +optional `Enum`. A field in `Absent` renders as `name?: type`; a field +whose value may be nil renders as `name: type?`. Literals type as single-value enums, so `{"verdict": "APPROVE"}` types as `{verdict: "APPROVE"}`. @@ -751,7 +754,8 @@ Check reports, as a `*TypeError` wrapping `ErrType`, every: `join([1, 2], ",")`, `sort("abc")`), or nil where it does not accept nil (`upper(nil)`, `has(m, nil)`); - object key that may be absent: an unlisted field of an open object - with a typed `Elem`, or an index by a string that is not a literal + with a typed `Elem`, a field in `Absent` (which Join sets for a field + only one side has), or an index by a string that is not a literal (`scores[name]`), unless read with `?.`/`?[` or inside `try`; - object literal key that is not a string or may be nil; - `==`/`!=` that can never be true: different kinds, or a literal outside @@ -766,7 +770,11 @@ builtin accepts a union only when it accepts every member, and `==` reports one that shares no kind with the other side. A nil check narrows: in `x != nil && x.a > 3`, `x == nil || x.a > 3` and `if(x != nil, x.a, 0)`, `x` is known not to be nil where it is read. A field -an open object does not list may be absent, so it is read with `?.`. +an open object does not list may be absent, so it is read with `?.`. Joining +two objects (through `if`, `||`, a list literal or a function typed with +`Join`) keeps the fields both have, as present; a field only one has is +in `Absent`, so `if(c, {"x": 1}, {}).x` is reported and +`if(c, {"x": 1}, {})?.x` types as `1?`. `==` and `!=` on lists or objects are reported, since Run rejects them. `Any` disables checking for the value and everything derived from it, so a diff --git a/docs_guides_test.go b/docs_guides_test.go index 0fa0911..f277768 100644 --- a/docs_guides_test.go +++ b/docs_guides_test.go @@ -762,12 +762,14 @@ func TestGuide_TypeChecking(t *testing.T) { `issue.kind == "bug" && issue.score > 0.5`: "bool", `map(filter(issue.labels, startsWith(it, "area/")), upper(it))`: "list", `{"title": issue.title, "kind": issue.kind}`: `{kind: "bug" | "feature" | "question", title: string}`, - `review?.verdict`: `("approve" | "reject")?`, - `try(review.body, "no review")`: "string", - `review != nil && review.verdict == "approve"`: "bool", - `event.action`: "any", - `issue.title || "untitled"`: "string", - `issue.title || 0`: "0 | string", + `review?.verdict`: `("approve" | "reject")?`, + `try(review.body, "no review")`: "string", + `review != nil && review.verdict == "approve"`: "bool", + `event.action`: "any", + `issue.title || "untitled"`: "string", + `issue.title || 0`: "0 | string", + `if(review != nil, review, {"verdict": "approve"})`: `{body?: string, verdict: "approve" | "reject"}`, + `if(review != nil, review, {"verdict": "approve"})?.body`: "string?", } { got, err := check(src) if err != nil { @@ -785,6 +787,7 @@ func TestGuide_TypeChecking(t *testing.T) { `split(issue.title, 1)`: "split: argument 2 must be string, not number", `issue.labels[issue.title]`: "list index must be a number, not string", `len(issue.title || 0)`: "len: argument 1 may be number, and must be string or a list or an object", + `if(review != nil, review, {"verdict": "approve"}).body`: "`if(review != nil, review, {\"verdict\": \"approve\"}).body`: field \"body\" may be absent; use ?.", } { _, err := check(src) if !errors.Is(err, ErrType) || !strings.Contains(err.Error(), want) { diff --git a/llms.txt b/llms.txt index a1c0d65..e5e59a3 100644 --- a/llms.txt +++ b/llms.txt @@ -475,8 +475,10 @@ true (including against an enum). A nil `*Type` means `Any`. a registered function: `fn(args []*expr.Type) (*expr.Type, error)` returns the result type or an error to report. Without it, registered functions return `Any`. Names resolve as in Run: env, then registered functions, then -the forms. An open object's unlisted fields are `Any` unless `Elem` is set; -reading a key that may be absent needs `?.`/`?[`. Guide: +the forms. An open object's unlisted fields are `Any` unless `Elem` is set. +A field only one side of a join has (`if(c, a, b)`, `a || b`, `[a, b]`) may +be absent and prints as `name?: type`, unlike `name: type?` (present, may be +nil). Reading a key that may be absent needs `?.`/`?[`. Guide: `docs/guides/type-checking.md`. Field names may be Go keywords: `event.type`, `x?.range`.