Skip to main content

Control Flow Narrowing

Nudo narrows types when it can decide a condition for the concrete argument of a call site. Each call@L… => … line in the output reports the result of one call, evaluated with that call's exact argument — branches eliminated by narrowing never contribute to that case's result, and Observed: is the union of all per-call results.

Narrowing is precise on the call-site path (functions called at the top level, reported as call@ cases) and on @nudo:case directives with concrete arguments. Symbolic arguments (number(), union(...)) cannot decide a condition, so their branches join instead of narrowing. Every output block below is a real nudo infer run of the code above it.

Comparison Guards​

A comparison against a literal narrows the argument per call: each concrete call takes only the branch that matches.

function pickAdult(age) {
if (age >= 18) return age;
return -1;
}
pickAdult(25);
pickAdult(12);
=== pickAdult ===

call@L5: (25) => 25
call@L6: (12) => -1

Observed: 25 | -1

pickAdult(25) satisfies age >= 18 and returns 25; pickAdult(12) falls through to -1. The combined type keeps both literal results.

Discriminated Object Shapes​

When you compare a property against a string literal (shape.kind === "circle"), the branch for a matching call sees the object shape of that call's argument.

function area(shape) {
if (shape.kind === "circle") {
return shape.radius * 3.14159;
}
return shape.side * shape.side;
}
area({ kind: "circle", radius: 2 });
area({ kind: "square", side: 3 });
=== area ===

call@L7: ({ kind: "circle", radius: 2 }) => 6.28318
call@L8: ({ kind: "square", side: 3 }) => 9

Observed: 6.28318 | 9

The circle call takes the if branch and computes 6.28318; the square call falls through to side * side and yields 9.

typeof and Array.isArray() Guards​

Both guards fork per concrete call, and the narrowed value keeps its precise behavior in the matching branch.

function len(x) {
if (typeof x === "string") return x.length;
if (Array.isArray(x)) return x.length;
return -1;
}
len("abc");
len([1, 2]);
len(5);
=== len ===

call@L7: ("abc") => 3
call@L8: ([1, 2]) => 2
call@L9: (5) => -1

Observed: 3 | 2 | -1

The string call reaches x.length on a narrowed string (3), the array call on a narrowed array (2), and the number call falls through both guards to -1. The narrowed branch keeps the value itself: indexing a narrowed array (x[0]) resolves to its element type — a literal for a literal array, the element type for an abstract array — just like .length does.

Switch Statements​

A switch on a discriminant narrows per case clause — including for @nudo:case directive inputs.

/**
* @nudo:case "idle" ({ status: "idle" })
* @nudo:case "loading" ({ status: "loading", requestId: "abc" })
* @nudo:case "success" ({ status: "success", data: { name: "test" } })
* @nudo:case "error" ({ status: "error", message: "fail" })
*/
function handleState(state) {
switch (state.status) {
case "idle": return "Waiting...";
case "loading": return `Loading ${state.requestId}...`;
case "success": return state.data.name;
case "error": return state.message;
}
}
=== handleState ===

debug "idle": ({ status: "idle" }) => "Waiting..."
debug "loading": ({ status: "loading", requestId: "abc" }) => "Loading abc..."
debug "success": ({ status: "success", data: { name: "test" } }) => "test"
debug "error": ({ status: "error", message: "fail" }) => "fail"

Observed: "Waiting..." | "Loading abc..." | "test" | "fail"

Each clause receives its matching object shape, so state.requestId and state.data.name resolve inside their branches.

Not Narrowed Yet​

These patterns currently do not fork on the call-site path — each one degrades to a single branch or to unknown, so guard against them explicitly or verify with nudo infer before relying on them:

PatternCurrent behavior
Ternary with an unknown conditionflag ? "a" : "b" with a symbolic condition joins both branches (string). Definite conditions fork precisely on both paths — pick(true) → "a", x === 5 ? "five" : "other" with 5 → "five" — so no if-guard workaround is needed anymore.
Symbolic inputs@nudo:case with symbolic arguments (number(), union(...)) do not fork conditions — the branches join; concrete arguments narrow on both paths.
in operatorif ("toJSON" in value) narrows for object arguments, but method results widen (string instead of the closure's "serialized"); non-object arguments also report nudo:no-method.
?. / ??Shallow config.port ?? 3000 with a known property yields number; deep chains and short-circuiting members degrade to unknown.

Summary​

PatternNarrows per call siteExample
Comparison guardYesif (age >= 18) → 25 / -1
Discriminated objectYesif (shape.kind === "circle") → 6.28318 / 9
typeofYestypeof x === "string" → 3
Array.isArray()YesArray.isArray(x) → 2
switchYes (including directive inputs)per-clause literals
TruthinessYes (literal args)truthy(42) → "yes", truthy(0) → "no"; undefined/symbolic args join branches
Ternary conditionsYes (definite conditions)pick(true) → "a"; unknown condition joins branches
inPartialforks, member results widen
?. / ??Partialshallow ?? only