Summary
WFL has two internal "no value" variants — Value::Null and Value::Nothing — and they are crossed:
- the
nothing literal evaluates to Value::Null (typeof reports "Null"), and x is nothing matches it;
parse_json maps a JSON null to Value::Nothing (typeof reports "Nothing"), and x is nothing does not match it.
Because PartialEq for Value short-circuits on std::mem::discriminant, the two variants never compare equal, so the natural guard check if x is nothing: silently passes JSON nulls through. The isnothing native has the opposite bias (matches only Value::Null), so it also misses parse_json nulls.
Reproduction
store payload as parse_json of "{\"k\":null}"
store v as payload["k"]
display "json-null typeof: " with typeof of v
check if v is nothing:
display "json-null is-nothing: yes"
otherwise:
display "json-null is-nothing: no"
end check
store w as nothing
display "literal typeof: " with typeof of w
check if w is nothing:
display "literal is-nothing: yes"
otherwise:
display "literal is-nothing: no"
end check
Observed (release build of current main):
json-null typeof: Nothing
json-null is-nothing: no
literal typeof: Null
literal is-nothing: yes
Expected: both branches say yes — a beginner writing check if v is nothing: after parse_json is expressing exactly this intent.
Where the pieces sit
src/stdlib/json.rs — serde_json::Value::Null => Value::Nothing on parse; both variants serialize back to JSON null.
src/interpreter/value.rs — impl PartialEq for Value returns false for mismatched discriminants, so Null == Nothing is false; type_name() reports them as two different types ("Null" / "Nothing").
src/stdlib/core.rs — native_isnothing matches only Value::Null.
- The
nothing literal evaluates to Value::Null.
Impact
Real-world hit: a web handler that reads fields from a client JSON payload and guards them with is nothing lets {"api_key": null} straight through — in our case the null then reached an HTTP-client native and produced Expected text, got Nothing at runtime (line 0, column 0, so the failure doesn't even point back at user code). Untrusted-input validation is exactly where this guard gets written, which makes the silent pass-through worse than a crash at the check itself.
Workaround we're shipping in the app: type-based guards, e.g. check if typeof of model_id is not equal to "Text": — which catches both variants but is not the form a beginner would reach for first.
Suggested direction
Whatever the internal representation, the user-visible semantics should collapse to one concept:
is nothing (and is not nothing) treat Value::Null and Value::Nothing as equal — likely in the equality path, with a special case ahead of the discriminant short-circuit.
isnothing native matches both.
typeof reports one name for both (today it leaks two internal names, and the names are swapped relative to intuition: the nothing literal reports "Null").
Happy to follow up with a failing-test PR (Red first, per testing.md) if that helps — the repro above drops straight into a TestPrograms/ case plus a unit test on Value equality.
Found while dogfooding WFL as the backend for a chat app (LogbieLLC/Rin app/server.wfl), same series as #647 and #648.
Summary
WFL has two internal "no value" variants —
Value::NullandValue::Nothing— and they are crossed:nothingliteral evaluates toValue::Null(typeofreports"Null"), andx is nothingmatches it;parse_jsonmaps a JSONnulltoValue::Nothing(typeofreports"Nothing"), andx is nothingdoes not match it.Because
PartialEq for Valueshort-circuits onstd::mem::discriminant, the two variants never compare equal, so the natural guardcheck if x is nothing:silently passes JSON nulls through. Theisnothingnative has the opposite bias (matches onlyValue::Null), so it also missesparse_jsonnulls.Reproduction
Observed (release build of current main):
Expected: both branches say yes — a beginner writing
check if v is nothing:afterparse_jsonis expressing exactly this intent.Where the pieces sit
src/stdlib/json.rs—serde_json::Value::Null => Value::Nothingon parse; both variants serialize back to JSONnull.src/interpreter/value.rs—impl PartialEq for Valuereturnsfalsefor mismatched discriminants, soNull == Nothingisfalse;type_name()reports them as two different types ("Null"/"Nothing").src/stdlib/core.rs—native_isnothingmatches onlyValue::Null.nothingliteral evaluates toValue::Null.Impact
Real-world hit: a web handler that reads fields from a client JSON payload and guards them with
is nothinglets{"api_key": null}straight through — in our case the null then reached an HTTP-client native and producedExpected text, got Nothingat runtime (line 0, column 0, so the failure doesn't even point back at user code). Untrusted-input validation is exactly where this guard gets written, which makes the silent pass-through worse than a crash at the check itself.Workaround we're shipping in the app: type-based guards, e.g.
check if typeof of model_id is not equal to "Text":— which catches both variants but is not the form a beginner would reach for first.Suggested direction
Whatever the internal representation, the user-visible semantics should collapse to one concept:
is nothing(andis not nothing) treatValue::NullandValue::Nothingas equal — likely in the equality path, with a special case ahead of the discriminant short-circuit.isnothingnative matches both.typeofreports one name for both (today it leaks two internal names, and the names are swapped relative to intuition: thenothingliteral reports"Null").Happy to follow up with a failing-test PR (Red first, per testing.md) if that helps — the repro above drops straight into a
TestPrograms/case plus a unit test onValueequality.Found while dogfooding WFL as the backend for a chat app (LogbieLLC/Rin
app/server.wfl), same series as #647 and #648.