HTML & CSS
Article

Extra Map Functions in Sass

By Hugo Giraudel

Sass maps are awesome. They make a lot of things that were previously impossible, well, possible. For instance, they have a smart way to define dynamic variables. They are also the perfect container for a complex configuration, for a framework or a grid system.

Fortunately for us, Sass provides a handful of functions to manipulate maps. Yet, if you keep working with Sass you might come up with an edge case that is not being covered by the built-in API.

Perhaps you’ll need a way to get a key that is being nested within several maps? What if you want to update this key? Or extend nested maps like $.extend from jQuery? A few problems here and there that can be easily fixed with the functions we’ll inspect today.

Map Deep Get

When you need to access a key that belongs to a map which happens to be nested within another map (or several), you cannot rely on map-get anymore. At least, not as it currently is.

/// Fetch nested keys
/// @param {Map} $map - Map
/// @param {Arglist} $keys - Keys to fetch
/// @return {*}
@function map-deep-get($map, $keys...) {
  @each $key in $keys {
    $map: map-get($map, $key);
  }

  @return $map;
}

Example

$grid-configuration: (
  'columns': 12,
  'layouts': (
    'small': 800px,
    'medium': 1000px,
    'large': 1200px,
  ),
);

// Without `map-deep-get`
$medium: map-get(map-get($grid-configuration, 'layouts'), 'medium');

// With `map-deep-get`
$medium: map-deep-get($grid-configuration, 'layouts', 'medium');

Map Deep Set

As you can see, building a function to get a key from within nested maps is not overly difficult to write. On the other hand, a function to set a value at the end of a key chain is not as easy to build… Because of the complexity, we will not get too deep into the code for this one.

However, we will give the signature a close look because I went a bit crazy with this one. For our function to work, we need 3 things:

  • the map;
  • a key chain, that can contain from a single key to a bazillion;
  • the new value for the last key from the key chain.

We have seen in a previous article why it is better to use an arglist when dealing with an unknown number of arguments, which is exactly what we have here with $keys. The problem is, there can be no extra argument after an arglist, so to fix this point we have two ways to write our function.

Either we can put the new value, then the key chain, but having the new value before what’s getting updated looks a little odd to me.

@function map-deep-set($map, $value, $keys...) {}

Or we only have two actual arguments, and consider the last value from $keys to be our new value.

@function map-deep-set($map, $keys.../*, $value */) {}

This is what I decided to go with.

/// Update a key deeply nested
/// @author Hugo Giraudel
/// @param {Map} $map - Map to update
/// @param {Arglist} $keys - Keys to access to value to update
/// @param {*} $value - New value (last member of `$keys`)
/// @return {Map} - Updated map
@function map-deep-set($map, $keys.../*, $value */) {
  $map-list: ($map,);
  $result: null;

  @if length($keys) == 2 {
    @return map-merge($map, (nth($keys, 1): nth($keys, -1)));
  }

  @for $i from 1 through length($keys) - 2 {
    $map-list: append($map-list, map-get(nth($map-list, -1), nth($keys, $i)));
  }

  @for $i from length($map-list) through 1 {
    $result: map-merge(nth($map-list, $i), (nth($keys, $i): if($i == length($map-list), nth($keys, -1), $result)));
  }

  @return $result;
}

Example

$grid-configuration: (
  'columns': 12,
  'layouts': (
    'small': 800px,
    'medium': 1000px,
    'large': 1200px,
  ),
);

// Without `map-deep-set`
$grid-configuration: map-merge($grid-configuration, map-merge(map-get($grid-configuration, 'layouts'), ('large': 1300px)));

// With `map-deep-set`
$medium: map-deep-set($grid-configuration, 'layouts', 'medium', 1300px);

Map Depth

In the article about debugging a Sass map, we saw how we could build a function to retrieve the maximum depth of a Sass map. Let me post it here again for posterity:

/// Compute the maximum depth of a map
/// @param {Map} $map
/// @return {Number} max depth of `$map`
@function map-depth($map) {
  $level: 1;

  @each $key, $value in $map {
    @if type-of($value) == "map" {
      $level: max(map-depth($value) + 1, $level);
    }
  }

  @return $level;
}

Example

$grid-configuration: (
  'columns': 12,
  'layouts': (
    'small': 800px,
    'medium': 1000px,
    'large': 1200px,
  ),
);

// Maximum depth
$depth: map-depth($grid-configuration);
// -> 2

Map Has Keys

Sass provides a function to check whether a map has a given key. We could extend this function to test if Sass has several keys, what do you say? It is actually extremely easy to do, as you can see.

/// Test if map got all `$keys` at first level
/// @author Hugo Giraudel
/// @param {Map} $map - Map
/// @param {Arglist} $keys - Keys to test
/// @return {Bool}
@function map-has-keys($map, $keys...) {
  @each $key in $keys {
    @if not map-has-key($map, $key) {
      @return false;
    }
  }

  @return true;
}

Example

$grid-configuration: (
  'columns': 12,
  'layouts': (
    'small': 800px,
    'medium': 1000px,
    'large': 1200px,
  ),
);

$depth: map-has-keys($grid-configuration, 'columns', 'layouts');
// -> true

$depth: map-has-keys($grid-configuration, 'columns', 'options');
// -> false

Map Has Nested Keys

Along the same lines, the default map-has-key function cannot be used when dealing with nested maps, as it only goes one level deep. Let’s roll up our sleeves to build a deep map-has-key.

/// Test if map got all `$keys` nested with each others
/// @author Hugo Giraudel
/// @param {Map} $map - Map
/// @param {Arglist} $keys - Keys to test
/// @return {Bool}
@function map-has-nested-keys($map, $keys...) {
  @each $key in $keys {
    @if not map-has-key($map, $key) {
      @return false;
    }
    $map: map-get($map, $key);
  }

  @return true;
}

Example

$grid-configuration: (
  'columns': 12,
  'layouts': (
    'small': 800px,
    'medium': 1000px,
    'large': 1200px,
  ),
);

$depth: map-has-keys($grid-configuration, 'layouts', 'medium');
// -> true

$depth: map-has-keys($grid-configuration, 'layouts', 'huge');
// -> false

Map Zip

This is something I have been wanting to do more than once without having a built-in way to do so: merging two lists to build a map, where the first list would serve as keys, and the second list would serve as values.

In case both lists are not the same length, extra values would be discarded and the function would emit a warning to explain what is going on.

/// An equivalent of `zip` function but for maps.
/// Takes two lists, the first for keys, second for values.
/// @param {List} $keys - Keys for map
/// @param {List} $values - Values for map
/// @return {Map} Freshly created map
/// @see http://sass-lang.com/documentation/Sass/Script/Functions.html#zip-instance_method
@function map-zip($keys, $values) {
  $l-keys: length($keys);
  $l-values: length($values);
  $min: min($l-keys, $l-values);
  $map: ();

  @if $l-keys != $l-values {
    @warn "There are #{$l-keys} key(s) for #{$l-values} value(s) in the map for `map-zip`. "
        + "Resulting map will only have #{$min} pairs.";
  }

  @if $min == 0 {
    @return $map;
  }

  @for $i from 1 through $min {
    $map: map-merge($map, (nth($keys, $i): nth($values, $i)));
  }

  @return $map;
}

Example

$layout-names: 'small', 'medium', 'large', 'huge';
$layout-values: 600px, 900px, 1200px, 1500px;

$breakpoints: map-zip($layout-names, $layout-values);
// -> ('small': 600px, 'medium': 900px, 'large': 1200px, 'huge': 1500px)

Map Extend

You might know that map-merge has two purposes: adding a key to a map, and merge two maps together. As of today, I see that there are possibly two flaws in map-merge:

  • it is not recursive, meaning it does work with nested maps;
  • it only takes two maps, no more.

Surely we can do better, right? Let me introduce the map-extend function, ala jQuery. As for map-deep-set, we use a signature hack to have the $deep parameter as the last value from our $maps chain.

/// jQuery-style extend function
/// About `map-merge()`:
/// * only takes 2 arguments
/// * is not recursive
/// @param {Map} $map - first map
/// @param {ArgList} $maps - other maps
/// @param {Bool} $deep - recursive mode
/// @return {Map}
@function map-extend($map, $maps.../*, $deep */) {
  $last: nth($maps, -1);
  $deep: $last == true;
  $max: if($deep, length($maps) - 1, length($maps));

  // Loop through all maps in $maps...
  @for $i from 1 through $max {
    // Store current map
    $current: nth($maps, $i);

    // If not in deep mode, simply merge current map with map
    @if not $deep {
      $map: map-merge($map, $current);
    } @else {
      // If in deep mode, loop through all tuples in current map
      @each $key, $value in $current {

        // If value is a nested map and same key from map is a nested map as well
        @if type-of($value) == "map" and type-of(map-get($map, $key)) == "map" {
          // Recursive extend
          $value: map-extend(map-get($map, $key), $value, true);
        }

        // Merge current tuple with map
        $map: map-merge($map, ($key: $value));
      }
    }
  }

  @return $map;
}

Example

$grid-configuration-default: (
  'columns': 12,
  'layouts': (
    'small': 800px,
    'medium': 1000px,
    'large': 1200px,
  ),
);

$grid-configuration-custom: (
  'layouts': (
    'large': 1300px,
    'huge': 1500px
  ),
);

$grid-configuration-user: (
  'direction': 'ltr',
  'columns': 16,
  'layouts': (
    'large': 1300px,
    'huge': 1500px
  ),
);

// Not deep
$grid-configuration: map-extend($grid-configuration-default, $grid-configuration-custom, $grid-configuration-user);
// -> ("columns": 16, "layouts": (("large": 1300px, "huge": 1500px)), "direction": "ltr")

// Deep
$grid-configuration: map-extend($grid-configuration-default, $grid-configuration-custom, $grid-configuration-user, true);
// -> ("columns": 16, "layouts": (("small": 800px, "medium": 1000px, "large": 1300px, "huge": 1500px)), "direction": "ltr")

Final thoughts

That’s it folks! Obviously, you will not use them in your everyday stylesheet but once in a while, such functions can come in handy. At least, you’ll know where to find them. ;)

Comments
Dan503

Some nice functions there. Deep get is probably the most useful.
I don't really see the point of map-zip... actually I just realised that if your using loops to generate your keys and values then that could come in handy.

I really don't like arg lists though. I see the commas in functions as important separaters that define different functionality applied to each value in between those brackets.

It makes the functions more difficult to understand when you have this sort of layout:

@function function_name($map, $key, $values...){ };

function_name($map, key, val, val, val, val);

I think this makes more sense:

@function function_name($map, $key, $values){ };

function_name($map, key, val val val val);

The commas clearly define where the difference is in functionality on the second example. In the first example, it can be difficult to tell when the list of values actually starts.

Also there is the benefit of the list not having to be the last thing in the function brackets, so you can put the list earlier in the chain and set defaults for the variables after it.

Recommended

Learn Coding Online
Learn Web Development

Start learning web development and design for free with SitePoint Premium!

Get the latest in Front-end, once a week, for free.