Let's talk
engineering

Not set and zero are different answers, and one input box cannot hold both

The low-stock screen was empty. Stock levels existed, items were plainly running down, and the screen showed nothing. The obvious conclusion was that the view behind it was wrong.

It was not wrong. It was proved correct by setting a reorder level above the current quantity on one item — the item appeared — and then clearing it — the item disappeared. The screen was empty because every seeded item had no reorder level configured, and an item with no reorder level is not low. It cannot be. Low is measured against a threshold, and there was no threshold.

That is the whole subject of this article, and it is a smaller idea than it sounds: a field with no value is not a field with the value zero, and every layer of a stack will try to merge them for you.

The two sentences the field has to hold

A reorder level answers “tell me when this drops to here”. Two distinct configurations exist and they are not neighbours on a number line:

  • No reorder level. This item is not monitored. It will never appear in low stock and will never raise an alert, however far down it goes. Most items in most catalogues are this.
  • A reorder level of zero. This item is monitored, and I want to be told the moment it runs out.

An item at zero on hand with no reorder level is silent by design. An item at zero on hand with a reorder level of zero raises an alert. The stock figure is identical. The behaviour is opposite, and which one you get is a property of the configuration, not of the stock.

Once that is stated, every implementation decision follows from it, and every shortcut that collapses the two is visibly a bug.

Where the collapse happens

It happens in the form, and it happens for a reason that looks like tidiness.

A number input that is empty yields an empty string. Converting that to a number gives zero. So the straightforward implementation — read the input, convert to a number, send it — turns “I have not typed anything” into “alert me at zero”, and it does it for every item where somebody opened the dialog and closed it again.

The fix is to keep the field a string all the way to the point where the request is built, and to distinguish an empty string from the string “0” explicitly. That is the entire trick. It looks like an oversight in a code review — why is this a string, it is a number — and it is the only shape that can represent both states.

The same collapse waits at three other layers, and each needs its own decision:

The table. A blank cell and a zero look different, but a table that renders the absent value as 0 has already lied. It renders “Not set” instead — a value nobody chose should not be displayed as a value.

The filter. “Below reorder level” is not a checkbox. It has three states: show me items below their threshold, show me items not below their threshold, and do not filter on this at all. Sent as true, false, or omitted. A boolean filter that only knows on and off cannot express the third, and the third is the default.

The clearing action. On the mobile client the editor has an explicit “remove reorder level” action rather than relying on the user emptying the field. Emptying a field is ambiguous — it might mean “unset this” and it might mean “I have not finished typing” — and an ambiguous gesture should not be the only way to reach a distinct state.

Each of those was verified against the running API rather than reasoned about: set a level above the current quantity and the item appears in low stock; clear it and the item disappears; type “0” and the stored value comes back as a zero rather than a null. Three assertions, and they are the only three that distinguish this implementation from the broken one.

The alert has to be edge-triggered

A threshold implies a notification, and a notification implies a question most implementations never ask: how often?

The naive version fires whenever a quantity is observed to be below the threshold. That produces an alert on every read, or on every decrement, which for a fast-moving item means an alert per order until somebody turns notifications off — and once they are off, the one that mattered is off too.

What is wanted is an alert when the quantity crosses the threshold. That is an edge, not a state, and detecting an edge requires knowing the value before the change as well as after it.

This is only cheap because of a structural decision made much earlier: every stock decrement in the system goes through one guarded statement in one service. There is exactly one place where a quantity goes down. That place knows the old value and the new one, so the edge is available without any extra read, and the notification is emitted inside the same transaction as the decrement — if the decrement rolls back, so does the alert.

Had decrements been scattered across the order, dispatch and demonstration modules, edge detection would have meant either a periodic scan or the same logic copied three times with two of them eventually wrong. A single choke point for a change is not just about correctness of the change; it is the only place from which you can observe the change cheaply. That is a second, later payoff for a decision that was originally about avoiding oversell.

The screen that says nothing is wrong

One more thing, because it is a genuine design decision rather than a technicality.

The empty state on the low-stock screen does not say “no results”. Nothing being low is the good outcome — it is the state the whole feature exists to produce. Rendering it as a sad empty table teaches the user that the screen is broken, which is precisely the conclusion drawn the first time it was opened.

Empty is not always failure. A screen that lists problems should say so when there are none.

Rules

Decide explicitly whether absent and zero mean different things, before you choose the column type. If they do, the column is nullable and nothing downstream may coerce.

Keep the input a string until the request is built. Converting an empty numeric input to a number is a data-loss step, and it is invisible.

Render an unset value as unset. A zero nobody typed is a lie in a table.

A filter over a nullable condition has three states. True, false, and not filtering.

Give “unset” its own action, not an empty field.

Emit threshold alerts on the crossing, from the single place the value changes. If you have more than one such place, fix that first — the notification problem is a symptom.

Working on something like this?

We build this kind of software, and we staff the teams that do.

Get in touch