Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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)
Expand Down
80 changes: 75 additions & 5 deletions check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down Expand Up @@ -261,19 +270,40 @@ 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",
"x": []any{map[string]any{"a": 1}}, "xss": []any{[]any{}}, "map": 3,
}
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",
Expand Down Expand Up @@ -328,9 +358,23 @@ 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 ?.`,
`if(c, {"x": 1}, {}).x`: `field "x" 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)
Expand Down Expand Up @@ -369,9 +413,24 @@ 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",
`if(c, {"x": 1}, {})?.x`: "1?",
} {
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)
Expand All @@ -388,7 +447,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) {
Expand All @@ -413,3 +472,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
}
4 changes: 3 additions & 1 deletion docs/guides/designing-an-env.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 30 additions & 6 deletions docs/guides/type-checking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions docs/reference/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`.

Expand All @@ -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
Expand All @@ -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
Expand Down
15 changes: 9 additions & 6 deletions docs_guides_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>",
`{"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 {
Expand All @@ -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) {
Expand Down
6 changes: 4 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading
Loading