sass/accepted/nested-map-functions.md
2023-08-22 21:10:27 +00:00

11 KiB

Nested Map Functions: Draft 1.0

(Issue)

This proposal updates the built-in sass:map module to better support merging, setting, and getting elements from nested maps.

Table of Contents

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.

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...:

@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:

@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:

@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:

@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.