# Nested Map Functions: Draft 1.0 *[(Issue)](https://github.com/sass/sass/issues/1739)* This proposal updates the built-in `sass:map` module to better support merging, setting, and getting elements from nested maps. ## Table of Contents * [Background](#background) * [Summary](#summary) * [Functions](#functions) * [`get()`](#get) * [`has-key()`](#has-key) * [`set()`](#set) * [`merge()`](#merge) * [`deep-merge()`](#deep-merge) * [`deep-remove()`](#deep-remove) ## Background > This section is non-normative. Variables have always been a key feature of the Sass language. But these days, design systems and component libraries form the basis of most CSS projects -- with well organized *design tokens* as the foundation. While Individual token variables can be quite useful, the ability to group tokens into structured and meaningful relationships is essential for creating resilient systems. There are many ways to group tokens. The popular [Style Dictionary] recommends a deep nesting of *category*, *type*, *item*, *sub-item*, and *state*. Other taxonomies also include concepts like *theme*, or even *operating system*. Most of the existing tools rely on YAML or JSON objects to achieve that nested structure, at the expense of other important information. YAML and JSON are not design languages, and do not understand fundamental CSS concepts like color or length. [Style Dictionary]: https://amzn.github.io/style-dictionary With Sass, we don't have to make that tradeoff. We already support nestable map structures, and the ability to interact with them programmatically -- adding or removing properties, accessing values, and looping over entire structures. But current built-in functions don't provide much support for managing nested maps. Projects often build their own tooling. The results are inconsistent across projects, difficult to re-use, and often slow to compile. Implementing core support for nested maps could change all that. ## Summary > This section is non-normative. This proposal updates existing map functions with better support for inspection and manipulation of nested maps, as well as adding new functions to the `sass:map` module. For existing legacy functions (`get()`, `has-key()`, `merge()`) the new behavior will be accessible through both the `sass:map` module, and global legacy names (`map-get()`, `map-has-key()`, `map-merge()`). New functions (`set()`, `deep-merge()`) will only be available inside the `sass:map` module. The `has-key()` and `get()` functions both accept multiple `$keys...`: ```scss @use 'sass:map'; $nav: ( 'bg': 'gray', 'color': ( 'hover': ( 'search': yellow, 'home': red, 'filter': blue, ), ), ); $has-search: map.has-key($nav, 'color', 'hover', 'search'); // true $search-hover: map.get($nav, 'color', 'hover', 'search'); // yellow ``` The `merge()` function now accepts multiple `$keys...` between the two maps being merged. The keys form a path to the nested location in `$map1` where `$map2` should be merged. For example, we update the hover colors in our `$nav` map above: ```scss @use 'sass:map'; $new-hover: ( 'search': green, 'logo': orange, ); $nav: map.merge($nav, 'color', 'hover', $new-hover); // $nav: ( // 'bg': 'gray', // 'color': ( // 'hover': ( // 'search': green, // 'home': red, // 'filter': blue, // 'logo': orange, // ), // ), // ); ``` This proposal also adds a `set()` function to `sass:map`, with a similar syntax, returning a map with any nested key set to a specific value. To achieve the same output as our merge example, we can set each key individually: ```scss @use 'sass:map'; $nav: map.set($nav, 'color', 'hover', 'search', green); $nav: map.set($nav, 'color', 'hover', 'logo', orange); ``` And finally, a new `deep-merge()` function in the `sass:map` module allows merging two or more nested maps. This works much like the existing `merge()` function, but when both maps have a nested-map at the same key, those nested maps are also merged: ```scss @use 'sass:map'; $nav: ( 'bg': 'gray', 'color': ( 'hover': ( 'search': yellow, 'home': red, 'filter': blue, ), ), ); $update: ( 'bg': white, 'color': ( 'hover': ( 'search': green, 'logo': orange, ) ) ); $nav: map.deep-merge($nav, $update); // $nav: ( // 'bg': white, // 'color': ( // 'hover': ( // 'search': green, // 'home': red, // 'filter': blue, // 'logo': orange, // ), // ), // ); ``` ## Functions All new and modified functions are part of the `sass:map` built-in module. ### `get()` This proposal updates the signature and behavior of the existing `get()` function. > This also affects the global `map-get()` function. ``` get($map, $key, $keys...) ``` > Intuitively, `get($map, $key1, $key2, $key3)` is equivalent to > `get(get(get($map, $key1), $key2), $key3)` with the exception that if any > intermediate value isn't a map or doesn't have the given key the whole > function returns `null` rather than throwing an error. * If `$map` is not a map, throw an error. * Let `child` be `$map`. * Let `keys` be a list containing `$key` followed by the elements of `$keys`. * For each element `key` in `keys`: * If `child` is not a map, return `null`. * If `child` contains a key that's `==` to `key`, set `child` to the value associated with that key. Otherwise, return `null`. * Return `child`. ### `has-key()` This proposal updates the signature and behavior of the existing `get()` function. > This also affects the global `map-has-key()` function. ``` has-key($map, $key, $keys...) ``` > Intuitively, `has-key($map, $key1, $key2, $key3)` is equivalent to > `has-key(get(get($map, $key1), $key2), $key3)` with the exception that if any > intermediate value isn't a map or doesn't have the given key the whole > function returns `false` rather than throwing an error. * If `$map` is not a map, throw an error. * Let `child` be `$map`. * Let `keys` be a list containing `$key` followed by the elements of `$keys`. * For each element `key` in `keys`: * If `child` is not a map, return `false`. * If `child` contains a key that's `==` to `key`, set `child` to the value associated with that key. Otherwise, return `false`. * Return `true`. ### `set()` > Note: For consistency with other functions whose multi-key overloads were > added after their single-key versions, `set()` is defined to have a separate > single-key overload and multi-key overload. * ``` set($map, $key, $value) ``` > Intuitively, `set($map, $key, $value)` is equivalent to `merge($map, ($key: $value))`. * If `$map` is not a map, throw an error. * Let `map` be a copy of `$map`. * If `map` has a key that's `==` to `$key`, remove it and its associated value. * Associate `$key` with `$value` in `map`. * Return `map`. * ``` set($map, $args...) ``` > Intuitively, `set($map, $key1, $key2, $value)` is equivalent to `set($map, > $key1, set(get($map, $key1), $key2, $value))` with the exception that if any > intermediate value isn't set or isn't a map it's replaced with a map. * If `$map` is not a map, throw an error. * If `$args` has fewer than three elements, throw an error. * Let `map` be a copy of `$map`. * Let `key` be the first element of `$args`. * Let `remaining` be the slice of all elements in `$args` except the first. * If `map` has a key that's `==` to `key`: * Remove that key and its associated value from `map`. * Let `child` be the value that was associated with that key if that value is a map, or an empty map otherwise. * Otherwise: * Let `child` be an empty map. * Let `new-child` be the result of calling `set()` with `child` as the first argument and the elements of `remaining` as the remaining arguments. * Associate `key` with `new-child` in `map`. * Return `map`. ### `merge()` This proposal adds a new overload to the existing `merge()` function with lower priority than the existing signature. > This means that the new overload is only called if the existing signature > doesn't match. This proposal adds a new overload to the existing `merge()` function: ``` merge($map1, $args...) ``` > Intuitively, `map.merge($map1, $keys..., $map2)` is equivalent to > `map.set($map1, $keys..., map.merge(map.get($map1, $keys...), $map2))`. * If `$args` is empty, return `$map1`. * Let `map2` be the last element of `$args`. * If either `$map1` or `map2` is not a map, throw an error. * If `$args` has fewer than two elements, throw an error. * Let `keys` be a slice of all elements in `$args` except the last. * Let `sub` be the result of calling `get()` with `$map1` as the first argument and the contents of `keys` as the remaining arguments. * If `sub` is a map: * Let `sub-merged` be the result of calling `merge()` with `sub` and `map2` as arguments. * Otherwise: * Let `sub-merged` be `map2`. * Return the result of calling `set()` with `$map1` as the first argument, followed by the contents of `keys` as separate arguments, followed by `sub-merged`. ### `deep-merge()` ``` deep-merge($map1, $map2) ``` * If `$map1` and `$map2` are not maps, throw an error. * Let `merged` be a copy of `$map1`. * For each `new-key`/`new-value` pair in `$map2`: * If `merged` has a key `old-key` that's `==` to `new-key`: * Let `old-value` be the value associated with `old-key` in `merged`. * Remove `old-key`/`old-value` from `merged`. * If both `old-value` and `new-value` are maps, set `new-value` to the result of calling `deep-merge()` with `old-value` and `new-value`. * Associate `new-key` with `new-value` in `merged`. * Return `merged`. ### `deep-remove()` ``` deep-remove($map, $key, $keys...) ``` > Note: This is explicitly *not* an override of `remove()`, because `remove()` > already accepts a variable number of arguments as a way of removing multiple > keys from the same map. This proposal adds a new function rather than adjust > the existing behavior to avoid backwards-compatibility pain. > Intuitively, `map.deep-remove($map, $keys..., $last-key)` is equivalent to > `map.set($map, $keys..., map.remove(map.get($map, $keys...), $last-key)`. * If `$map` isn't a map, throw an error. * If `$keys` has no elements: * Return the result of calling `map.remove($map, $key)`. * Otherwise: * Let `last-key` be the last element of `$keys`. * Let `other-keys` be a list containing `$key` followed by all elements in `$keys` except the last. * Let `sub` be the result of calling `get()` with `$map` as the first argument and the contents of `other-keys` as the remaining arguments. * If `sub` is a map with a key `old-key` that's `==` to `last-key`: * Set `sub` to a copy of itself. * Remove `old-key` and its associated value from `sub`. * Return the result of calling `set()` with `$map` as the first argument, followed by the contents of `other-keys` as separate arguments, followed by `sub`. * Otherwise: * Return `$map`.