Mert Özen Further With Every Line
Frontend Sass/SCSS — #11

Writing Your Own Sass Library: Argument Validation with @debug, @warn and @error, sass:meta, sass:selector and Testing with True

Mert Özen Nov 15, 2026 13 min 10 views
Writing Your Own Sass Library: Argument Validation with @debug, @warn and @error, sass:meta, sass:selector and Testing with True

In a Sass library, validate public functions with meta.type-of and raise @error with meta.inspect, announce aging API with @warn, and test functions with True and errors and warnings from JavaScript in Vitest. We move Bookstore's shared Sass code into the @bookstore/styles package, show that Angular's hard-coded quietDeps does not silence @warn, and catch a min-width regression with a 16-test suite.

Version note: The examples in this post were tested with Dart Sass 1.108.2, True (sass-true) 10.1.0, Vitest 5.1 and Angular 22.2 (TypeScript 6.0; Sass 1.104.1 inside the Angular CLI).

Guess First

In this post the shared Sass code of Bookstore, the online bookstore used as the shared example across this site's series, moves into an npm package called @bookstore/styles. The old from() mixin in the package now raises a warning:

// old name, still works but warns: callers should move to up()
@mixin from($name) {
    @warn "from() is deprecated and will be removed in @bookstore/styles 1.0. Use up(#{meta.inspect($name)}) instead.";
    @include up($name) {
        @content;
    }
}

The package is loaded from node_modules. In the seventh post of the series we saw that Angular's build code hard-codes quietDeps: true and never shows deprecations in files that come through a load path or an importer. If a component writes @include from("sm"), does ng build show this warning?

Over the previous three posts we wrote rem(), space(), color tones, breakpoint mixins and a utility generator. They all lived in the abstracts folder of bookstore-web. In this post we turn them into a library: validating arguments, giving the caller proper errors and warnings, drawing the public surface, working with sass:meta and sass:selector, and most importantly writing tests.

The Library's Surface: Package, @forward and Private Members

The package lives in the packages/styles folder, next to the Angular application. The sass condition of the exports field in package.json tells tools that load the package with @use "@bookstore/styles" which file is the entry point:

{
  "name": "@bookstore/styles",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": {
      "sass": "./_index.scss"
    }
  },
  "files": [
    "_index.scss",
    "src"
  ],
  "scripts": {
    "test": "vitest run"
  },
  "devDependencies": {
    "sass": "1.108.2",
    "sass-true": "10.1.0",
    "vitest": "5.1.0"
  }
}
// @bookstore/styles: functions and mixins only, no CSS output
@forward "src/units";
@forward "src/colors";
@forward "src/breakpoints";
@forward "src/utilities";

The public API is exactly what _index.scss forwards. Two rules from the fourth post of the series pay off at library scale here: members starting with -, such as -size(), -next() and -query(), are not visible from outside; src/_validate.scss is not forwarded at all, so it is a module used inside the package but not exposed. $root-font-size, $space-scale and $breakpoints are defined with !default; the caller can change them with with(), and the configuration travels through the @forward chain to the actual module. I asked the compiler for the public surface:

.meta-demo {
    functions: map.keys(meta.module-functions("bs"));
    mixins: map.keys(meta.module-mixins("bs"));
    exists: meta.function-exists("rem", "bs") meta.function-exists("expect", "bs");
}
.meta-demo {
  functions: "rem", "space", "font-stack", "tone", "with-states";
  mixins: "up", "down", "between", "only", "each-breakpoint", "respond", "from", "generate-utilities";
  exists: true false;
}

Five functions and eight mixins; the private members and expect() are not on the list. This list is the answer to a library's "who is affected if I change this?" question.

Validating Arguments with @error: meta.type-of and meta.inspect

In the eighth post of the series we saw that the error message for rem("18px") was not ours but the built-in function's, and that it pointed inside the function. In the library I collected all type checks into a single function:

// package-internal: not forwarded from _index.scss
@use "sass:meta";

// returns $value if its type is $type, otherwise stops the build with a readable message
@function expect($value, $type, $caller) {
    @if meta.type-of($value) != $type {
        @error "#{$caller} expects a #{$type}, got #{meta.inspect($value)} (#{meta.type-of($value)})";
    }
    @return $value;
}
@function rem($value) {
    $value: v.expect($value, number, "rem()");
    // ...
}

@function tone($color, $lightness: 0%, $chroma: 0%) {
    $color: v.expect($color, color, "tone()");
    // ...
}
Error: 'tone() expects a color, got "#b45309" (string)'
  ╷
7 │     $color: v.expect($color, color, "tone()");
  │             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  ╵
  node_modules/@bookstore/styles/src/_colors.scss 7:13  tone()
  11/wrong-color.scss 4:12                              root stylesheet

This error is common in real life: when tokens come from a JSON file or a design tool, colors may arrive as quoted strings, and "#b45309" looks like a color on screen. meta.inspect() matters here. Interpolation drops a string's quotes and the message would say got #b45309; nobody would realize the problem is the type. meta.inspect() shows the value as it would be written in Sass:

@use "sass:meta";

$values: ("18px", 18px, null, (sm: 36rem), (Inter, system-ui), "#b45309", #b45309);

@each $value in $values {
    @debug "#{meta.type-of($value)}: #{meta.inspect($value)}";
}
11/inspect.scss:6 DEBUG: string: "18px"
11/inspect.scss:6 DEBUG: number: 18px
11/inspect.scss:6 DEBUG: null: null
11/inspect.scss:6 DEBUG: map: (sm: 36rem)
11/inspect.scss:6 DEBUG: list: Inter, system-ui
11/inspect.scss:6 DEBUG: string: "#b45309"
11/inspect.scss:6 DEBUG: color: #b45309

Putting a map straight into interpolation is an error: (sm: 36rem) isn't a valid CSS value. That is the second reason to always pass the value through meta.inspect() in error messages; otherwise you get a different error while trying to build the error message.

The real gain of validation is that it removes silent failures. If a function without validation returns null on bad input, the declaration is dropped from the output without a warning, as we saw in the ninth post of the series. In a shared library, that means the problem gets noticed far away from the person who wrote the code, on another team's page.

Deprecation with @warn, Printing Values with @debug

@warn does not stop compilation; it prints the message and the stack trace and carries on. The CLI output:

npx sass --load-path=node_modules 11/deprecated.scss
WARNING: from() is deprecated and will be removed in @bookstore/styles 1.0. Use up("md") instead.
    node_modules/@bookstore/styles/src/_breakpoints.scss 101:5  from()
    11/deprecated.scss 4:5                                      root stylesheet

The second line of the stack trace matters: the cause of the warning is not inside the package but the line that calls from(). With --quiet-deps the same command still shows the same warning. That is also the answer to the guess: the Angular build shows the @warn.

▲ [WARNING] from() is deprecated and will be removed in @bookstore/styles 1.0. Use up("sm") instead. [plugin angular-sass]
  ../../../../../packages/styles/src/_breakpoints.scss 101:5  from()
  ../../../../src/app/app.scss 18:5                           root stylesheet

The Dart Sass CHANGELOG describes quietDeps as a setting that silences "compiler warnings", the compiler's own warnings. @warn is not the compiler's message but the library author's; Angular's hard-coded quietDeps: true does not touch it. That makes @warn the most reliable way for a library to prepare its users for a change: while compiler deprecations like slash-div stay invisible in Angular, our message lands in the build output.

@debug is for seeing values while developing. I looked at the same build with one line written into a component file:

@debug "space(2) = #{space(2)}";
❯ Building...
../../../../src/app/app.scss:3 DEBUG: space(2) = 8px
✔ Building...

Angular prints @debug in the middle of the build progress, without the warning format. It does not stop a production build either, so a forgotten @debug stays in the log of every build.

Choosing Functions and Mixins with sass:meta

Since Sass 1.69.0 mixins can be passed around as values, just like functions. meta.get-mixin() takes a mixin as a value and meta.apply() calls it with arguments and a content block. I added a mixin to the package that chooses which breakpoint mixin to use through a parameter:

// picks the query mixin by name: respond(up, md), respond(between, sm, lg)
@mixin respond($kind, $args...) {
    @if not list.index((up, down, between, only), $kind) {
        @error "respond() expects up, down, between or only, got #{meta.inspect($kind)}";
    }
    @include meta.apply(meta.get-mixin($kind), $args...) {
        @content;
    }
}

On the function side the counterparts are meta.get-function() and meta.call(). I used them to give utility values in design units and pick a conversion function by name:

// utility values can be given in design units and converted by a function picked by name
$transforms: (spacing: "rem", none: null);

@function transform($value, $name) {
    $fn-name: map.get($transforms, $name);
    @if not $fn-name {
        @return $value;
    }
    @return meta.call(meta.get-function($fn-name, $module: "bs"), $value);
}

.book-card {
    padding: transform(12, spacing);
    border-width: transform(1px, none);

    @include bs.respond(between, "sm", "lg") {
        padding: transform(16, spacing);
    }

    @include bs.respond(only, "xl") {
        padding: transform(24, spacing);
    }
}
.book-card {
  padding: 0.75rem;
  border-width: 1px;
}
@media (36rem <= width < 64rem) {
  .book-card {
    padding: 1rem;
  }
}
@media (width >= 80rem) {
  .book-card {
    padding: 1.5rem;
  }
}

These tools are for libraries that choose behavior through configuration. In application code, writing bs.up("md") directly instead of bs.respond(up, "md") is both more readable and catches a wrong name (such as above) with Undefined mixin, without needing our own @error. meta.load-css(), which we used in the sixth post of the series, is in the same module; it loads a module's CSS wherever you want, for example inside an @layer.

When Do You Need sass:selector?

Most of the time you do not: &, nesting and forms like .catalog & cover most selector work. sass:selector is useful when you need to compute a selector as a value:

.selector-demo {
    append: selector.append(".book-card", "--featured", ":hover");
    nest: selector.nest(".catalog", "& .book-card", ".price");
    unify: selector.unify("button.book-card__add", ".is-loading");
    unify-none: selector.unify("button", "a");
    replace: selector.replace(".cart .price", ".price", ".price--sale");
    extend: selector.extend(".cart .price", ".price", ".total");
    parse: selector.parse(".a, .b .c");
}
.selector-demo {
  append: .book-card--featured:hover;
  nest: .catalog .book-card .price;
  unify: button.book-card__add.is-loading;
  replace: .cart .price--sale;
  extend: .cart .price, .cart .total;
  parse: .a, .b .c;
}

The unify-none line is missing: an element cannot be both a button and an a, so selector.unify() returns null. A practical example: writing a "while the block has this modifier" rule from inside a BEM element. .book-card--featured & can be written by hand, but it repeats the block name; the mixin derives it from &:

// inside an element of a BEM block: style it when the block has a modifier
@mixin when-block($modifier) {
    // & is e.g. ".book-card__title"; the block is the part before "__"
    $element: string.slice(list.nth(list.nth(&, 1), -1) + "", 2);
    $block: string.slice($element, 1, string.index($element, "__") - 1);
    @at-root #{selector.nest(".#{$block}--#{$modifier}", &)} {
        @content;
    }
}

.book-card {
    &__title {
        color: var(--color-text);

        @include when-block(featured) {
            color: var(--color-brand);
        }
    }
}
.book-card__title {
  color: var(--color-text);
}
.book-card--featured .book-card__title {
  color: var(--color-brand);
}

In Sass, & is a list of lists (each selector is a list, and so are the parts of each selector); list.nth(list.nth(&, 1), -1) takes the last part of the first selector. Before putting code this roundabout into a library, ask whether the team can read it; in Bookstore I did not add this mixin to the package.

Testing with True and Vitest

True is OddBird's Sass testing library. It has two kinds of assertion: assert-equal compares Sass values (function tests), while assert/output/expect compares the CSS a mixin produces with the expected CSS. The tests run inside a JavaScript test runner; in Bookstore that is Vitest, which Angular uses too:

cd packages/styles
npm install --save-dev --save-exact sass@1.108.2 sass-true@10.1.0 vitest@5.1.0
@use "true" as * with ($terminal-output: false);
@use "../index" as bs;

@include describe("rem()") {
    @include it("converts px to rem") {
        @include assert-equal(bs.rem(18px), 1.125rem);
    }

    @include it("treats a unitless number as px") {
        @include assert-equal(bs.rem(6), 0.375rem);
    }

    @include it("converts units that are compatible with px") {
        @include assert-equal(bs.rem(12pt), 1rem);
    }
}
@include describe("up() / down() / between()") {
    @include it("writes range syntax media queries") {
        @include assert {
            @include output {
                .book-list {
                    @include bs.up("md") {
                        gap: 24px;
                    }
                    @include bs.between("sm", "lg") {
                        padding-inline: 16px;
                    }
                }
            }
            @include expect {
                @media (width >= 48rem) {
                    .book-list {
                        gap: 24px;
                    }
                }
                @media (36rem <= width < 64rem) {
                    .book-list {
                        padding-inline: 16px;
                    }
                }
            }
        }
    }
}

$terminal-output: false turns off True's own report; Vitest reports the results. I test configuration too: config.test.scss loads the package with @use "../index" as bs with ($root-font-size: 10px, $breakpoints: ("tablet": 40rem)) and checks that rem(18px) is 1.8rem. Since each compilation loads a module with a single configuration, different configurations live in separate test files.

Because @error stops compilation, True cannot catch it inside a Sass test. True's own error() function solves that, but it requires the library to depend on True. I wrote the error and warning tests on the JavaScript side, in the same Vitest file:

import { fileURLToPath } from "node:url";
import * as sass from "sass";
import sassTrue from "sass-true";
import { describe, expect, it } from "vitest";

const root = fileURLToPath(new URL("..", import.meta.url));

// every *.test.scss file becomes a describe/it tree in Vitest
for (const name of ["units", "colors", "breakpoints", "config"]) {
    sassTrue.runSass({ describe, it, sass }, fileURLToPath(new URL(`./${name}.test.scss`, import.meta.url)));
}

// @error stops compilation, so errors and warnings are tested from JavaScript
function compile(source) {
    const warnings = [];
    const result = sass.compileString(`@use "index" as bs;\n${source}`, {
        loadPaths: [root],
        logger: { warn: (message) => warnings.push(message) },
    });
    return { css: result.css, warnings };
}

describe("errors and warnings", () => {
    it("rejects a space() step outside the scale", () => {
        expect(() => compile(".x { margin: bs.space(6); }")).toThrow("space() expects a step from 0 to 5, got 6");
    });

    it("rejects a string where rem() expects a number", () => {
        expect(() => compile('.x { font-size: bs.rem("18px"); }')).toThrow('rem() expects a number, got "18px" (string)');
    });

    it("warns when the deprecated from() is used", () => {
        const { css, warnings } = compile('.x { @include bs.from("md") { order: 1; } }');
        expect(warnings).toEqual(['from() is deprecated and will be removed in @bookstore/styles 1.0. Use up("md") instead.']);
        expect(css).toContain("@media (width >= 48rem)");
    });
});
npm test -- --reporter=verbose
 ✓ test/styles.test.js > rem() > converts px to rem 0ms
 ✓ test/styles.test.js > rem() > treats a unitless number as px 0ms
 ✓ test/styles.test.js > rem() > converts units that are compatible with px 0ms
 ✓ test/styles.test.js > space() > starts the scale at 0 0ms
...
 ✓ test/styles.test.js > up() / down() / between() > writes range syntax media queries 0ms
...
 ✓ test/styles.test.js > errors and warnings > warns when the deprecated from() is used 2ms

 Test Files  1 passed (1)
      Tests  16 passed (16)

To see what the tests are worth, I introduced a regression: I changed up() back to the old min-width form. Five tests broke and Vitest showed the difference line by line:

 FAIL  test/styles.test.js > up() / down() / between() > writes range syntax media queries
AssertionError:  [type: equal]

- Expected
+ Received

- @media (width >= 48rem) {
+ @media (min-width: 48rem) {
    .test-output .book-list {
      gap: 24px;
    }
  }

On a failing run vitest run exits with code 1; in CI, running npm ci and npm test in the package folder is enough. So that Vitest's watch mode re-runs the tests when a .scss file changes, I added forceRerunTriggers: ["**/*.scss"] to vitest.config.js; True's README recommends this as well.

In Bookstore: Wiring @bookstore/styles into the Application

bookstore-web uses the package through npm's file: protocol; npm puts a symlink at node_modules/@bookstore/styles:

npm install --save "@bookstore/styles@file:../packages/styles"

Only the application-specific parts remain in the abstracts folder: the theme tokens and the color-tokens() mixin. The other four files were deleted, and the index forwards the package:

// abstracts produce no CSS: only variables, functions and mixins
@forward "@bookstore/styles";
@forward "tokens";
@forward "theme";

None of the components changed; they all still start with @use "abstracts" as *;. After the move, the global style sheet produced by ng build kept the same name (styles-37AI4YFI.css); since Angular puts a hash of the content into the file name, the global CSS is identical byte for byte. npm run check:sass2 also finds the package through the node_modules load path and says 3 files, 0 failed.

In Production

Manage the package version and its @warns together. Before removing a mixin, raise a @warn for at least one version, say in the message what replaces it, and name the version that removes it. The Bookstore message contains all three.

Do not ship test files in the published package. The files field in package.json contains only _index.scss and src; the tests and vitest.config.js stay in the repo.

Do not confuse the package's Sass version with the application's. The package tests run on their own sass@1.108.2, while the Angular build uses 1.104.1, which comes with @angular/build. If the package uses a new Sass feature, the tests can pass while the application build breaks; running the application build in CI as well catches that difference.

Common Pitfalls

Silently returning null on bad input. If a function returns null for a wrong argument, the declaration is dropped from the output and there is no warning anywhere. Check types and ranges in the library's public functions and stop with @error.

Forgetting @debug lines. @debug does not change the CSS, but it stays in the log of every build and drowns out real warnings. In a library, use tests instead of @debug; search for temporary @debugs with grep -rn "@debug" src before committing.

Getting tripped up by how CSS is written in tests. True formats the CSS before comparing; indentation and whitespace differences do not matter. But how a value is written is compared. In a test I temporarily added to a test file, I compared the output of tone(#ffffff, 0%) with color: #ffffff:

- Expected
+ Received

  .test-output .a {
-   color: #ffffff;
+   color: white;
  }

Same color, different spelling. When you compare colors or numbers, test the value with assert-equal (assert-equal(bs.tone(#ffffff, 0%), #ffffff) passes); use assert/output only to test the structure of selectors, media queries and declarations.

Putting every helper into the library. A clever but hard-to-read mixin like when-block() becomes another team's dependency the moment it enters the package's public API. Keep the package surface small; adding something is easy, removing it is a deprecation process.

A Small Experiment

★☆☆ In packages/styles, add a "negative step is an error" test for space() on the JavaScript side and check the full error message with toThrow().

★★☆ Add validation to font-stack(): every font name it receives should be a string. Should font-stack(Georgia) (unquoted) be valid or an error? Decide by checking what meta.type-of(Georgia) returns, then write the test.

★★★ Prepare for a 1.0 release that removes from() from the package: write a small Node script that finds every from( in bookstore-web and turns it into up(. After running it, verify that no warning remains in the ng build output and that the global CSS file name has not changed.

In the last post of the series we look at how Sass is wired into the build: source maps in the CLI and in Angular, jumping to the .scss line in DevTools, sass-embedded and preprocessorOptions in Vite, --watch and --style=compressed in ASP.NET Core, and loading this post's package with the pkg: importer in your own build script.