Skip to main content

Where Sass+ evaluates differently

Sass+ Strictness covers input Sass+ refuses to parse. This page covers the smaller, more important set: input that parses in both, and evaluates to something different.

The bar for landing on this page is deliberately high. "Sass does it differently" is not enough — Sass+ follows Sass unless the Sass behaviour is defensible by history rather than by principle, meaning Sass documents what it does but has no rule that explains why. Every entry below states the principle Sass+ follows instead, and what it measured before changing.

Truthiness: empty is falsy​

Sass: everything is truthy except false and null. Sass+: falsy for exactly false, null, "" (and ''), and the empty list — () or [].

valueSassSass+
false, nullfalsyfalsy
0, 0px, 0%, "0"truthytruthy
red, transparent, rgba(0,0,0,0)truthytruthy
"a", a, nonetruthytruthy
"", ''truthyfalsy
() (empty list / map)truthyfalsy
[] (empty bracketed list)truthyfalsy

Only three rows move, and all three move in the same direction.

Why​

The principle is emptiness, not zero-ness.

0 is a real CSS value — margin: 0 means something — so it is not an absence and stays truthy. That is where JavaScript is wrong for this domain, treating both 0 and "" as falsy.

Sass gets the 0 half right, but then calls "" and () truthy as well, and has no principle that distinguishes them from null. An empty string and an empty list are absences in exactly the way null is. Sass's rule is documented, but it is a list of exceptions rather than an explanation — which is the bar described above.

Sass+ applies one rule: falsy if absent or empty. That is also why [] moves: a bracketed list is a list, so the empty one is empty. The rule is the principle, not a list of privileged spellings.

What this changes in practice​

Only a bare truth test on a value that is "" or ():

$name: "";

@if $name { /* Sass: runs. Sass+: does not run. */ }
@if $name == "" { /* identical in both — an explicit comparison */ }
@if $name != "" { /* identical in both */ }

The same applies to and / or, which return an operand rather than a boolean in both languages:

$name: "";
$label: $name or "fallback"; // Sass: "" Sass+: "fallback"

Anything written as an explicit comparison against "", () or null is unaffected, because the comparison decides, not the truthiness rule.

Measured before changing​

Bootstrap 5 (134 .scss files) was audited for reliance on this:

  • 60 bare @if $x truth tests — every one on a boolean flag ($enable-*), or on a map-get() / str-index() result, which returns null and is falsy under both rules.
  • The variables Bootstrap actually holds "" in — notably $infix — are never truth-tested. Every use is an explicit comparison: @if not ($infix == "").
  • Variables defaulted to () ($utilities, $result, $_map, $_args, $merged-maps) never appear in an @if at all.

Result: zero affected sites in Bootstrap.

The same audit was run against three more libraries — bourbon, foundation-sites and include-media — for a combined ~333 .scss files:

librarybare @if $xvariables holding "" / ()of those, bare-tested
bootstrap 56080
foundation-sites101150
bourbon140
include-media030

Zero affected sites in any of them. Real Sass code does not truth-test emptiness — it writes the comparison:

@if length($missing-dependencies) > 0 { … }   // foundation-sites
@if not ($infix == "") { … } // bootstrap

Both spellings are unaffected here, and both behave identically in Sass and Sass+. If you maintain a Sass library that does rely on "" or () being truthy in a bare @if, this is the change that affects you, and the fix is to write the comparison explicitly — which is portable to both.

Unexpressible units: same rule, different spelling​

Sass: an operation whose unit CSS cannot name is preserved as calc(…), normalized — coefficients folded into one number, units rewritten as 1<unit> factors. Sass+: the same value, preserved as calc(…) in the operands you wrote.

The rule is identical, and that is the important half: 1px * 2px is an area, CSS has no area unit, and both engines refuse to invent one. Both still do the arithmetic, so a chain that cancels back to a real unit lands there in both — (1px * 1px / 1px) is 1px and (8cats * 9dogs / 4cats) is 18dogs in Sass and Sass+ alike.

Only the bytes inside the calc(…) differ:

expressionSassSass+
1px * 2pxcalc(2px * 1px)calc(1px * 2px)
(1.4em * 14px) * 10cmcalc(196em * 1px * 1cm)calc(1.4em * 14px * 10cm)
10% * 1pxcalc(10% * 1px)calc(10% * 1px)

Why​

The principle is that a preserved expression is preserved, not rewritten.

Preservation exists here because the engine cannot name the result — it is handing the expression back to the browser. An expression handed back should be the one that was written, so the output is traceable to its source line. Sass's normalization is not wrong arithmetically, but folding 1.4em * 14px * 10cm into 196em * 1px * 1cm discards every operand the author typed, and there is no rule that says why a value the engine declined to name should nevertheless be rewritten.

The third row shows the divergence is only about rewriting: where there is nothing to normalize, the two agree byte-for-byte.

Measured before changing​

Measured on dart-sass 1.101.0 against Sass+ at c906c2f9e, the commit that landed this rule:

  • 1px * 2px, (1.4em * 14px) * 10cm, 10% * 1px — the three rows above.
  • (1px * 1px / 1px) → 1px, (8cats * 9dogs / 4cats) → 18dogs, (2px / 1px) → 2 — identical in both, so the cancelling chains are unaffected. (Sass deprecates / for division outside calc() but still computes it, and Sass's own math.div spelling gives the same answers.)

One thing Sass+ adds that Sass does not: an unexpressible result also warns (eval/unexpressible-unit), and the unitMode option chooses between preserving it (default), folding it to a dimensionally false answer, or rejecting it. Sass has no equivalent lever and does not warn.

Placeholder selectors (%name)​

Placeholder selectors work as they do in Sass: a placeholder rule emits nothing on its own and reaches output only through @extend.

%ph { color: red; }          // -> emits nothing
%ph { color: red; }
.a { @extend %ph; } // -> .a { color: red; }
%ph, .a { color: red; } // -> .a { color: red; } (only the %ph BRANCH is dropped)

In .jess the same construct is spelled \\name, because % is the modulo operator there. %name in .scss lowers to exactly that, so the two are the same selector and can extend across dialects. The spelling is deliberate: \\ is the CSS escape for a literal backslash (css-syntax-3 §4.3.7), so \\name is a well-formed identifier that no element type can match (selectors-4 §5.1) — a placeholder is inert even if it does reach output.

Two differences from dart-sass remain:

A segment-substituted extend can still print the placeholder. When a placeholder appears as one segment of a complex selector, Sass+ compacts the result with :is() and the placeholder stays in the group:

%ph .c { color: red; }
.a { @extend %ph; }
// dart-sass: .a .c { color: red; }
// Sass+: :is(\\ph, .a) .c { color: red; }

The two select the same elements — the \\ph alternative matches nothing — so this is a cosmetic difference, not a behavioural one.

A missing @extend target is not an error. dart-sass raises The target selector was not found. unless you write !optional. Sass+ accepts !optional and records it, but currently ignores a missing target for both spellings, so @extend %does-not-exist; silently does nothing rather than failing the build.

See also​