Indexed Optics

Indexed optics focus an index value in addition to the value. This typically occurs when traversing a collection.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
	data := map[string]int{
		"alpha": 1,
		"beta":  2,
		"gamma": 3,
		"delta": 4,
	}
	result := MustGet(
		SeqIOf(
			TraverseMap[string, int](),
		),
		data,
	)
	for index, value := range result {
		fmt.Println(index, value)
	}

Here we have used SeqIOf which is the index aware version of SeqOf. SeqIOf focuses an iter.Seq2 of the focused index and value.

The result is this sequence of values being printed.

alpha 1
beta 2
delta 4
gamma 3

Traversing a map in go optics always iterates the value in a consistent order. The sorting is performed by sorting by the index values.

There is only 1 active index at any time. Compose by default maintains the right optic“s index. This can cause the index to be lost.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
	data := map[string]int{
		"alpha": 1,
		"beta":  2,
		"gamma": 3,
		"delta": 4,
	}
	result, err := Get(
		SeqIOf(
			Compose(
				TraverseMap[string, int](),
				Mul(10),
			),
		),
		data,
	)

	if err != nil {
		fmt.Println(err)
	}

	for index, value := range result {
		fmt.Println(index, value)
	}

Mul(10) doesn’t have an index and this causes the TraverseMap’s index to be lost. The result is this sequence of values being printed.

{} 10
{} 20
{} 40
{} 30

In go-optics an empty struct called Void is used to represent the lack of an index.

There are 3 built in variants of Compose that can retain either the left or both indices.

  • ComposeLeft retains the left index.
  • ComposeBoth retains both indices wrapped in a lo.Tuple2
  • ComposeI uses a user provided IxMapper to map the index.

Compose Left #

ComposeLeft is a drop in replacement for Compose that retains the left instead of right index.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
	result, err := Get(
		SeqIOf(
			ComposeLeft(
				TraverseMap[string, int](),
				Mul(10),
			),
		),
		data,
	)

	if err != nil {
		fmt.Println(err)
	}

	for index, value := range result {
		fmt.Println(index, value)
	}

This example now focuses the string index from the map instead of the Void index from the Mul(10) operation. The example prints the following values.

alpha 10
beta 20
delta 40
gamma 30

The index focused by ComposeLeft is compatible with the Index combinator which focuses a value at a given index.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
	result, ok, err := GetFirst(
		Index(
			ComposeLeft(
				TraverseMap[string, int](),
				Mul(10),
			),
			"beta",
		),
		data,
	)

	if err != nil {
		fmt.Println(err)
	}

	fmt.Println(result, ok, err)

This prints the following values.

1
		20, true, nil,

ComposedLeft provides Index with an optimised optic that is still able to perform a data["beta"} lookup into the map.

Compose Both #

ComposeBoth is also a drop in replacement for Compose that retains both indices as a lo.Tuple2

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
	data := []map[string]int{
		map[string]int{
			"alpha": 1,
			"beta":  2,
		},
		map[string]int{
			"gamma": 3,
			"delta": 4,
		},
	}
	result, err := Get(
		SeqIOf(
			ComposeBoth(
				TraverseSlice[map[string]int](),
				TraverseMap[string, int](),
			),
		),
		data,
	)

	if err != nil {
		fmt.Println(err)
	}

	for index, value := range result {
		fmt.Println(index, value)
	}
In this example we can see that the index is a combination of the slice index and map key.

{0 alpha} 1
{0 beta} 2
{1 delta} 4
{1 gamma} 3

ComposeBoth also provides an optimized Index implementation. We simply pass the 2 index values as a lo.Tuple2

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	result, ok, err := GetFirst(
		Index(
			ComposeBoth(
				TraverseSlice[map[string]int](),
				TraverseMap[string, int](),
			),
			lo.T2(1, "gamma"),
		),
		data,
	)

	fmt.Println(result, ok, err)

This example returns the “gamma” value from the second slice. The Index was able to perform both the slice index and map lookup data[1]["gamma"]

1
		3, true, nil,

ComposeI #

ComposeI enables custom mapping of the index. The mapping is performed by an IxMap optic. By using the IxMapIso constructor the composed optic is able to provide an optimized Index implementation as it is able to recover the original index values by performing a ReverseGet on the IxMap

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
	data := []map[string]int{
		map[string]int{
			"alpha": 1,
			"beta":  2,
		},
		map[string]int{
			"gamma": 3,
			"delta": 4,
		},
	}

	ixMap := IxMapIso[int, string, lo.Tuple2[int, string]](
		func(left int, right string) lo.Tuple2[int, string] {
			return lo.T2(left, right)
		},
		func(t1, t2 lo.Tuple2[int, string]) bool {
			return t1 == t2
		},
		func(mapped lo.Tuple2[int, string]) (int, bool, string, bool) {
			return mapped.A, true, mapped.B, true
		},
		ExprCustom("IxMapBoth"),
	)

	result, err := Get(
		SeqIOf(
			ComposeI(
				ixMap,
				TraverseSlice[map[string]int](),
				TraverseMap[string, int](),
			),
		),
		data,
	)

	if err != nil {
		fmt.Println(err)
	}

	for index, value := range result {
		fmt.Println(index, value)
	}

This example is equivalent to a ComposeBoth

{0 alpha} 1
{0 beta} 2
{1 delta} 4
{1 gamma} 3

Reindexing #

It’s sometimes necessary to modify the index of an optic for example when building a map. For this purposes the reindexing combinators can be used.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
	data := []string{
		"alpha",
		"beta",
		"gamma",
		"delta",
	}

	optic := ReIndexed(
		TraverseSlice[string](),
		Compose(
			FormatInt[int](10),
			Op(func(focus string) string {
				return "KEY:" + focus
			}),
		),
		EqT2[string](), //IxMatch for the new index
	)

	res, err := Get(MapOf(optic, 10), data)
	fmt.Println(res, err)

This example uses the ReIndexed combinator to modify the int index of a slice traversal and converts it into a string and builds a map from the result. The EqT2 parameter is an IxMatch optic. It is used to perform equality checks on the new index during Index lookups using the new index. After reindexing the Index lookup is performed by filtering on the index using the IxMatch

There are several other reindexing combinators.

CombinatorPurpose
Indexedreplaces the index with an integer counter
SelfIndexuses the focused element as the index
ReIndexedenables an arbitrary transformation if the index

By combining the SelfIndex and ReIndexed combinators an arbitrary property of the focus can be used as the index.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
	data := []lo.Tuple2[string, int]{
		lo.T2("Max Mustermann", 42),
		lo.T2("Erika Mustermann", 37),
	}

	optic := ReIndexed(
		//Self index will set the index to the lo.Tuple2[string,int]
		SelfIndex(
			TraverseSlice[lo.Tuple2[string, int]](),
			EqT2[lo.Tuple2[string, int]](), //IxMatch for the new index
		),
		//Reindexed will then set the index to element A of the tuple.
		T2A[string, int](),
		EqT2[string](), //IxMatch for the new index
	)

	//We can then build a map the element A of the tuple as key and the fully tuple as the element.
	res, err := Get(MapOf(optic, 10), data)
	fmt.Println(res, err)

This example prints

map[Erika Mustermann:{Erika Mustermann 37} Max Mustermann:{Max Mustermann 42}] <nil>

By using the reindexed combinators arbitrary transformations can be applied to the index of an optic.