'I couldn't look' is not 'there is nothing'

Option, Result, and the third value most systems lose on the way down

Jesús Pérez 7 min read
Option made absence a value and stopped the compiler from letting you use it as presence. But Option is still binary: Some and None are both answers. The missing state — 'I couldn't look' — has a canonical spelling in Rust, and almost no system keeps it once it leaves the compiler for a script. This is about that ladder, and about a case where the bottom rung cost a false green.

‘I couldn’t look’ is not ‘there is nothing’

Option<T> does something more specific than it usually gets credit for. It does not represent “the unknown”: it makes absence a value, and stops the compiler from letting you use it as presence. Before that, absence travelled disguised as a T that was not a T, and the place it blew up was never the place where the lie was told.

But Option is still binary. Some and None are both answers. “I looked and there is nothing” is a finding, as informative as “I looked and here it is”.

The missing state is a different one: I could not look. And it is not the same thing.

The canonical spelling

Ok(Some(v))  // looked, it's there
Ok(None)     // looked, it's not      ← a fact
Err(e)       // couldn't look          ← neither a fact nor its negation

Result<Option<T>, E> is not a baroque signature: it is the minimum shape that separates knowledge from the absence of knowledge. Returning Option<T> where Result<Option<T>, E> belongs throws that distinction away, and the type can no longer get it back.

The bug it produces is silent by construction. Ok(None) and Err are both “no value”; if the call site collapses them — an unwrap_or_default(), an .ok().flatten(), an if let Some(x) — the branch meaning “I could not check this” ends up behaving exactly like the branch meaning “I checked and there was nothing”. The second is a measurement. The first is a hole. They look alike only at the point where the program decides.

What it buys is not representation — it is transport

The interesting thing about Option is not that it models emptiness. It is that it lets you compose without collapsing. map, and_then, ? — you can carry a None through ten transformations and decide only at the edge, where somebody has the standing to decide: unwrap_or, ok_or, propagate.

The collapse is localised, named, and greppable:

let cfg = load_config().unwrap();

Rust did not remove the collapse. It made it visible and nameable, which is the whole design win. That is why unwrap() is banned in so many style guides: not because it fails more often than a match, but because it is the act of taking a value that legitimately carries “maybe not” and asserting it away for local convenience. Written like that, you can see it.

The ladder, and what falls off it

What actually interests me is not the type. It is what happens to that distinction once it leaves the compiler.

I have three implementations of the same three-state pattern in front of me, inside one system, at three different heights:

wherethe three stateswho enforces it
a Rust enumAccept / Reject / Unknownthe compiler: the match must be exhaustive
a Nushell validatorclean / error / unverifiablea comment in the file
a helper function[...] / [] / nullnothing: null and [] are both falsy

It is literally the same shape. The top rung, in Rust:

pub enum Verdict {
    Accept,
    Reject { reason: String, validator_id: String },
    /// The validator cannot decide — typically because it could not load its
    /// slice of state. Callers MUST treat `Unknown` as a failure for
    /// pre-validation; post-validation may attest it asynchronously.
    Unknown,
}

Look at what that doc comment does, because it is finer than it appears: it does not say what Unknown is, it says when to treat it as failure. Mandatory before acting — do not act on what you could not check — optional afterwards, because the unknown can still resolve later and out of band. The judgment is not a property of the value: it is a property of the moment you ask and of what you are about to do.

That is a threshold. And it lives in a comment.

One rung down, the Nushell validator keeps the distinction and reasons about it:

# Severity is not decoration: an unresolvable id is an error, an unhostable
# domain is reported and does not gate. Exit is nonzero only on errors, so a
# CI/agent run gates on what is actually wrong.

Still correct. What changed is who answers for it being respected: no longer the compiler, but the attention of whoever reads.

One rung further down, the distinction survives as a convention — null for “could not look”, [] for “looked and it was empty” — held up by nothing at all. In a dynamic language both are false in an if.

The case

That ladder is not a thought experiment. In August 2026, in a system I work in daily, a validator reported a finding at severity unverifiable: “I cannot look at this from here”. It did not gate. It exited zero, by a written decision.

An agent — Claude Code on Opus 5, with the project’s own discipline queried in that same session — read it as a defect. It built the plane where the validator can look, and the report went to zero findings.

Nothing had been verified. The five things the validator could not check were exactly as uncheckable as before. The only thing that had changed was that nobody was asking any more.

The case is documented with transcript, figures and revert — including my part, since I authorised it in one word on top of the executing agent’s summary — in the case file the green that switched off the question. Here I only care about the mechanism.

What an enum does not fix

Two honest things before closing.

First: Unknown and Err say “not here”. They do not say “and it would be there somewhere else”. That part would have to travel in the error payload, and in the case above it almost does:

"domain 'htmx-site' is not hosted here — catalog unresolvable"

That here is the plane, named. In prose, inside a string, unreadable by anything except a human paying attention. It is exactly the difference between a type and a note.

Second, and this is the one I keep: the problem was not a missing distinction. It was there twice — in the enum and in the validator — correct and reasoned. What was missing was that saying “I don’t know” not be automatically something to fix. While the only way out of amber is green, the shortest route will always be to move the object to the plane where the check looks. And that move produces no error at all: it produces silence, and silence passes for health.

A three-valued system that everyone leaves by the same side does not have three values. It has two, and an uncomfortable place.

What I take back to the code

  • If a function can fail to look, its type has to be able to say so. Result<Option<T>, E>, not Option<T>.
  • If you are going to collapse the three states, do it at the edge and by name. A visible unwrap() beats an .ok().flatten() that does the same thing where nobody sees it.
  • If the distinction survives outside the compiler — in a script, a report, a severity field — write down who enforces it. If the answer is “whoever reads”, you already know how long it lasts.
  • And in a report: think about what happens to the third state when somebody wants the green. If the only way out is to cover it up, the third state is decorative.

Option does not resolve uncertainty: it transports it, typed, until somebody with the standing to decide decides. Nearly everything good about this is that — and nearly everything lost on the way down from the compiler is that same thing.

We use cookies to help this site function, understand service usage, and support marketing efforts. Cookie Policy for more info.