Extra Map Functions in Sass
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 Kitty 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 Kitty 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 Kitty 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. ;)