CoffeeScript's Existential and Soak Operators: What They Compile To and When They Bite
CoffeeScript's ? and ?. operators both handle missing values but compile to different JavaScript. Learn what each emits, why 0 and false survive the check, and how to verify it yourself.
24 May 2026, 09:07 UTC

If you maintain a CoffeeScript codebase, the two operators you'll misread most often are ? (existential) and ?. (soak). They look similar, they both deal with "missing" values, and they compile to very different JavaScript. The short version: a ? b means "use a unless it's null or undefined", while a?.b means "access b only if a exists". Neither one treats 0, false, or "" as missing — and that distinction is where most bugs live.
The existential operator: a null/undefined check, nothing more
CoffeeScript's ? is a compile-time construct. It never exists at runtime. When you write:
if config?
start(config)
else
start(defaults)
the compiler emits roughly:
if (typeof config !== "undefined" && config !== null) {
start(config);
} else {
start(defaults);
}
Two things fall out of that expansion. First, config? is safe even when config was never declared — the typeof guard prevents a ReferenceError, which a bare config !== null would throw. Second, only null and undefined count as "missing". If config is 0, false, or an empty string, the existential check passes.
The binary form a ? b is a default-value idiom:
port = options.port ? 8080
compiles to a conditional expression equivalent to options.port != null ? options.port : 8080. This is not the same as JavaScript's ||. With options.port || 8080, a port of 0 would be discarded as falsy; with ?, 0 is kept. That is usually what you want for numeric or boolean settings — and a real behavioral difference if you're transliterating CoffeeScript to modern JS.
The soak operator: conditional property access
The soak operator ?. guards a single access in a chain:
name = user?.profile?.name
Each ?. compiles to a check like user == null ? undefined : user.profile, so the whole expression short-circuits to undefined the moment any guarded link is null or undefined. Note the loose == null in the output — in JavaScript that matches both null and undefined, which is exactly the intended semantics.
You can also soak function calls: callback?() invokes callback only if it exists. This is handy for optional hooks, but remember it only checks existence — it does not verify the value is actually callable.
Worked example: reading both operators apart
Consider this realistic fragment from an old Express-style app:
port = process.env.PORT ? settings?.port ? 8080
Reading left to right: if process.env.PORT is null/undefined, fall back to settings?.port (which itself is undefined if settings is missing), and if that is missing, use 8080. The compiled JavaScript nests the conditionals in the same order. If you ever port this to modern JavaScript, the faithful translation uses the nullish coalescing operator and optional chaining:
const port = process.env.PORT ?? settings?.port ?? 8080;
That translation works because JS ?? and ?. have the same null/undefined-only semantics as CoffeeScript's ? and ?.. Do not translate a ? b to a || b — that changes behavior for falsy-but-present values.
Common mistakes
- Confusing the two operators.
a ? brequiresato be non-null to pick it;a?.baccessesbonly ifais non-null. Swapping them changes meaning entirely. - Expecting falsy filtering.
count ? 10keepscount = 0. If you actually want to reject0, write an explicit check. - Assuming
?.is JavaScript optional chaining. The syntax matches, but if you're reading mixed CoffeeScript/JS code, remember CoffeeScript compiles it away; the runtime semantics come from the emitted conditional, not the JS engine. - Soaking too late.
user.profile?.namestill throws ifuseritself is missing. Guard the earliest link that can be absent.
Limits and context
CoffeeScript 2.x compiles to ES2015+ and is in maintenance mode: it still builds and works, but no new language features are coming, and modern JavaScript now covers nearly everything these operators provided (??, ?., arrow functions). For new code, prefer TypeScript or plain modern JS; treat CoffeeScript knowledge as maintenance tooling for existing codebases.
Two adjacent CoffeeScript behaviors also surprise people during maintenance. Functions return their last expression implicitly, so a callback whose final line is an assignment or a loop can leak a value you never meant to return — add an explicit return (or a trailing undefined) where that matters. And => compiles to a bound/arrow function capturing the enclosing this, while -> is a regular function; passing a => method as a callback freezes this, which breaks code expecting dynamic binding.
How to verify behavior yourself
Don't take any compiler's output on faith. Install the compiler (npm install -g coffeescript, requires Node.js) and inspect what it emits:
# Run anywhere; writes compiled JS to stdout
coffee -c -p snippet.coffee
Compile a snippet like x = if a? then b else c and confirm the typeof/!== null guard appears. Then test the falsy edge case in the REPL (coffee with no arguments): evaluate 0 ? "fallback" and confirm it returns 0, not "fallback". Finally, compile both -> and => versions of a small function and compare how this is handled in the output. These checks take minutes and pin down the exact semantics for the compiler version your project uses.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.