CoffeeScript ?. and Compilation Targets: From Verbose Guards to Native Optional Chaining
CoffeeScript's existential operator compiles to native optional chaining only when the build target is ES2020 or newer. Learn how to configure targets and avoid common pitfalls.
20 Jul 2025, 05:23 UTC

If your CoffeeScript build produces JavaScript full of && and void 0 where you expected clean optional chaining, the cause is usually the compiler target rather than a bug in your source. CoffeeScript 2.x rewrites the existential operator (?.) based on the ECMAScript version you select. When the target is ES2020 or later, the compiler emits native optional chaining. When the target is older—including the CoffeeScript 2 default of ES2017—it expands the operator into verbose guard expressions. The practical decision is therefore a build-configuration choice: set --target es2020 (or higher) to get concise, native output that matches JavaScript semantics exactly.
How the target changes the emitted code
The existential operator is a source-level convenience. The compiler decides how to represent it at build time. For a chain like user?.address?.city, an ES2020 target preserves the optional-chaining syntax in the output. An older target cannot assume the runtime supports ?., so the compiler generates equivalent logic using conditional checks and void 0.
The same applies to indexed access and safe calls. The expression items?.[0] compiles to items?.[0] under modern targets, and to guarded access under legacy targets. The postfix call form func?() compiles to func?.() when targeting ES2020+, but becomes a typeof guard such as typeof func === 'function' && func() under older targets.
A worked configuration
You can inspect the difference without changing your project. Run the following in your project directory with normal user permissions:
# Compile a one-liner targeting ES2020
coffee --compile --target es2020 -e "console.log user?.address?.city"
Expected check: the emitted JavaScript contains user?.address?.city (or an equivalent native chain) rather than guard expressions.
Now run the same snippet against an older target:
# Compile the same one-liner targeting ES2017
coffee --compile --target es2017 -e "console.log user?.address?.city"
Expected check: the output expands into nested guards, for example patterns containing != null and void 0. The exact shape depends on the CoffeeScript 2.x point release, but the presence of guard logic instead of a native chain confirms the target is controlling the output.
To make this permanent, set the target in your build script. In package.json:
{
"scripts": {
"build": "coffee --compile --target es2020 --output lib src"
}
}
Replace lib and src with your actual output and source directories. If you use a task runner or bundler plugin, pass the same target option there so every package in the pipeline agrees.
Runtime semantics: null and undefined only
A common misunderstanding is treating ?. as a truthiness guard. It is not. The operator short-circuits only when the left-hand value is null or undefined. Values such as 0, empty string, false, and NaN do not stop evaluation. This matches JavaScript optional chaining precisely, but it differs from CoffeeScript's older if x? soak pattern when used loosely in conditional logic.
Limits and common mistakes
You cannot assign through an optional chain
Writing obj?.prop = value is a syntax error in CoffeeScript, mirroring the JavaScript restriction on optional chaining assignment targets. Use a temporary reference and an explicit check instead:
ref = obj?.prop
ref = value if ref?
Postfix calls require parentheses
The safe-call form must include parentheses, even with zero arguments. func?() is a guarded call. func? without parentheses is parsed as property access, which will not invoke the function and may produce unexpected undefined results.
Source maps degrade under legacy targets
When the compiler expands ?. into multi-line guard expressions, a single line of CoffeeScript can map to many lines of generated JavaScript. Browser debuggers and stack traces become harder to follow. If you rely on source maps for production debugging, the ES2020 target is not just a bundle-size optimization; it also keeps the mapping closer to one-to-one.
Monorepo target drift causes inconsistent behavior
If some packages compile with --target es2020 and others with es5, the same ?. source produces different runtime code. In environments without native optional chaining support or a suitable polyfill, the ES5 output may behave differently from the ES2020 output for the same input. Standardize the target across the repository.
TypeScript declaration files need review
If you generate .d.ts files from compiled CoffeeScript output for downstream TypeScript consumers, verify that optional properties are annotated as intended. The compiled JavaScript retains the optional chain, but automatic declaration generation does not always infer optionality the way a TypeScript author would expect. Unlike TypeScript's non-null assertion operator, CoffeeScript's ?. does not suppress strict-null checks in downstream tooling.
How to verify the result
- Diff the output. Compile a representative file with
--target es2020and again with--target es2017. Confirm that the modern output uses?.and the legacy output uses guard expressions. - Add a behavioral test. In your test suite, pass
nullandundefinedthrough a?.chain and assert the result isundefinedwithout throwing. This catches accidental truthiness assumptions. - Check source-map density. For a file heavy with
?., compare the line-count ratio between the source and the generated.js.mapunder both targets. A large ratio under legacy targets indicates degraded debugging. - Inspect declarations. If you run
tsc --declaration --allowJson compiled output, open the generated.d.tsand confirm optional property types match your source intent.
These steps assume CoffeeScript 2.x. CoffeeScript 1.x does not include the existential operator; legacy 1.x codebases must upgrade to 2.x or use an unmaintained backport plugin. Because compiler defaults and exact output shapes can vary across point releases, verify against the specific CoffeeScript version pinned in your project before relying on the emitted syntax.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.