Study 0002 Decided
Absent, null and empty
What a missing key, a null and an empty value each mean in the FlowDrop spec, so every implementation reads them the same way.
Here is a node’s settings, as a workflow sends them:
{ "retries": null }
Does that mean “I didn’t set retries, use the default”? Or “retries: none, and ignore whatever is saved”? If you build a JSON API, you have an answer, and the person writing the client in another language may have a different one. Neither of you wrote it down.
This study is how I answered that for the FlowDrop spec, by giving every kind of “nothing” exactly one meaning. If you only want the result, skip to the five rules at the end.
What the FlowDrop spec is
FlowDrop is a visual workflow editor: you draw nodes, connect their inputs and outputs, and something runs the result. The editor is a JavaScript library. The running happens elsewhere, for example in the FlowDrop modules for Drupal.
When more than one program reads the same workflow, “what the code does” stops being a definition. So the rules a workflow obeys (how it is written, stored, validated and run) are written down on their own, independent of any implementation: the FlowDrop Workflow Specification. It is a set of short rules, each with a permanent id like CFG-7 or STORE-4, so that tests and issues can cite them. Where an implementation disagrees with a rule, the implementation has the bug, unless the rule is wrong. It is at version 1.0-draft. I’m reviewing it rule by rule before 1.0, and this study came out of that review.
Most of those rules move JSON around: a workflow’s inputs, a node’s saved settings, the values on the wires between nodes, the result of a run. Which brings us back to null.
Four states, two ways to say nothing
A value can be in four states:
- Not provided. Nobody said anything.
- Provided as nothing. Somebody said “no value here”, on purpose.
- Provided as empty.
"",[],false,0. - Provided with content.
JSON gives you two ways to write “nothing”: leave the key out, or send null. So the first two states have a spelling each, if you agree which is which. The spec already said that a missing key and a null are different. It never said what each one means.
It came up over something small. A workflow declares its outputs, and one of them isn’t connected to anything yet. Should the run result carry it as null, or leave it out? I had answered that a week earlier with “null”. When I tried to write the rule down, I couldn’t, because the answer depended on what null means everywhere else.
The spec mostly agreed with itself
I read every rule that touches a value and sorted them by how they treat the three “nothing” states. The core was consistent:
- A missing key means not provided. A parameter falls through to the saved config, then to the default.
nullis a real value. It stops there and wins over the saved config.- An empty value is a value too. It also wins.
null stops and wins. Read each row left to right: a missing key lets the saved config decide, and the default only if that is missing too, while null and empty values are answers in their own right.That model is worth having, because null is the only way a workflow can say “use nothing here” against a saved setting. But about a dozen rules quietly read it another way. A null default counted as “no default”. A required check failed on null exactly like a missing key. An extraction that found a null reported a miss. Edge keys, versions and message roles treated "" as if the key weren’t there. Each one made sense locally. Together they meant a reader had to check every rule to know what “nothing” meant in it.
Every language blurs it differently
This is where it stops being a spec problem and becomes everybody’s problem. Each language has a comfortable idiom that merges two of the states, and a less comfortable one that keeps them apart. The first three read null and a missing key as the same thing; Go’s omitempty goes further and writes false, 0 and "" as missing:
| Language | Blurs the states | Keeps them apart |
|---|---|---|
| PHP | isset() |
array_key_exists() |
| JavaScript | ??, JSON.stringify |
Object.hasOwn() |
| Rust | Option<T> |
Option<Option<T>> |
| Go | omitempty |
leave it off |
None of these is a bug. They are defaults that are right most of the time, which is exactly why a spec that depends on the difference has to say so out loud. An implementation keeps the difference only if someone does it on purpose.
What I decided
null is a chosen nothing. A missing key means not provided. null means provided, with no value, and it wins over lower tiers. The alternative, “null also means not provided”, is friendlier to isset and ??, but it leaves two spellings for one meaning and no way at all to clear a saved setting. JSON Schema, which the spec’s contracts are written in, already sees it this way: required means the key is present, and null is a type like any other.
Empty is always a value. false is a choice. "" is a string. An untouched optional boolean is missing, not false.
An output with no value has no key. That reversed my own earlier answer. If an unconnected output came back as null, it would flow into the next node and, by the first rule, win over that node’s saved config. A sub-workflow would override settings by accident. Left out, the next node’s config still applies, and null keeps its one meaning: some node really produced null.
In an update, null clears. A missing key leaves the stored value alone, null clears it, a value sets it. That is JSON Merge Patch, and the Rust implementation already worked this way.
“Required” keeps two meanings, both named. At a boundary (launching a workflow) it means the key is present. For a node parameter, it means a non-null value once everything is resolved. A node needs a value to run; a boundary needs the key. The glossary now defines both, so nobody has to guess which one a rule means.
The two calls I made against the recommendation
The study recommended two softer options, and I turned both down.
It suggested that empty could count as “not provided” where a rule says so openly, which would keep some handy input clean-up, like treating a blank version as no version. I said never. An exception like that holds while a value sits in one process, and breaks the moment it is serialized in one language and read back in another. Go’s omitempty is that exact bug, shipped as a feature.
It also suggested keeping the dozen odd rules as stated exceptions, since each had a local reason. I chose to align all of them instead. That costs behaviour changes in four places and some storage work, because a stored default has to tell “not recorded” (no key) from “recorded as null”. One exception stays, on purpose: the two meanings of “required”.
The recommendation was the cheaper path. I think the cheap path is how a spec ends up with a dozen exceptions in the first place.
Others paid for this before
The prior art was the most convincing part of the study, because the same lesson keeps coming back:
- Protocol Buffers dropped field presence for scalars in proto3, so
0,""andfalseread the same as unset. Version 3.15 broughtoptionalback, because people needed the difference. Collapsing empty into absent was undone after years of workarounds. - GraphQL gives a missing input field its default, and passes an explicit
nullon asnull. The same three states, in a widely used API language. - Kubernetes makes optional fields pointers, so unset (
nil) stays distinct from the zero value. A large API with many clients chose to enforce the split in the type system rather than rely on care. - JSON Merge Patch uses absent and
nullexactly as above, and has one known gap: a patch can’t set a value tonull. Worth knowing before you copy it.
Tests, not review
Rules in prose get read differently in each language, which was the whole problem. So the rulings come with a small set of presence fixtures: for each tier and each output, one case with the key missing, one with null, one empty and one with a value, each stating its input, its expected result and the rules it tests. The spec repo validates them, and every implementation runs the same files. When a PHP isset or a serde Option slips back in, a test fails instead of a reviewer having to notice.
Five rules worth stealing
If you design a JSON API or a config format, write these down before your second client exists:
- A missing key means “not provided”. Defaults and fallbacks apply.
nullmeans “provided, and it’s nothing”. It overrides, and in an update it clears.- Empty is a value.
false,0,""and[]are answers, never “not set”. - Output nothing as no key. Don’t invent a
nullfor something nobody produced. - Name the presence test for each language you support, and back it with shared fixtures:
array_key_exists,Object.hasOwn,Option<Option<T>>, noomitempty.
None of this is new. What was missing was writing it down in one place, before the implementations each decided for themselves.
Decisions at a glance
| Question | Decided |
|---|---|
| Should the answer bind the whole spec, or only workflow values? | The whole spec |
| What does null mean? | Null is a chosen 'nothing' |
| Is an empty value ever the same as not provided? | Never |
| What does a run result carry for an output with no value? | Leave the key out |
| What happens to the rules that read it differently? | Align all to the convention |
| What does 'required' demand? | Keep both, name them |
| In a partial update, what does null do? | Null clears the value |
| Which direction do we plan for? | Two rulings plus fixtures |