Anonymous Functions and Threading Macros in Sass
A few years ago, I started learning ClojureScript and quickly became fascinated with functional programming. One day, I wondered whether I could bring a similar programming style to Sass, with anonymous functions and threading macros. After some experiments, I came up with the following Sass functions:
.map-function {
property: map(inc, 1, 2, 3); // (2, 3, 4)
}
.filter-function {
property: filter(odd, 1, 2, 3); // (1, 3)
}
.reduce-function {
property: reduce((plus _1 _2 _2), "#", [ "a", "b", "c" ]); // #aabbcc
}
.assoc-function {
property: assoc([1 2 3], 2, (a b)); // [1 (a b) 3]
}
.thread-last-function {
property: thread-last(1, inc, (plus 10 5), dec, ("math.pow" 2)); // 65536
}It turned out to be quite doable. It all started with Clojure’s function literal, an anonymous function written as #(println %1 %2 %3) that can be passed to iterator and threading functions. For syntax reasons, I used _ as the placeholder for a single parameter, and _1, _2 and so on for multiple parameters.
Calling a function
In this post, I will explain how to implement these functions, using reduce() as an example:
@use "sass:list";
@function plus($args...) {
// Return the sum of $args
}
.reduce-function {
property: reduce((plus _1 _2 _2), "#", [ "a", "b", "c" ]); // #aabbcc
}Here, _1 is the accumulator and _2 is the current value. The function literal is a parenthesized list, also known as an S-expression, and a Sass list can fully handle this syntax. The first step, therefore, is to write a caller that accepts a Sass list and treats its first item as the function name and the remaining items as arguments. It would look something like this:
@use "sass:meta";
@function call-fn($fn, $args...) {
@return meta.call(meta.get-function($fn), $args...);
}With this caller, call-fn(plus, 1, 2, 3) becomes plus(1, 2, 3). In call-fn(), meta.call() calls $fn with $args, and meta.get-function() turns the name into the function value that meta.call() expects.
Replacing argument placeholders
This is only the basic idea. In practice, $fn is an S-expression, and the _ placeholders inside it must be replaced with the values in $args. The next step is to turn call-fn() into a call-lambda() function, so that call-lambda((plus _1 _2), 10, 100) becomes plus(10, 100).
@use "sass:list";
@use "sass:meta";
@function first($list) {
// Return the first item in $list
}
@function rest($list) {
// Return the remaining items in $list
}
@function replace-args($args, $values) {
@for $_i from 1 through list.length($values) {
@if $_i == 1 {
$_index: list.index($args, _) or list.index($args, _1);
@while $_index != null {
$args: list.set-nth($args, $_index, list.nth($values, 1));
$_index: list.index($args, _) or list.index($args, _1);
}
} @else {
$_index: list.index($args, _#{$_i});
@while $_index != null {
$args: list.set-nth($args, $_index, list.nth($values, $_i));
$_index: list.index($args, _#{$_i});
}
}
}
@return $args;
}
@function call-lambda($lambda, $args...) {
$_fn: first($lambda);
$_lambda-args: replace-args(rest($lambda), $args);
@return meta.call(meta.get-function($_fn), $_lambda-args...);
}The replace-args() function uses a @while loop because a _ placeholder can appear more than once.
Resolving Sass module functions
There is a problem here: meta.get-function() cannot get a function from a Sass module directly. For example, meta.get-function("list.index") is invalid, and the correct call is meta.get-function("index", false, "list"). A new get-function() function therefore checks whether the function passed in belongs to a Sass module and, if it does, extracts the module name and the function name.
@use "sass:meta";
@use "sass:string";
@function get-function($fn) {
$_dot-index: string.index($fn, ".");
@if $_dot-index {
$_module: string.slice($fn, 1, $_dot-index - 1);
$_fn: string.slice($fn, $_dot-index + 1, -1);
@return meta.get-function($_fn, false, $_module);
} @else {
@return meta.get-function($fn);
}
}Dispatching named and anonymous functions
We are almost done, with only one thing left. Iterator and threading functions can take either an anonymous function or a named function. Since call-fn() and call-lambda() already exist, a new auto-call() function checks the type of the function passed in and calls call-fn() or call-lambda() accordingly:
@use "sass:meta";
@function auto-call($fn, $args...) {
$_type: meta.type-of($fn);
@if $_type == string {
@return call-fn($fn, $args...);
} @else if $_type == list {
@return call-lambda($fn, $args...);
} @else {
@error "#{$fn} is not a function. Please make sure that #{$fn} is defined as a function before calling it.";
}
}Implementing reduce
Finally, with this groundwork in place, implementing reduce() and other functions becomes much simpler. The source code has more examples.
@function reduce($fn, $init, $list...) {
$_result: $init;
@each $_i in $list {
$_result: auto-call($fn, $_result, $_i);
}
@return $_result;
}This project is published as an npm package named lambda-sass. The syntax was borrowed from Clojure, but the name was inspired by Lisp’s lambda function. I’d be delighted to see what you build with it. If you find any function missing, let me know or send a pull request.