11 KiB
Nested Map Functions: Draft 1.0
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 toget(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 returnsnull
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
inkeys
:-
If
child
is not a map, returnnull
. -
If
child
contains a key that's==
tokey
, setchild
to the value associated with that key. Otherwise, returnnull
.
-
-
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 tohas-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 returnsfalse
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
inkeys
:-
If
child
is not a map, returnfalse
. -
If
child
contains a key that's==
tokey
, setchild
to the value associated with that key. Otherwise, returnfalse
.
-
-
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 tomerge($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
inmap
. -
Return
map
.
-
-
set($map, $args...)
Intuitively,
set($map, $key1, $key2, $value)
is equivalent toset($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==
tokey
:-
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
-
Let
new-child
be the result of callingset()
withchild
as the first argument and the elements ofremaining
as the remaining arguments. -
Associate
key
withnew-child
inmap
. -
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 tomap.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
ormap2
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 callingget()
with$map1
as the first argument and the contents ofkeys
as the remaining arguments. -
If
sub
is a map:- Let
sub-merged
be the result of callingmerge()
withsub
andmap2
as arguments.
- Let
-
Otherwise:
- Let
sub-merged
bemap2
.
- Let
-
Return the result of calling
set()
with$map1
as the first argument, followed by the contents ofkeys
as separate arguments, followed bysub-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 keyold-key
that's==
tonew-key
:-
Let
old-value
be the value associated withold-key
inmerged
. -
Remove
old-key
/old-value
frommerged
. -
If both
old-value
andnew-value
are maps, setnew-value
to the result of callingdeep-merge()
withold-value
andnew-value
.
-
-
Associate
new-key
withnew-value
inmerged
.
-
-
Return
merged
.
deep-remove()
deep-remove($map, $key, $keys...)
Note: This is explicitly not an override of
remove()
, becauseremove()
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 tomap.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)
.
- Return the result of calling
-
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 callingget()
with$map
as the first argument and the contents ofother-keys
as the remaining arguments. -
If
sub
is a map with a keyold-key
that's==
tolast-key
:-
Set
sub
to a copy of itself. -
Remove
old-key
and its associated value fromsub
. -
Return the result of calling
set()
with$map
as the first argument, followed by the contents ofother-keys
as separate arguments, followed bysub
.
-
-
Otherwise:
- Return
$map
.
- Return
-