Skip to content

fix(check): require ?. for fields only one side of a join has - #30

Merged
myzie merged 2 commits into
mainfrom
fix/join-absent-fields
Oct 28, 2026
Merged

myzie merged 2 commits into
mainfrom
fix/join-absent-fields

Conversation

@myzie

@myzie myzie commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

What changes for the user

Check now reports .x on a joined object when only one side of the join has x, and asks for ?. or ?[. The joined type prints that field as x?: T (may be absent), distinct from x: T? (present, may be nil).

See it

Env: b is ObjectOf({y: string}), value {"y": "s"}.

Before (v1.3.0):

[b, {"x": "q"}][0].x
    ok: "q"?
    Run: expr: evaluate error: key "x" not found (did you mean "y"?)

After:

[b, {"x": "q"}][0].x
    `[b, {"x": "q"}][0].x`: field "x" may be absent; use ?.
[b, {"x": "q"}][0]?.x
    ok: "q"?
if(review != nil, review, {"verdict": "approve"})
    ok: {body?: string, verdict: "approve" | "reject"}

Why

Check must agree with Run: an expression that passes Check must not fail with a missing field. Join turned a field that only one side had into T? on a closed object. Check then allowed .x, but at run time the key is missing, not nil, and Run fails. Every join of object types had this problem: list literals, if(...), ||, and host functions typed with Join (such as coalesce). The workflow library's soundness review found it (A2.1).

Decisions to check

  • Add Type.Absent map[string]bool, the fields whose key may be missing. Absence belongs to the object, not to the field's value type, and a set on the object keeps field types unchanged when they are read. Rejected: an Absent flag on the field's own Type. Every place that reads a field type would have to clear the flag so that it does not leak into results.
  • Join keeps a one-sided field's type and marks it absent. It does not make the type T?. Optional keeps one meaning: present, may be nil. The workflow library depends on this, because it sets missing optional JSON Schema properties to nil. Rejected: making the join an open object. That would drop the field's own type, and misspelled field names would no longer be reported.
  • A host can set Absent directly. A host that leaves keys out of objects, instead of setting them to nil, can now describe that. The guide's JSON Schema advice says when to use .OrNil() and when to use Absent.
  • When the other side is open, a one-sided field also gets that side's Elem. The open side may hold that key with an Elem value. v1.3.0 typed the field T?, which was also unsound.
  • A one-sided field whose type is Any still needs ?.. We know the key may be missing even when its value type is unknown. Rejected: skipping the check for Any fields, as Check does for unlisted fields of an open object whose Elem is untyped.
  • The message names the right operator. obj["x"] gets "use ?[", and obj.x gets "use ?.". This also applies to the existing message for an open object's typed Elem.
  • A nil check or has() does not prove that a field is present. ?., ?[ and try are the ways to read it. b.x != nil itself fails at run time when the key is missing, so it could not be the guard.

How to verify

go test ./...
go vet ./...
go run ./examples/type_checking

The regression cases are in TestCheckMatchesRun (marked A2.1). Each rejected case is also run, to confirm that Run fails. Each accepted case is run, to confirm that Run succeeds. TestJoin covers the new join types.

Risk and rollout

Check now rejects some expressions that v1.3.0 accepted. These are plain .x or ["x"] reads of a field that only some of the joined objects have. Joins come from list literals, if(...), ||, any/filter/map over such lists, and host functions typed with Join. Each newly rejected expression can fail in Run, so the fix is to change . to ?.. Types from joins against an open object can also change, from T? to a union with that object's Elem (or any). A joined type's String() output changes from x: T? to x?: T for one-sided fields. Fields that every side has are unchanged.

After merge, release patch v1.3.1. Then workflow#42 adopts it.

🤖 Generated with Claude Code

myzie and others added 2 commits October 28, 2026 13:30
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

@mobiusops mobiusops Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve: The join fix holds up: Absent is set on a fresh object in joinFields, copies made by OrNil/NonNil keep it, and every field read in check.go goes through mayBeAbsent.

This closes the A2.1 gap in the workflow library's soundness review. Optional keeps its meaning of "may be nil", so the workflow library's nil-filled schema properties still type the same way.

Questions

  • Q1. This ships as a patch (v1.3.1), but Check now rejects some expressions v1.3.0 accepted, and those can run fine on the data they were written for. Is a patch the version you want for that, or should it be a minor bump? (types.go:297)

@myzie
myzie merged commit e805252 into main Oct 28, 2026
2 checks passed
@myzie
myzie deleted the fix/join-absent-fields branch October 28, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant