Branching
Two constructs, and they are not variations on each other.
if evaluates a condition and takes one of two paths. The condition is any expression, so if can ask anything you can compute. switch evaluates one expression and jumps to the clause whose value is strictly equal to it, and that is the only question it can ask: ranges, combinations, and anything involving two values at once are if territory, and no arrangement of switch will get you there.
What you buy in exchange is that when the question really is "which of these values is it", switch says so in a shape the checker understands well enough to prove you covered every case. That is worth reaching for. It also carries a set of sharp edges that if does not have, and most of this page is those edges.
Create programs/branching.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import { assert, assertEquals, assertThrows } from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
else if is not a construct
Two functions, one written with the familiar chain, one with the chain spelled out:
Deno.test("else if is not a construct", () => {
function label(status: string): string {
if (status === "pending") {
return "waiting for payment";
} else if (status === "shipped") {
return "on its way";
} else {
return "unknown";
}
}
function labelNested(status: string): string {
if (status === "pending") {
return "waiting for payment";
} else {
if (status === "shipped") {
return "on its way";
} else {
return "unknown";
}
}
}
for (const status of ["pending", "shipped", "lost"]) {
assertEquals(label(status), labelNested(status));
}
assertEquals(label("shipped"), "on its way");
});
Check programs/branching.test.ts
running 1 test from ./programs/branching.test.ts
else if is not a construct ... ok (0ms)
ok | 1 passed | 0 failed (2ms)
The loop proves the two functions agree on every input, because they are the same function. else if is not a keyword and not a construct: the grammar is if («cond») «statement» else «statement», and in the chain, the else's statement happens to be another if. That is why the chain has no natural end, why the language needs no elif, and why nothing has to know in advance how many branches you intend.
One more thing the condition slot will accept: anything truthy, not just booleans, with everything the truthiness page measured following from that. And when you want the form that produces a value rather than choosing a statement, that is the conditional operator runs only the branch it picks on the same page.
braces are optional, and you should write them anyway
The statement after the condition does not have to be a block. shipped is false below, so predict what done holds after both lines run:
Deno.test("braces are optional, and you should write them anyway", () => {
const done: string[] = [];
function mark(tag: string) {
done.push(tag);
}
const shipped = false;
if (shipped) mark("notify");
mark("log");
assertEquals(done, []);
});
Check programs/branching.test.ts
running 2 tests from ./programs/branching.test.ts
else if is not a construct ... ok (0ms)
braces are optional, and you should write them anyway ... FAILED (10ms)
ERRORS
braces are optional, and you should write them anyway => ./programs/branching.test.ts:33:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- [
- "log",
- ]
+ []
FAILURES
braces are optional, and you should write them anyway => ./programs/branching.test.ts:33:11
FAILED | 1 passed | 1 failed (12ms)
error: Test failed
"log" ran. One statement is one statement: mark("notify") is the whole branch, and mark("log") sits after the if, unconditional, whatever indentation might suggest. Correct the prediction to ["log"]:
braces are optional, and you should write them anyway ... ok (0ms)
Deno's formatter keeps the single-line form, so this shape does not survive long in a formatted codebase. It survives long enough to be pasted somewhere and then extended by somebody in a hurry, which is the argument for braces on every if body: two characters, and the trap cannot arise.
switch jumps to the strictly equal case
Deno.test("switch jumps to the strictly equal case", () => {
function describe(status: string): string {
switch (status) {
case "pending":
return "waiting for payment";
case "shipped":
return "on its way";
case "delivered":
return "done";
default:
return "unknown";
}
}
assertEquals(describe("shipped"), "on its way");
assertEquals(describe("frozen"), "unknown");
const visited: string[] = [];
switch ("frozen" as string) {
case "pending":
visited.push("pending");
break;
}
assertEquals(visited, []);
});
switch jumps to the strictly equal case ... ok (0ms)
Four rules, in order: evaluate the expression once; jump to the first case whose expression is strictly equal to it; run from there, continuing into the clauses below unless something stops you; and if nothing matched, jump to default, or do nothing at all when there is no default.
The second half of the step is that last rule happening. No case matched "frozen", there is no default, and visited stayed empty: a switch that did nothing, looking exactly like a switch that ran. The as string widening is there because the checker would otherwise refuse the comparison outright, which is the next step's subject.
the comparison is ===, at the boundary too
Deno.test("the comparison is ===, at the boundary too", () => {
const fromOutside: unknown = "1";
const taken: string[] = [];
switch (fromOutside) {
case 1:
taken.push("matched");
break;
default:
taken.push("default");
}
assertEquals(taken, ["default"]);
assert(fromOutside == 1);
const count: number = 2;
switch (count) {
// @ts-expect-error: a string case can never match a number
case "2":
break;
}
});
the comparison is ===, at the boundary too ... ok (0ms)
Three facts stacked here.
- A
switchcompares the way===does, with no conversion, by the rules of the equality page."1"arrived as text, thecasesays the number1, and they are not strictly equal, sodefaultruns. - The
==assertion shows how near the miss was: loose equality would have matched. And the miss is quiet, becausedefaultcatches it, and adefaultlooks like a decision rather than an accident. - The pinned
case "2"records the checker's side: when it can see both types,TS2678: Type 'string' is not comparable to type 'number'stops the mismatch before it runs.
So this trap only bites when the switch expression's type is loose, which is precisely when the value came from outside the program. Inside a well-typed function the checker has you covered; at the boundary it does not, and the boundary is where the parsing belonged anyway.
falling through is the default
Type this version exactly, with no break anywhere, and predict translate("hello"):
Deno.test("falling through is the default", () => {
function translate(word: string): string {
let result = "";
switch (word) {
case "hello":
result = "bonjour";
case "goodbye":
result = "au revoir";
}
return result;
}
assertEquals(translate("hello"), "bonjour");
});
Check programs/branching.test.ts
running 5 tests from ./programs/branching.test.ts
...
falling through is the default ... FAILED (12ms)
ERRORS
falling through is the default => ./programs/branching.test.ts:94:11
error: AssertionError: Values are not equal.
[Diff] Actual / Expected
- au revoir
+ bonjour
FAILURES
falling through is the default => ./programs/branching.test.ts:94:11
FAILED | 4 passed | 1 failed (16ms)
error: Test failed
"au revoir", for "hello". Control jumped into the "hello" clause, assigned, and then walked straight into the clause below and assigned again, because a case is a jump label rather than a block. Every input to this function produces "au revoir" or nothing at all.
The checker had nothing to say; the file type-checked cleanly on the way to that failure, because assigning to the same variable twice in a row is perfectly consistent. What catches it is the linter, and no-fallthrough is in Deno's recommended set, so deno lint on the file as it stands right now reports:
error[no-fallthrough]: Fallthrough is not allowed
--> programs/branching.test.ts:98:9
|
> 98 | case "hello":
| ^^^^^^^^^^^^^
> 99 | result = "bonjour";
| ^^^^^^^^^^^^^^^^^^^
= hint: Add `break` or comment `/* falls through */` to your case statement
docs: https://docs.deno.com/lint/rules/no-fallthrough
Note what the hint offers: a comment spelled exactly /* falls through */ tells the linter you meant it, which is the rule admitting that deliberate fallthrough is a real thing. If you would rather the checker owned this, noFallthroughCasesInSwitch in compilerOptions promotes it to TS7029; it is off by default in Deno, as in tsc. The division of labor is the one the assignment page's assignment is an expression drew for if (x = 1): the checker asks whether the program is internally consistent, and the linter asks whether you meant it.
Add the breaks and the predictions hold:
Deno.test("falling through is the default", () => {
function translate(word: string): string {
let result = "";
switch (word) {
case "hello":
result = "bonjour";
break;
case "goodbye":
result = "au revoir";
break;
}
return result;
}
assertEquals(translate("hello"), "bonjour");
assertEquals(translate("goodbye"), "au revoir");
assertEquals(translate("hi"), "");
});
falling through is the default ... ok (0ms)
an empty clause is the same rule, used on purpose
Deno.test("an empty clause is the same rule, used on purpose", () => {
function isWeekend(day: string): boolean {
switch (day) {
case "saturday":
case "sunday":
return true;
default:
return false;
}
}
assertEquals(isWeekend("saturday"), true);
assertEquals(isWeekend("sunday"), true);
assertEquals(isWeekend("monday"), false);
});
an empty clause is the same rule, used on purpose ... ok (0ms)
A clause with no statements falls through to the next one, which is how "saturday" and "sunday" come to share one body, and the linter raises no objection because there is nothing half-executed to fall out of. The feature and the previous step's bug are one rule seen from two sides. That is why "always write break" is weaker advice than it sounds, and "return from every clause" is the advice that actually removes the problem.
the body of a switch is a single scope
The clauses look like blocks. Declare the same name in two of them and save:
Deno.test("the body of a switch is a single scope", () => {
function run(args: string[]): string {
switch (args[0]) {
case "once":
const text = args[1];
return text;
case "repeat":
const text = args[1];
return text.repeat(2);
default:
return "";
}
}
assertEquals(run(["once", "ab"]), "ab");
});
Check programs/branching.test.ts
TS2451 [ERROR]: Cannot redeclare block-scoped variable 'text'.
const text = args[1];
~~~~
at file:///programs/branching.test.ts:132:17
TS2451 [ERROR]: Cannot redeclare block-scoped variable 'text'.
const text = args[1];
~~~~
at file:///programs/branching.test.ts:135:17
TS2454 [ERROR]: Variable 'text' is used before being assigned.
return text.repeat(2);
~~~~
at file:///programs/branching.test.ts:136:18
Found 3 errors.
error: Type checking failed.
The first two errors are the collision: two declarations of text in what turns out to be one scope, the whole switch body. The third is the consequence, and it is the one worth slowing down for. Inside the "repeat" clause, text is the same single binding, and control can jump there without ever running the line that assigns it: the declaration is visible everywhere in the body, and only the assignment is where you wrote it. That is TS2454, the same used-before-assigned diagnostic the scope and declarations page met in the temporal dead zone.
None of that is a TypeScript rule. Strip the types and the JavaScript refuses on its own terms, which the fixed step proves by parsing the same shape at run time:
Deno.test("the body of a switch is a single scope", () => {
const source =
'switch ("x") { case "a": const t = 1; break; case "b": const t = 2; break; }';
const error = assertThrows(() => new Function(source), SyntaxError);
assert(String(error).includes("Identifier 't' has already been declared"));
function runScoped(args: string[]): string {
switch (args[0]) {
case "once": {
const text = args[1];
return text;
}
case "repeat": {
const text = args[1];
return text.repeat(2);
}
default:
return "";
}
}
assertEquals(runScoped(["once", "ab"]), "ab");
assertEquals(runScoped(["repeat", "ab"]), "abab");
});
the body of a switch is a single scope ... ok (0ms)
new Function hands the source to the JavaScript parser at run time, and the parser throws SyntaxError: Identifier 't' has already been declared before a line of it runs: const means one declaration per scope, from the scope page's the same name twice does not even parse, and a switch body is one scope. The fix is one pair of braces per clause, which turns each clause into the block it already looked like, and runScoped shows both clauses holding their own text in peace.
making the checker prove you covered every case
The never trick from the unions-and-narrowing page's never turns a forgotten case into a compile error is built for exactly this construct. Write it, then grow the union and watch it pay out:
Deno.test("making the checker prove you covered every case", () => {
type Status = "pending" | "shipped" | "delivered" | "returned";
function nextStep(status: Status): string {
switch (status) {
case "pending":
return "take payment";
case "shipped":
return "wait";
case "delivered":
return "archive";
default: {
const unreachable: never = status;
return unreachable;
}
}
}
assertEquals(nextStep("shipped"), "wait");
});
Check programs/branching.test.ts
TS2322 [ERROR]: Type '"returned"' is not assignable to type 'never'.
const unreachable: never = status;
~~~~~~~~~~~
at file:///programs/branching.test.ts:165:17
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 complaint names the member you forgot, which is the entire value of the pattern: with three cases handled, status inside default has narrowed to exactly "returned", and that is not nothing, so it will not fit in never. Remove "returned" from the union, or add its case, and the file checks again. The final version keeps the three-member union next to the grown one, to record what happens without the guard:
Deno.test("making the checker prove you covered every case", () => {
type Status = "pending" | "shipped" | "delivered";
function nextStep(status: Status): string {
switch (status) {
case "pending":
return "take payment";
case "shipped":
return "wait";
case "delivered":
return "archive";
default: {
const unreachable: never = status;
return unreachable;
}
}
}
assertEquals(nextStep("shipped"), "wait");
type GrownStatus = Status | "returned";
function nextStepStale(status: GrownStatus): string {
switch (status) {
case "pending":
return "take payment";
case "shipped":
return "wait";
case "delivered":
return "archive";
default:
return status;
}
}
assertEquals(nextStepStale("returned"), "returned");
});
making the checker prove you covered every case ... ok (0ms)
nextStepStale is the counterfactual: same grown union, no never guard, and it compiles without a murmur. The last assertion is the quiet damage: it returns the status itself, "returned", to a caller expecting an instruction, and nothing anywhere objects. The any, unknown, and never page's nothing is assignable to never is why the guard version cannot fail this way.
a switch on a discriminant narrows in every clause
Deno.test("a switch on a discriminant narrows in every clause", () => {
type Result =
| { kind: "ok"; value: number }
| { kind: "failed"; error: string };
function show(result: Result): string {
switch (result.kind) {
case "ok":
return `got ${result.value}`;
case "failed":
return `failed: ${result.error}`;
}
}
assertEquals(show({ kind: "ok", value: 10 }), "got 10");
assertEquals(show({ kind: "failed", error: "no" }), "failed: no");
const result: Result = { kind: "ok", value: 10 };
switch (result.kind) {
case "ok":
// @ts-expect-error: error exists only on the failed member
assertEquals(result.error, undefined);
break;
}
});
a switch on a discriminant narrows in every clause ... ok (0ms)
Look at what show does not contain: no default, and no return after the switch, and the checker raises no missing-return complaint, because it can see the two clauses cover the union. Inside each clause the whole object is narrowed to that one member, so .value and .error are each reachable exactly where they exist, and the pinned read records the refusal everywhere else, with the runtime line underneath confirming what the property actually holds there: undefined.
This is the payoff, and the reason to prefer a switch over a chain of ifs when the question genuinely is "which member is this". The sentinels page is about designing the union; a shared literal field is a tag to switch on is about how far the narrowing goes.
when a switch only maps values, use an object
Deno.test("when a switch only maps values, use an object", () => {
type Status = "pending" | "shipped" | "delivered";
const LABELS: Record<Status, string> = {
pending: "waiting for payment",
shipped: "on its way",
delivered: "done",
};
assertEquals(LABELS.shipped, "on its way");
// @ts-expect-error: a missing key fails at the definition
const partial: Record<Status, string> = { pending: "a", shipped: "b" };
assertEquals(partial.delivered, undefined);
});
when a switch only maps values, use an object ... ok (0ms)
When every clause would just return a constant, the switch is a lookup table wearing control flow. A Record keyed by the union is exhaustive by construction: no never trick, no default clause, and the pinned partial shows the failure mode, Property 'delivered' is missing, arriving at the definition rather than inside a branch nobody runs. That is the same guarantee the unions page built in the tags are a type you can derive, and it is stronger here for the same reason: the check moves to where the data is written.
The runtime line under the pin is the price of ignoring it: partial.delivered is undefined, the silent absence from the nothing-twice page, flowing into whatever asked for a label.
In practice
- Return from each
switchclause so fallthrough cannot happen. When a clause must assign instead, give it braces and abreak. - Give every
ifbody andswitchclause braces to avoid the single-scope trap. - Use a
Recordkeyed by the union when a switch would only map one value to another. - Use a discriminated union and an exhaustive
switchwhen the branches genuinely differ. - Throw in the default clause when a value should be impossible.
Related
- Errors and exceptions covers how a thrown value travels.