Skip to main content

Map/Reduce

Map

While a total optic is focused on one value and a partial optic is focused on zero or one value, a mapped optic is focused on multiple values.
Such optic can read these values and update them simultaneously.

To illustrate that let's create a new state holding US national parks:

ts
const stateOptic = createState([
{
name: "Zion",
visitors: 5_039_835,
attractions: ["angels landing", "great white throne"],
},
{
name: "Yosemite",
visitors: 3_287_595,
attractions: ["half dome", "el capitan", "mariposa grove"],
},
{
name: "Yellowstone",
visitors: 4_860_242,
attractions: ["old faithful", "grand prismatic"],
},
]);
ts
const stateOptic = createState([
{
name: "Zion",
visitors: 5_039_835,
attractions: ["angels landing", "great white throne"],
},
{
name: "Yosemite",
visitors: 3_287_595,
attractions: ["half dome", "el capitan", "mariposa grove"],
},
{
name: "Yellowstone",
visitors: 4_860_242,
attractions: ["old faithful", "grand prismatic"],
},
]);

The initial state is an array so we can call .map() to get a mapped optic, an optic that maps over the values of the array:

ts
const nationalParksOptic = stateOptic.map();
const nationalParksOptic: Optic<{ name: string; visitors: number; attractions: string[]; }, mapped>
ts
const nationalParksOptic = stateOptic.map();
const nationalParksOptic: Optic<{ name: string; visitors: number; attractions: string[]; }, mapped>

From there we can get the name of every national park ...

ts
const namesOptic = nationalParksOptic.name;
const namesOptic: Optic<string, mapped>
 
namesOptic.get(); // ['Zion', 'Yosemite', 'Yellowstone']
ts
const namesOptic = nationalParksOptic.name;
const namesOptic: Optic<string, mapped>
 
namesOptic.get(); // ['Zion', 'Yosemite', 'Yellowstone']

... or update them all at once:

ts
namesOptic.set((prev) => `${prev} National Park`);
 
namesOptic.get(); // ['Zion National Park', 'Yosemite National Park', 'Yellowstone National Park']
ts
namesOptic.set((prev) => `${prev} National Park`);
 
namesOptic.get(); // ['Zion National Park', 'Yosemite National Park', 'Yellowstone National Park']

Same thing but for the annual number of visitors:

ts
const visitorsOptic = nationalParksOptic.visitors;
const visitorsOptic: Optic<number, mapped>
 
visitorsOptic.get(); // [5_039_835, 3_287_595, 4_860_242]
 
visitorsOptic.set((prev) => prev + 1_000_000);
visitorsOptic.get(); // [6_039_835, 4_287_595, 5_860_242]
ts
const visitorsOptic = nationalParksOptic.visitors;
const visitorsOptic: Optic<number, mapped>
 
visitorsOptic.get(); // [5_039_835, 3_287_595, 4_860_242]
 
visitorsOptic.set((prev) => prev + 1_000_000);
visitorsOptic.get(); // [6_039_835, 4_287_595, 5_860_242]

The update function is applied to every focused value, which is convenient to update or reset a large number of values all at once.

tip

The new optics you derive from a mapped optic are themselves mapped optics !

Multi level

You can call .map() every time the focused value is an array, even if the optic is already a mapped one:

ts
const attractionsOptic = nationalParksOptic.attractions.map();
const attractionsOptic: Optic<string, mapped>
ts
const attractionsOptic = nationalParksOptic.attractions.map();
const attractionsOptic: Optic<string, mapped>

Here attractionsOptic is a mapped optic focused on every attraction from every national park.

ts
attractionsOptic.get(); // ['angels landing', 'great white throne', 'half dome', 'el capitan', 'mariposa grove', 'old faithful', 'grand prismatic']
 
attractionsOptic.set(capitalize);
attractionsOptic.get(); // ['Angels Landing', 'Great White Throne', 'Half Dome', 'El Capitan', 'Mariposa Grove', 'Old Faithful', 'Grand Prismatic']
ts
attractionsOptic.get(); // ['angels landing', 'great white throne', 'half dome', 'el capitan', 'mariposa grove', 'old faithful', 'grand prismatic']
 
attractionsOptic.set(capitalize);
attractionsOptic.get(); // ['Angels Landing', 'Great White Throne', 'Half Dome', 'El Capitan', 'Mariposa Grove', 'Old Faithful', 'Grand Prismatic']

Calling .get() returns a flat representation of all attractions, even though in the root state they are nested in their respective national parks.

Updating them with .set() preserves the state's original structure.
We can see that if we look at our state, now that we've applied all these transformations thanks to mapped optics:

ts
// nationalParksOptic.get();
[
{
name: "Zion National Park",
visitors: 6_039_835,
attractions: ["Angels Landing", "Great White Throne"],
},
{
name: "Yosemite National Park",
visitors: 4_287_595,
attractions: ["Half Dome", "El Capitan", "Mariposa Grove"],
},
{
name: "Yellowstone National Park",
visitors: 5_860_242,
attractions: ["Old Faithful", "Grand Prismatic"],
},
];
ts
// nationalParksOptic.get();
[
{
name: "Zion National Park",
visitors: 6_039_835,
attractions: ["Angels Landing", "Great White Throne"],
},
{
name: "Yosemite National Park",
visitors: 4_287_595,
attractions: ["Half Dome", "El Capitan", "Mariposa Grove"],
},
{
name: "Yellowstone National Park",
visitors: 5_860_242,
attractions: ["Old Faithful", "Grand Prismatic"],
},
];
cardinality recap
  • total: 1..1
  • partial: 0..1
  • mapped: 0..n

Reduce

Mapped optics have special methods called reduce methods.
They yield new optics that, from the originally focused values, reduce them into a new one (a bit like JavaScript's Array.reduce).

For example to focus on all attractions sorted by name...:

ts
const sortedAttractionsOptic = attractionsOptic.reduceSort((a, b) =>
a.localeCompare(b)
);
 
sortedAttractionsOptic.get(); // ['Angels Landing', 'El Capitan', 'Grand Prismatic', 'Great White Throne', 'Half Dome', 'Mariposa Grove', 'Old Faithful']
ts
const sortedAttractionsOptic = attractionsOptic.reduceSort((a, b) =>
a.localeCompare(b)
);
 
sortedAttractionsOptic.get(); // ['Angels Landing', 'El Capitan', 'Grand Prismatic', 'Great White Throne', 'Half Dome', 'Mariposa Grove', 'Old Faithful']

... and then to focus on the first three:

ts
const firstThreeSortedOptic = sortedAttractionsOptic.reduceSlice(0, 3);
 
firstThreeSortedOptic.get(); // ['Angels Landing', 'El Capitan', 'Grand Prismatic']
firstThreeSortedOptic.set((prev) => prev.toUpperCase());
 
attractionsOptic.get(); // ['ANGELS LANDING', 'Great White Throne', 'Half Dome', 'EL CAPITAN', 'Old Faithful', 'Mariposa Grove', 'GRAND PRISMATIC']
ts
const firstThreeSortedOptic = sortedAttractionsOptic.reduceSlice(0, 3);
 
firstThreeSortedOptic.get(); // ['Angels Landing', 'El Capitan', 'Grand Prismatic']
firstThreeSortedOptic.set((prev) => prev.toUpperCase());
 
attractionsOptic.get(); // ['ANGELS LANDING', 'Great White Throne', 'Half Dome', 'EL CAPITAN', 'Old Faithful', 'Mariposa Grove', 'GRAND PRISMATIC']

Back to one (or none)

The above examples return new mapped optics but some reduce methods can also give us a partial optic by reducing back to a single value.

For example to focus on the first attraction starting with the letter G:

ts
const firstStartingWithGOptic = attractionsOptic.reduceFindFirst((attraction) =>
attraction.startsWith("G")
);
 
firstStartingWithGOptic.get(); // 'Great White Throne'
const firstStartingWithGOptic: Optic<string, partial>
ts
const firstStartingWithGOptic = attractionsOptic.reduceFindFirst((attraction) =>
attraction.startsWith("G")
);
 
firstStartingWithGOptic.get(); // 'Great White Throne'
const firstStartingWithGOptic: Optic<string, partial>

Or to focus on the attraction with the shortest name:

ts
const shortestNameOptic = attractionsOptic.reduceMin((name) => name.length);
 
shortestNameOptic.get(); // 'Half Dome'
const shortestNameOptic: Optic<string, partial>
ts
const shortestNameOptic = attractionsOptic.reduceMin((name) => name.length);
 
shortestNameOptic.get(); // 'Half Dome'
const shortestNameOptic: Optic<string, partial>
info

We get a partial optic instead of a total one because the mapped optic might yield no value at all, in case the arrays we mapped over are empty.

Reduce methods vs Array methods

You might have noticed that the reduce methods look a lot like the methods available to optics focused on arrays (e.g. reduceFindFirst vs findFirst).
The difference is that array methods only operate on the array that's currently focused by the optic, while reduce methods operate on the whole mapping.

It means that ...

ts
const longestForEachParkOptic = nationalParksOptic.attractions.max(
(attraction) => attraction.length
);
 
longestForEachParkOptic.get(); // ['Great White Throne', 'Mariposa Grove', 'Grand Prismatic']
const longestForEachParkOptic: Optic<string, mapped>
ts
const longestForEachParkOptic = nationalParksOptic.attractions.max(
(attraction) => attraction.length
);
 
longestForEachParkOptic.get(); // ['Great White Throne', 'Mariposa Grove', 'Grand Prismatic']
const longestForEachParkOptic: Optic<string, mapped>

... is different from:

ts
const mostAttractionsOptic = nationalParksOptic.attractions.reduceMax(
(attractions) => attractions.length
);
 
mostAttractionsOptic.get(); // ['Half Dome', 'El Capitan', 'Mariposa Grove']
const mostAttractionsOptic: Optic<string[], partial>
ts
const mostAttractionsOptic = nationalParksOptic.attractions.reduceMax(
(attractions) => attractions.length
);
 
mostAttractionsOptic.get(); // ['Half Dome', 'El Capitan', 'Mariposa Grove']
const mostAttractionsOptic: Optic<string[], partial>

The first example is a mapped optic that focuses on the attraction with the longest name for each national park.
The second example is a partial optic focused on the attractions of the national park that has the most attractions (Yosemite).