Concepts

Concepts #

There are 5 core concepts in go-optics

  1. Actions
  2. Optics
  3. Compose
  4. Combinators
  5. The Identity Rule

Here is an example of performing an update using go-optics.

1
2
3
4
5
6
7
8
9
	result := MustSet(
		Index(
			TraverseSlice[int](),
			3,
		),
		4,
		[]int{10, 20, 30, 40, 50},
	)
	fmt.Println(result)

This example sets the value at slice index 3 to 5. The result value is []int{10,20,30,4,50}

MustSet is the action TraverseSlice[int]() is an Optic and Index(3) is a combinator

Actions determine what operation will be performed on the data structure. There are 2 types of actions read and write. Write actions do not directly modify the given data, they return a modified copy.

The optic determines what data will be read or modified. Optics are described by their source,focus and index types. In this example TraverseSlice[int]() has

  • a source type of []int
  • a focus type of int. The focus is the elements of the slice.
  • an index of int which is the position of the focus in the slice.

TraverseSlice[int]() focuses on the intvalues and int indexes within a source slice.

We can visualize the optic in this way.

Traverse

On the left is the source []int and on the right is the focus int, The index type is inside the square brackets [int]

Combinators modify the behaviour of optics. In this example Index(...,3) modifies the TraverseSlice[int]() optic to focus on the slice element at index 3. Combinators return optics enabling the results to be further refined by other combinators.

We can visualize the combinator like this.

Index

Again the source type is on the left. The empty slot in the middle is where the TraverseSlice]int() plugs in and on the right is the focus type.

Index

Here are some further example optic statements.

1
2
3
4
5
6
7
8
9
	result, ok := MustGetFirst(
		Index(
			TraverseSlice[int](),
			3,
		),
		[]int{10, 20, 30, 40, 50},
	)

	fmt.Println(result, ok)

This example is similar to the first one except we have changed the action from MustSet to MustGetFirst this actions reads the first focused value instead of updating it. in this case result is the integer value 40 and ok is true as we found a value at index 3.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	result := MustSet(
		Index(
			TraverseMap[string, int](),
			"alpha",
		),
		1,
		map[string]int{
			"alpha": 10,
			"beta":  20,
		},
	)
	fmt.Println(result)

This example uses the TraverseMap[string,int]() optic instead of TraverseSlice[int](). TraverseMap[string,int]() focuses on the values of a map instead of a slice. This optic is however still compatible with the same Index combinator used in the TraverseSlice[int]() examples. In this example the map entry with key “alpha” is set to the value 1. The result is a new map with the following value.

1
2
3
4
		map[string]int{
			"alpha": 1,
			"beta":  20,
		},

Again the original map is not modified. TraverseSlice and TraverseMap immutably update maps and slices by returning new copies with the necessary updates applied.

1
2
3
4
5
6
7
8
9
	result := MustModify(
		Filtered(
			TraverseSlice[int](),
			Lt(10),
		),
		Mul(2),
		[]int{1, 2, 30, 4, 5},
	)
	fmt.Println(result)

This example uses the MustModify action which applies an operation to each focused value. This is different to MustSet which sets a fixed value. In this example Mul(2) is the operation that will be applied to each focused value. It may seem that Mul(2) is a new kind of concept but it is in fact an optic. It focuses on a value that is the source value multiplied by 2. The optic that determines which values will be updated in this example is Filtered( TraverseSlice[int](), Lt(10) ). Filtered is a combinator that modifies an optic to only focus on values that match a predicate. The predicate in this example is Lt(10) meaning less than 10. The Filtered optic will only focus on values within the the TraverseSlice[int]() that are less than 10. Again predicates like Lt(10) may look like a new concept but predicates are also optics that focus on a boolean value for each source value. In this case Lt(10) will focus on true for values less than 10 and false for all other values. The Filtered combinator in this example raises an interesting question. What does it mean to update a filtered slice? The answer is that the Mul(2) operation is only applied to the values that match the filter. The other values are retained with their original values. The result in this example is the following slice.

1
		[]int{2, 4, 30, 8, 10},

Notice that the 30 value is greater than 10 and was therefore not multiplied by 2. This behaviour is due to the Identity Rule. Which states that when applying the Identity operation to a Modify action the output should be identical to the input.In the case of filtered that means that non matching elements remain with their original values.

These examples only touch the surface of what optics are capable of but demonstrate the core concepts of actions, optics, combinators and the identity rule.

Actions #

Actions determine the operation that will be performed using an optic. Actions are provided according to the following naming convention.

PureError AwareContext Aware
Non IndexedMustActionActionActionContext
IndexedMustActionIActionIActionContextI

The non-index/pure forms provide a simpler interface when the use case does not require the use of indexes and the optic is pure i.e. will not return an error.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
	result := MustModify(
		TraverseMap[string, int](),
		Mul(2),
		map[string]int{
			"alpha": 1,
			"beta":  2,
			"gamma": 3,
		},
	)
	fmt.Println(result)

The TraverseMap[string,int] focuses on the values within a map. MustModify is an action that applies the given operation ,Mul(2), to each focused element. In this example the result is.

1
2
3
4
5
		map[string]int{
			"alpha": 2,
			"beta":  4,
			"gamma": 6,
		},

MustModifyI is an indexed form of MustModify that provides access to the index during modification.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	result := MustModifyI(
		TraverseSlice[string](),
		OpI(func(index int, focus string) string {
			if index%2 == 0 {
				return strings.ToUpper(focus)
			} else {
				return focus
			}
		}),
		[]string{"alpha", "beta", "gamma", "delta"},
	)
	fmt.Println(result)

OpI is a specialized Getter constructor that operates on an index & value instead of the just the focused value. The result of this example is

1
		[]string{"ALPHA", "beta", "GAMMA", "delta"},

MustModifyI can only modify the focused value. In order to modify an index you will need to use the ReIndexed combinator.

Indexes are also relevant to read actions.

1
2
3
4
5
6
7
8
	result, found := MustGetFirst(
		TraverseMap[string, int](),
		map[string]int{
			"alpha": 1,
			"beta":  2,
		},
	)
	fmt.Println(result, found)

In this example the MustGetFirst action is a non indexed action that will return the first focused value. The result in this example is

1
		1, true,

The true values indicates that a value was found.

MustGetFirstI is the indexed form of MustGetFirst if we drop it into the example above.

1
2
3
4
5
6
7
8
	index, result, found := MustGetFirstI(
		TraverseMap[string, int](),
		map[string]int{
			"alpha": 1,
			"beta":  2,
		},
	)
	fmt.Println(index, result, found)

We see that the return now includes the index of the found element. In this example the result is

1
		"alpha", 1, true,

So far all the actions we have used have been prefixed with Must These are non error aware and only accept pure Optics that will not return an error.

The error and context aware actions accept any optic and have an extra error return value.

1
2
3
4
5
6
	result, err := Modify(
		ParseInt[int](10, 0),
		Mul(2),
		"1",
	)
	fmt.Println(result, err)

In this example we parse a string to int and multiply by 2. This can obviously fail if the string is not a number.

The results of this example is

1
		"2", nil,

The context aware versions have an additional context parameter.

1
2
3
4
5
6
7
	result, err := ModifyContext(
		context.Background(),
		ParseInt[int](10, 0),
		Mul(2),
		"1",
	)
	fmt.Println(result, err)

Context aware actions are cancel & deadline aware and will stop processing and return an error if the context is canceled or the deadline has expired.

Optics #

There are 6 different kinds of optics in the go optics library.

  1. Getter
  2. Lens
  3. Iteration
  4. Traversal
  5. Iso
  6. Prism

Their basic behaviour is determined by combining 4 different properties.

  1. Return type
  2. Read only
  3. Direction
  4. Error raising

Return type determines whether an optic returns exactly 1 result or may return 0 or more results. An example of a return 1 optic is a Getter e.g. Gt(1) this clearly always returns exactly 1 value either true or false. TraverseSlice[int]() will focus on the number of elements in the slice, which may of course be empty.

Read only determines whether an optic supports modifications, again Getters are read only. You cannot set a value into Eq(1). TraverseSlice[int]() however does support modifications by returning a modified version of the slice.

Direction determines whether the effect of an optic can be reversed, again Getters can´t be reversed. It’s not possible to take a true value and pass it to Gt(1) and determine what the source value was. TraverseSlice[int)() is also not bidirectional as it focuses on multiple values and only single values can be reversed. Add is a bidirectional optic as te reverse an addition by subtracting.

1
2
	result := MustGet(Add(5), 10)
	fmt.Println(result)

Will add 5 to 10 the and return the integer 15 as a result.

1
2
	result := MustReverseGet(Add(5), 10)
	fmt.Println(result)

Will reverse add/subtract the integer value 5 from 10 and return 5.

The following table demonstrates the behaviour of each of the optic types.

Return TypeRead TypeDirection TypeOptic Type
Return OneRead OnlyUniGetter
Return OneRead OnlyBi—————
Return OneRead WriteUniLens
Return OneRead WriteBiIso
Return ManyRead OnlyUniIteration
Return ManyRead OnlyBi—————
Return ManyRead WriteUniTraversal
Return ManyRead WriteBiPrism

Read only bidirectional optics do not exist as the reverse operation is equivalent to modification.

Error awareness determines whether the Optic can return an error. The Getter Gt(1) is pure it never returns an error. ParseInt however may raise an error if passed a non numeric string.

The optics library includes compile time support to prevent optics being used with incompatible actions. e.g.

//Compile error : ReadOnly does not satisfy comparable
Set( Eq(1) , false , 1 )

This clearly makes no sense to try and set 1 == 1 to false.

//Compile error : ReturnMany does not satisfy comparable
Get( TraverseSlice[int]() , []int{ 1 , 2 , 3 } )

Traversing a slice focuses multiple values so we cannot view the single focused value. Get could try to return the first focused value but if the slice is empty then there is no value to return.

//Compile error : UniDir does not satisfy comparable
ReverseGet( Gt(1) , false )

Gt is unidirectional. We can’t determine which integer to return for a false result from a greater than 1 operation.

//Compile error : Err does not satisfy comparable
MustGet( ParseInt(10,32) , "1" )

ParseInt might return an error so it can’t be used with Must actions.

When constructing optics several variants of the constructor are provided.

VariantCapabilities
BaseNon index aware, Non error raising, Non polymorphic
BaseIIndex aware
BaseEError raising
BasePPolymorphic
BaseIEIndex aware, polymorphic.
BaseEPError raising, polymorphic
BaseIEPIndex aware, error raising, polymorphic.

Index aware constructors require additional parameters and return values. Non error raising constructors are required to create Pure optics. Polymorphic constructors require additional type parameters.

Getters #

Getters return exactly one result, cannot be written to and are unidirectional. They are primarily used for computed values that cannot be reversed.

1
2
3
4
5
	result := MustGet(
		Eq("alpha"),
		"beta",
	)
	fmt.Println(result)

The Op() family of constructors is provided to make it easy to wrap a go function into a getter

1
2
3
4
5
6
7
8
9
	result := MustGet(
		Op(
			func(source []string) int {
				return len(source)
			},
		),
		[]string{"alpha", "beta"},
	)
	fmt.Println(result)

Existing functions can also be wrapped.

1
2
3
4
5
6
	result := MustModify(
		TraverseSlice[string](), //Optic
		Op(strings.ToUpper),     //Modify Operation
		[]string{"alpha", "beta"},
	)
	fmt.Println(result)

This example returns

1
		[]string{"ALPHA", "BETA"},

Lenses #

Lenses return exactly one result, can be written to and are unidirectional. They are most often used to provide access the fields of a struct using the built in FieldLens

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
	type ExampleStruct struct {
		name    string
		address string
	}

	nameField := FieldLens(
		func(source *ExampleStruct) *string {
			return &source.name
		},
	)

	data := ExampleStruct{
		name:    "Max Mustermann",
		address: "Musterstadt",
	}

	result := MustSet(nameField, "Erika Mustermann", data)
	fmt.Println(result)

Lenses to access the fields of a struct are usually auto-generated using the makelens tool

1
2
3
4
5
6
7
8
9
	result := MustSet(
		O.BlogPost().Content(),
		"New Content",
		NewBlogPost(
			"Content",
			nil,
		),
	)
	fmt.Println(result)

The makelens tool generated the data.O.BlogPost().Content() optic providing access to the BlogPost.content field.

Isos #

Isos return exactly one result and are bidirectional. Writing to an Iso is equivalent to the reverse operation. Isos are named after isomorphism as the conversion from source to focus and back should be lossless so the two types are isomorphic to one another.

In go-optics the unary mathematical operations are implemented as Isos This has the property of providing the inverse conversion automatically.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
	celsiusToFahrenheit := Compose(
		Mul(1.8),
		Add(32.0),
	)

	fahrenHeit := MustGet(celsiusToFahrenheit, 32.0)
	fmt.Println(fahrenHeit)

	celsius := MustReverseGet(celsiusToFahrenheit, 89.6)
	fmt.Println(celsius)

Here we were able to compose a Mul with an Add and using ReverseGet we can perform the reverse conversion.

The AsReverseGet combinator is able to reverse the direction of an Iso

1
2
3
4
5
6
7
8
9
	celsiusToFahrenheit := Compose(Mul(1.8), Add(32.0))

	celsius := MustGet(
		AsReverseGet(
			celsiusToFahrenheit,
		),
		89.6, //Fahrenheit
	)
	fmt.Println(celsius)

Traversals & Iterations #

Traversals return 0 or more results, can be written to and are unidirectional. Traversals are used to iterate over and immutably update the contents of a container.

In addition to the built in traversals user defined traversals can be constructed using the Traversal() family of constructors. This enables new container data types to be supported by go optics.

1
2
3
4
5
6
7
8
	for v := range MustGet(
		SeqOf(
			TraverseSlice[int](),
		),
		[]int{1, 2, 4, 5},
	) {
		fmt.Println(v)
	}

SeqOf is a Combinator that focuses an iter.Seq of the foci of an optic. TraverseSlice is a Traversal that focuses the elements of a slice.

A Traversal can also be written to which will return a copy of the original data structure with the modification applied.

1
2
3
4
5
6
	result := MustModify(
		TraverseSlice[int](),
		Mul(2),
		[]int{1, 2, 3, 4},
	)
	fmt.Println(result)

Mul(2) is an Iso that multiplies the value by 2.

The result of the example is

1
		[]int{2, 4, 6, 8},

A read only Traversal is called an Iteration. Traversals are also index aware. The Index combinator can be used to focus an element at a given index.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
	result, found := MustGetFirst(
		Index(
			TraverseMap[string, int](), //Optic
			"beta",                     //Index to access
		),
		map[string]int{
			"alpha": 1,
			"beta":  2,
			"gamma": 3,
			"delta": 4,
		},
	)
	fmt.Println(result, found)

TraverseMap]string,int]() is a Traversal that focuses the elements of a map[string}int.

The result of the example is

1
		2, true,

Prisms #

Prisms return 0 or 1 results, can be written to and are bidirectional. Prisms are most often used to perform type safe conversions from a super type to a sub type. If the cast fails then 0 results are returned. If the cast succeeds then the cast value is returned.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
	result := MustGet(
		SliceOf(
			Compose(
				TraverseSlice[any](),
				DownCast[any, int](),
			),
			3, //Initial size of slice
		),
		[]any{1, "two", 3},
	)
	fmt.Println(result)

SliceOf is a combinator that creates a slice from the focused elements. TraverseSlice[any]() is a Traversal that focuses the elements of a []any DownCase[any,int]() is a Prism that focuses an int if the case from any succeeds.

This example returns.

1
		[]int{1, 3},

Notice that the string "two" is missing from the result.

Prisms can also be written to.

1
2
3
4
5
6
7
8
9
	result := MustModify(
		Compose(
			TraverseSlice[any](),
			DownCast[any, int](),
		),
		Mul(2),
		[]any{1, "two", 3},
	)
	fmt.Println(result)

Mul(2) is an Iso that multiples a value by 2.

The result of this example is

1
		[]any{2, "two", 6},

The integer values have been multiplied by 2. The string was ignored.

Compose #

Compose enables optics to focus deeply into nested structures by combining Optics together in a chain.

For a simple BlogPost data structure

1
2
3
4
5
6
7
8
type Comment struct {
	title   string
	content string
}
type BlogPost struct {
	content  string
	comments []Comment
}

We can define the following optics

1
2
3
4
5
6
7
	blogComments := FieldLens(func(source *BlogPost) *[]Comment {
		return &source.comments
	})

	commentTitle := FieldLens(func(source *Comment) *string {
		return &source.title
	})

FieldLenses focus the given field of the parent struct.

The blogComments lens focuses the BlogPost.comments field

Optic

The commentTitle lens focuses the Comment.title field

Optic

We can use Compose to combine these lenses to focus the Comments nested within a BlogPost

1
2
3
4
5
	blogCommentTitles := Compose3(
		blogComments,
		TraverseSlice[Comment](),
		commentTitle,
	)

In this example we have chained together 3 optics of differing types.

  1. blogComments is a lens with source type BlogPost that focuses on the Comments field which is of type []Comment
  2. TraverseSlice[Comment] is a traversal with source type []Comment that focuses on each Comment in the slice.
  3. commentTitle is a lens with source type Comment that focuses on the title field which is of type string

We can visualise this chaining in this way.

Blog comment’s title

Notice that the focus of each optic matches the source type of the next optic in the composed chain. Index types however do not need to match TraverseSlice has an int index however comment.Title is un-indexed.

The resulting blogCommentTitles optic has a source type of BlogPost and a focus type of string where that string is the title field of a Comment

This composed Optic is fully compatible with the built in Actions

1
2
3
4
5
6
7
8
	for commentTitle := range MustGet(
		SeqOf(
			blogCommentTitles,
		),
		BlogPost{ /*...*/ },
	) {
		fmt.Println(commentTitle)
	}

This will iterate over the title of each comment in a BlogPost

The composed optic is still write capable.

1
2
3
4
5
	updatedBlogPost := MustModify(
		blogCommentTitles,
		Op(strings.ToUpper),
		BlogPost{ /*...*/ },
	)

The effect of this MustModify action is to convert all comment titles to upper case.

In the visualisation the connecting arrows indicate a flow to the right but for modifiable optics the arrows flow in both directions. We can visualise the MustModify in this way.

Blog comment’s title

We feed a blogPost into the left hand source. The []Comment is extracted and fed to TraverseSlice which feeds each Comment to commentTitle which in turn extracts the comment title. This is then passed to ToUpper which returns an upper case string. This result is then fed back through the whole flow in reverse order. commentTitle packs the upper case title back into a new copy of the Comment struct with the updated title. This is then in turn packed into a new []Comment slice by the TraverseSlice and is then in turn packed into a new copy of the original blogPost by blogComments. In this way the optic has focused on the exact field we wanted to modify and then rebuilt a new copy of the original structure on the return path without modifying the original. This is how go optics achieves immutable data type support.

Note that the index value only flows to the right. Index values cannot be updated during an update of a Traversal

For this use case of delving into a nested structure this compose syntax is verbose. It is advised to use the makelens tool to generate helpers that make this use case much more intuitive. The above example can be simplified to the following when using makelens

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
	for commentTitle := range MustGet(
		SeqOf(
			O.BlogPost().Comments().Traverse().Title(),
		),
		NewBlogPost(
			"Content",
			[]Comment{
				NewComment("First Comment", "My comment"),
				NewComment("Second Comment", "Another comment"),
			},
		),
	) {
		fmt.Println(commentTitle)
	}

behind the scenes the O.BlogPost().Comments().Traverse().Title() is performing all the necessary composition.

When using Compose the type of the return optic is determined by the types of the input optics.

GetterLensIterationTraversalIsoPrism
GetterGetterGetterIterationIterationGetterIteration
LensGetterLensIterationTraversalLensTraversal
IterationIterationIterationIterationIterationIterationIteration
TraversalIterationTraversalIterationTraversalTraversalTraversal
IsoGetterLensIterationTraversalIsoPrism
PrismIterationTraversalIterationTraversalPrismPrism

The important thing to note from this table is that it is complete. Every optic type can be composed with every other optic type and a correctly functioning optic will be returned.

Compose by default retains the index of the right most optic. This makes sense when composing with a Traversal as traversals typically are indexed.

However in this example.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
	index, result, ok := MustGetFirstI(
		Compose3(
			FieldLens(func(source *BlogPost) *[]Comment { return &source.comments }),
			TraverseSlice[Comment](),
			FieldLens(func(source *Comment) *string { return &source.title }),
		),
		BlogPost{
			comments: []Comment{
				Comment{title: "First Comment"},
				Comment{title: "Second Comment"},
			},
		},
	)
	fmt.Println(index, result, ok)

After the TraverseSlice we composed a FieldLens which does not have an index.

Optic We can however maintain this index in the resulting Optic by using ComposeLeft

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
	index, result, ok := MustGetFirstI(
		Compose(
			FieldLens(func(source *BlogPost) *[]Comment { return &source.comments }),
			ComposeLeft(
				TraverseSlice[Comment](),
				FieldLens(func(source *Comment) *string { return &source.title }),
			),
		),
		BlogPost{
			comments: []Comment{
				Comment{title: "First Comment"},
				Comment{title: "Second Comment"},
			},
		},
	)
	fmt.Println(index, result, ok)

Optic

The ComposeLeft has retained the [int] index of the TraverseSlice. As a convenience the Optics generated by makelens use ComposeLeft when the right side of the Compose is un-indexed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
	index, result, ok := MustGetFirstI(
		O.BlogPost().Comments().Traverse().Title(),
		NewBlogPost(
			"Content",
			[]Comment{
				NewComment("First Comment", "My comment"),
				NewComment("Second Comment", "Another comment"),
			},
		),
	)
	fmt.Println(index, result, ok)

Combinators #

Combinators are functions that take an optic as a parameter and return a new optic with modified behaviour. The optic returned by a combinator may not be the same type (Lens,Traversal,..) as the input optic.

Go-optics provides a wide variety of other combinators that modify the behavior of another optic.

We have already used several combinators

  • Compose is in fact a combinator that combines multiple Optics together.
  • Index returns an Optic that focuses the elements with a given index.
  • Filtered returns an Optic that focuses the elements that match a given predicate.
  • AsReverseGet returns an OPtic that reverses the direction of an Iso
  • SeqOf returns an Optic that focuses an iter.Seq of the focused elements.
  • SliceOf returns an Optic that focuses a slice of the focused elements.

An important combinator is Filtered

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
	result := MustModify(
		Filtered( //Combinator
			TraverseSlice[int](),
			AndOp( //Combinator
				Gt(10),
				Lt(40),
			),
		),
		Compose( //Combinator
			Add(10),
			Mul(2),
		),
		[]int{10, 20, 30, 40, 50},
	)
	fmt.Println(result)
  • Filtered is a combinator that focuses only elements that meet the Predicate
  • TraverseSlice[int]() is a Traversal that focuses the elements of a []int

Filtered uses the types from TraverseSlice[int]() as it’s own source and focus types, the index is retained as well. The int focus type of TraverseSlice[int] defines the type of the Predicate.

In this case the Predicate is AndOp which is a combinator that applies a boolean and to the focus of it’s 2 input optics.

  • Gt(10) is a Predicate that returns true if the value is greater than 10.
  • Lt(40) is a Predicate that returns true if the value is less than than 40.

The modify operation can also be a combined optic. In this case we composed Add(10) with Mul(2) creating a combined effect of (focus+10)*2

The result of this example is

1
		[]int{10, 60, 80, 40, 50},

Every value >10 and <40 had the operation (focus+10)*2 applied.

One of the most important concepts in go-optics is that predicates and modify operations are also optics and can be combined together using combinators.

The Identity Rule #

When reading from an Optic the set of focused elements will be returned. If filtering is involved then the filtered results will be missing from the read result. If re-ordering has occurred then the results will be delivered in the order defined by the optic.

1
2
3
4
5
6
7
8
9
	result := MustModify(
		Filtered(
			TraverseSlice[int](),
			Lt(10),
		),
		Mul(2),
		[]int{1, 2, 30, 4, 5},
	)
	fmt.Println(result)

When using MustModify the values focused by the filter are updated and the non matching values are passed through unmodified into the result.

1
		[]int{2, 4, 30, 8, 10},

The identity rule states that passing Identity() as the operation to a modify action should return the original data structure back . Identity() is an operation that returns the source directly back as the focus.

If we change Mul(2) to Identity()

1
2
3
4
5
6
7
8
9
	result := MustModify(
		Filtered(
			TraverseSlice[int](),
			Lt(10),
		),
		Identity[int](),
		[]int{1, 2, 30, 4, 5},
	)
	fmt.Println(result)

Then due to the Identity Rule the result must be

1
		[]int{1, 2, 30, 4, 5},

This can only be true if the filtered combinator retains the unmatched values in the output. This applies to all built in combinators provided by go optics.

The same logic applies to re-ordering

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
	result := MustGet(
		SliceOf(
			Ordered(
				TraverseSlice[int](),
				OrderBy[int, int](Identity[int]()),
			),
			10, //initial size of slice
		),
		[]int{5, 4, 30, 2, 1},
	)
	fmt.Println(result)

Reading the re-ordered data returns a sorted slice.

1
		[]int{1, 2, 4, 5, 30},

However under modification the data is returned in the original order.

1
2
3
4
5
6
7
8
9
	result := MustModify(
		Ordered(
			TraverseSlice[int](),
			OrderBy[int, int](Identity[int]()),
		),
		Mul(2),
		[]int{5, 4, 30, 2, 1},
	)
	fmt.Println(result)
1
		[]int{10, 8, 60, 4, 2},

This may seem to make Ordered pointless. However the elements were focused in order. In conjunction with other combinators the effect of this ordered focus can be used effectively.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	result := MustModify(
		Taking(
			Ordered(
				TraverseSlice[int](),
				OrderBy[int, int](Identity[int]()),
			),
			3, //Take 3
		),
		Mul(2),
		[]int{5, 4, 30, 2, 1},
	)
	fmt.Println(result)

Taking(...,3) focuses on the first 3 elements. In this case this is the first 3 ordered elements. Which are then multiplied by 2 and then restored to their original positions in the slice.

1
		[]int{5, 8, 30, 4, 2},

The net effect is that the lowest 3 elements are modified in place.

Another impact of the Identity Rule is that under modification arithmetic operations are applied the modification is made and the the arithmetic operations are reversed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
	celsiusToFahrenheit := Compose(
		Mul(1.8),
		Add(32.0),
	)

	celsiusResult := MustModify(
		celsiusToFahrenheit,
		Add(1.0),
		32,
	)
	fmt.Println(celsiusResult)

The result of this example is

1
		32.55555555555555,

The Add(1.0) was an increase of 1 Fahrenheit which corresponds to an ~0.5 increase in Celsius. Note that we only specified the conversion to Fahrenheit. Mul and Add are Isos and automatically provide the ReverseGetter to reverse the conversion.

This behaviour is a result of the identity rule as if the Add(1) were replaced with identity the result must be the original Celsius value.

This may seem counter-intuitive initially but this can be used to create “virtual” fields or conversions. Suppose our underlying data is stored in celsius we can provide an Optic that performs the conversion to fahrenheit.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type Data struct {
	celsiusValue float64
}

func CelsiusValue() Optic[Void, Data, Data, float64, float64, ReturnOne, ReadWrite, UniDir, Pure] {
	return FieldLens(func(source *Data) *float64 { return &source.celsiusValue })
}

func FahrenheitValue() Optic[Void, Data, Data, float64, float64, ReturnOne, ReadWrite, UniDir, Pure] {
	return Ret1(Rw(Ud(EPure(Compose(
		CelsiusValue(),
		Compose(
			Mul(1.8),
			Add(32.0),
		),
	)))))
}

Users are now free to work in both fahrenheit or celsius by selecting the correct Optic