(Posts)

Implement Functional Programming in Sass

Jul 12, 2023[ Sass , Clojure ]
https://www.npmjs.com/package/lambda-sass

A few years ago, I started learning the ClojureScript language and quickly became fascinated with functional programming. One day, I wondered if it would be possible to implement a similar programming style in Sass, allowing for anonymous functions and threading macros. After conducting 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 turns out to be quite doable. The magic began with the Clojure Function Literal style anonymous function, #(println %1 %2 %3), which can be passed to iterator functions and threading functions. For syntax reasons, I used _ as the placeholder for a single parameter or _1, _2, and so on for multiple parameters.

In this post, I will explain how to accomplish this. Let’s take the reduce() function 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
}

In this case, _1 represents the accumulator, and _2 represents the current value. The function literal is a parentheses list, also known as the S-expression, and the Sass list is fully capable of handling this syntax. Therefore, the first step is to create a caller that accepts a Sass list, takes the first item in the list as the function name, and the rest 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...);
}

As such, call-fn(plus, 1, 2, 3) is converted to plus(1, 2, 3). The meta.call() is used to invoke the $fn with $args, and the meta.get-function() ensures that the passed-in function is callable.

This is just a basic concept. In the actual case, $fn would be an S-expression, and we need to replace the _ placeholder inside with the values in $args. So let’s update the call-fn() to a call-lambda() function that can convert call-lambda((plus, _1, _2), 10, 100) to plus(10, 100).

@use "sass:list";
@use "sass:meta";

@function first($list) {
  // Return the first $list
}
@function rest($list) {
  // Return the rest 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...);
}

In the replace-args() function, use a @while loop to support cases where the _ placeholder appears multiple times.

Here, we have a problem. The meta.get-function() cannot directly get a Sass module function. For example, meta.get-function("list.index") is invalid. The correct way would be meta.get-function("index", false, "list"). So, let’s create a new get-function() function to detect if the passed-in function is a Sass module method, and if so, extract the module name and the method 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);
  }
}

We’re almost done, there’s only one thing left. In iterator functions or threading functions, we can either pass an anonymous function or a named function. We already have the call-fn() and call-lambda() functions, so let’s add a new auto-call() function to detect the type of the passed-in function, and correspondingly choose to invoke call-fn() or call-lambda():

@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.";
  }
}

Finally, based on the previous work, implementing the reduce() function or other functions becomes much simpler. Check the source code for more examples.

@function reduce($fn, $init, $list...) {
  $_result: $init;

  @each $_i in $list {
    $_result: auto-call($fn, $_result, $_i);
  }

  @return $_result;
}

This project has been published as an NPM package named lambda-sass. The syntax was borrowed from Clojure, but the project name was inspired by the Lisp lambda function. I’d be delighted to see what you create using this library. If you find any functions missing, feel free to inform me or contribute by submitting a pull request.

Did you enjoy reading this post? I'd be thrilled to have you join me on my journey of exploration and creation. Let's keep discussing this post on Twitter and collaborate on GitHub. Keep in touch!