Sentinels and discriminated unions
Sooner or later a type needs a value that means something other than "here is your data": end of input, no match, not found, failed. There are two places to put it.
In band, as another member of the union: string | null, or string | typeof EOF. Cheap, readable, and the checker makes you handle it. Or out of band, by wrapping every value in a container that says which kind it is: { done: false, value: T } | { done: true }. More ceremony at every use site, and the only correct choice when the ordinary values could be anything.
The decision rule is short, and this page earns it the hard way: if a legitimate value could ever be indistinguishable from your marker, you must go out of band.
Create programs/sentinels.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import { assert, assertEquals, assertFalse } from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
an in-band sentinel is one more member of the union
Pick a value the domain cannot contain and add it to the union. lineReader is a factory holding private state, the pattern from state that survives between calls on the closures page, and it answers EOF once the lines run out. Read the loop closely before saving, because its guard is missing something:
Deno.test("an in-band sentinel is one more member of the union", () => {
const EOF = Symbol("EOF");
type Line = string | typeof EOF;
function lineReader(lines: string[]) {
let index = 0;
return (): Line => index < lines.length ? lines[index++] : EOF;
}
const next = lineReader(["first", "second"]);
const seen: string[] = [];
for (let i = 0; i < 4; i++) {
const line = next();
seen.push(line.toUpperCase());
}
assertEquals(seen, ["FIRST", "SECOND"]);
});
Check programs/sentinels.test.ts
TS2339 [ERROR]: Property 'toUpperCase' does not exist on type 'Line'.
Property 'toUpperCase' does not exist on type 'unique symbol'.
seen.push(line.toUpperCase());
~~~~~~~~~~~
at file:///programs/sentinels.test.ts:17:22
error: Type checking failed.
info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.
The union is doing its job from a union member is unusable until you know which on the unions and narrowing page: Line might be the symbol, and the diagnostic's second line blames exactly that member. Add the check, and notice what it is:
Deno.test("an in-band sentinel is one more member of the union", () => {
const EOF = Symbol("EOF");
type Line = string | typeof EOF;
function lineReader(lines: string[]) {
let index = 0;
return (): Line => index < lines.length ? lines[index++] : EOF;
}
const next = lineReader(["first", "second"]);
const seen: string[] = [];
for (let i = 0; i < 4; i++) {
const line = next();
if (line === EOF) break;
seen.push(line.toUpperCase());
}
assertEquals(seen, ["FIRST", "SECOND"]);
});
Check programs/sentinels.test.ts
running 1 test from ./programs/sentinels.test.ts
an in-band sentinel is one more member of the union ... ok (0ms)
ok | 1 passed | 0 failed (1ms)
The check that ends the loop is also the check that makes the value usable: after line === EOF fails, line is narrowed to string, and the toUpperCase that was refused is legal. That is not a coincidence. The union is unusable until narrowed, so the compiler enforces the very branch the protocol needed anyway.
a symbol makes the best in-band marker
Why a symbol and not "EOF" or -1? Because of everything the symbols page proved in two symbols are never the same value:
Deno.test("a symbol makes the best in-band marker", () => {
const EOF = Symbol("EOF");
type Line = string | typeof EOF;
const lookalike = Symbol("EOF");
assertFalse((lookalike as symbol) === (EOF as symbol));
// @ts-expect-error: a lookalike symbol does not satisfy the union
const pretender: Line = Symbol("EOF");
assertEquals(typeof pretender, "symbol");
});
a symbol makes the best in-band marker ... ok (0ms)
Two guarantees, one from each system.
- At run time,
lookalikeis not equal toEOF, so no value produced anywhere else can be mistaken for the marker. - At check time, the pin records that even a symbol spelled identically does not satisfy
Line, because TypeScript gives that one symbol its own type.
Compare a string marker such as "EOF", which any code can produce by accident, or -1, which is an ordinary number. Both are values the data might one day contain, which is the crack the next step splits open.
when in band breaks
Here is the failure, with null as the marker over data that legitimately contains null. The reader hands out three items of data; a well-behaved consumer stops at the first null. Predict what it collects:
Deno.test("when in band breaks", () => {
function nullReader() {
const data = ["a", null, "b"];
let index = 0;
return (): string | null =>
index < data.length ? data[index++] : null;
}
const next = nullReader();
const collected: (string | null)[] = [];
for (let i = 0; i < 5; i++) collected.push(next());
assertEquals(collected, ["a", null, "b", null, null]);
const again = nullReader();
const stopped: (string | null)[] = [];
for (let i = 0; i < 5; i++) {
const value = again();
if (value === null) break;
stopped.push(value);
}
assertEquals(stopped, ["a", "b"]);
});
Check programs/sentinels.test.ts
running 3 tests from ./programs/sentinels.test.ts
an in-band sentinel is one more member of the union ... ok (0ms)
a symbol makes the best in-band marker ... ok (0ms)
when in band breaks ... FAILED (8ms)
ERRORS
when in band breaks => ./programs/sentinels.test.ts:35:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
[
"a",
+ "b",
]
FAILURES
when in band breaks => ./programs/sentinels.test.ts:35:11
FAILED | 2 passed | 1 failed (10ms)
error: Test failed
"b" is gone. Walk the loss.
- The first loop reads raw and shows the wire:
["a", null, "b", null, null]. The second entry is data, the last two are the end marker, and they are the same value. - The consumer did exactly what the protocol told it to: stop at
null. It stopped at the datanull, kept["a"], and never saw"b". - Nothing threw, nothing warned, and two thirds of the input evaporated. Correct the prediction to
["a"], which is the honest record of the bug.
when in band breaks ... ok (0ms)
No sentinel can survive being a value the data might hold, and for a generic container there is no value it might not hold.
out of band moves the marker into the shape
Move the marker outside the value, into a discriminated union like the ones a shared literal field is a tag to switch on built, and both can be anything:
Deno.test("out of band moves the marker into the shape", () => {
type Read<T> = { done: false; value: T } | { done: true };
function wrappedReader() {
const data: (string | null)[] = ["a", null, "b"];
let index = 0;
return (): Read<string | null> =>
index < data.length
? { done: false, value: data[index++] }
: { done: true };
}
const next = wrappedReader();
const collected: (string | null)[] = [];
while (true) {
const read = next();
if (read.done) break;
collected.push(read.value);
}
assertEquals(collected, ["a", null, "b"]);
});
out of band moves the marker into the shape ... ok (1ms)
The same data, the same stop-at-the-end consumer, and this time all three items arrive, null included. null is now just a value that happens to be null, and the end of input is a different shape entirely, so the two can no longer collide. The cost is one extra layer at every read, which is exactly the price of not having to reserve a value.
the discriminant is the thing you check
The wrapper only works if the field carrying the answer is what you consult:
Deno.test("the discriminant is the thing you check", () => {
type Read<T> = { done: false; value: T } | { done: true };
const readOne = (): Read<string> => ({ done: false, value: "x" });
const read = readOne();
// @ts-expect-error: value does not exist until done says which member
assertEquals(read.value, "x");
if (!read.done) {
assertEquals(read.value, "x");
}
const known: Read<string> = { done: false, value: "y" };
assertEquals(known.value, "y");
});
the discriminant is the thing you check ... ok (0ms)
Three reads, three verdicts.
read.valuestraight off the function is pinned: the value came from a call, so the checker holds both members possible, andvalueexists on only one of them.- Inside
if (!read.done), the discriminant has answered, the union has narrowed, and the same read is legal. known.valueneeds no narrowing at all, and this detail makes the pattern look broken when you first try it in isolation: the checker watched you write the literal, so it already knows which memberknownis. The narrowing ceremony only exists for values whose member is genuinely unknown, which is to say values that came from a function.
iteration already made this decision
The language faced this exact choice and went out of band, which is the clearest argument for the pattern:
Deno.test("iteration already made this decision", () => {
const iterator = [1, 2][Symbol.iterator]();
assertEquals(iterator.next(), { value: 1, done: false });
assertEquals(iterator.next(), { value: 2, done: false });
assertEquals(iterator.next(), { value: undefined, done: true });
function* counted() {
yield "a";
return "total: 1";
}
const generator = counted();
assertEquals(generator.next(), { value: "a", done: false });
assertEquals(generator.next(), { value: "total: 1", done: true });
});
iteration already made this decision ... ok (0ms)
Two exhibits.
- Every iterator's
next()returns a small wrapper with adonediscriminant rather than a sentinel meaning finished. It has to: an iterator is generic, so any value chosen as the end marker could be an element.undefinedandnullwould fail the way the null reader failed, and a private symbol would fail in the interesting way, since aSet<symbol>can hold it. - The wrapper buys something a sentinel never could: the end result is still a result, so it can carry a value of its own.
countedis a generator, a function that yields values one at a time (its own subject, covered on the iterables and iterators page), and itsreturnvalue rides out on thedone: trueresult. There is nowhere to put that in a sentinel design.
the old in-band markers, and what each costs
JavaScript predates this thinking, and its own sentinels each carry a documented failure mode this series has already measured:
Deno.test("the old in-band markers, and what each costs", () => {
assertEquals("oak".indexOf("z"), -1);
assertEquals(Boolean(-1), true);
assert(Number.isNaN(Number("nope")));
assert(Number.isNaN(Number("nope") + 1));
assertEquals(/x/.exec("y"), null);
});
the old in-band markers, and what each costs ... ok (0ms)
One marker per line-pair.
indexOfanswers-1for not-found, and-1is truthy by the eight-value list from the truthiness page, so the obviousif (text.indexOf(x))guard is wrong in both directions. The strings page picks this trap up in asking what a string contains, along with theincludesrepair.Number("nope")answersNaN, and the numbers page's arithmetic never throws showed what happens next: it spreads through arithmetic instead of stopping, so the error surfaces far from its cause.execanswersnullfor no-match, and that is fine only because a match is always an object, so the marker genuinely cannot collide with the data. That guarantee, fromnullappears only because somebody put it there on the nothing-twice page, is one you get for free exactly once.
The lesson is not that these were mistakes. A sentinel is a promise about what the domain can never contain, and the promise gets harder to keep as the domain grows.
the same shape carries failure
Swap the discriminant's meaning from "finished?" to "worked?", and the wrapper becomes a result type:
Deno.test("the same shape carries failure", () => {
type Outcome<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function parseAmount(text: string): Outcome<number> {
const parsed = Number(text);
return Number.isNaN(parsed)
? { ok: false, error: `not a number: ${text}` }
: { ok: true, value: parsed };
}
const good = parseAmount("12");
const bad = parseAmount("nope");
assert(good.ok && good.value === 12);
assert(!bad.ok && bad.error.startsWith("not a number"));
});
the same shape carries failure ... ok (0ms)
parseAmount wraps the NaN sentinel from the previous step into an Outcome, and the difference is who has to remember. Failure is now a value the checker forces you to consider, because value and error each exist on only one member: the same trade as the whole page, more ceremony at each call and no way to forget the unhappy path. Whether that beats throwing depends on whether the failure is expected. Parsing user input fails routinely and suits a result; running out of memory does not.
For the third possibility, a value that can never occur at all, never is the marker, and the any, unknown, and never page showed how the exhaustiveness check spends it.
In practice
- Use
undefinedas the in-band marker for “not supplied” when it cannot collide with valid data. - Use a dedicated symbol when no other value must be able to reproduce the marker.
- Move the marker out of band for generic containers, iteration or streaming protocols, and any API where callers might legitimately supply the reserved value.
- Discriminate an out-of-band result with a literal field named for the question it answers, and check that field before reading
.value.