{"id":19115398,"url":"https://github.com/polytypic/loko-ml","last_synced_at":"2025-10-10T13:44:05.865Z","repository":{"id":58594302,"uuid":"531392642","full_name":"polytypic/loko-ml","owner":"polytypic","description":"Lower-Kinded Optics for OCaml","archived":false,"fork":false,"pushed_at":"2022-10-09T15:13:25.000Z","size":66,"stargazers_count":21,"open_issues_count":0,"forks_count":0,"subscribers_count":2,"default_branch":"main","last_synced_at":"2023-12-19T16:05:41.357Z","etag":null,"topics":["hobby-project","isomorphisms","lenses","optics","traversals","wip"],"latest_commit_sha":null,"homepage":"https://polytypic.github.io/loko-ml/loko/Loko/index.html","language":"OCaml","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/polytypic.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2022-09-01T06:29:36.000Z","updated_at":"2023-04-06T06:12:28.000Z","dependencies_parsed_at":"2023-01-19T18:01:21.652Z","dependency_job_id":null,"html_url":"https://github.com/polytypic/loko-ml","commit_stats":null,"previous_names":[],"tags_count":0,"template":null,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/polytypic%2Floko-ml","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/polytypic%2Floko-ml/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/polytypic%2Floko-ml/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/polytypic%2Floko-ml/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/polytypic","download_url":"https://codeload.github.com/polytypic/loko-ml/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":223786795,"owners_count":17202603,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["hobby-project","isomorphisms","lenses","optics","traversals","wip"],"created_at":"2024-11-09T04:46:16.553Z","updated_at":"2025-10-10T13:44:00.835Z","avatar_url":"https://github.com/polytypic.png","language":"OCaml","funding_links":[],"categories":[],"sub_categories":[],"readme":"[API reference](https://polytypic.github.io/loko-ml/doc/loko/Loko/index.html)\n\n# Lower-Kinded Optics for OCaml\n\nAn optic is an abstraction that knows how to take apart or transform a data\nstructure in some particular way and then put the data structure back together\nto provide _focuses_ for\n[CRUD](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete)\noperations. Optics for complex data structures can be built compositionally\nfollowing the structure and respecting the invariants of the data structure.\n\nThis library uses an approach to implementing optics that provides the following\nfeatures:\n\n- Optics are functions and can be composed with ordinary function composition\n- Optics have simple polymorphic types\n- Optic classes:\n  - **Isomorphisms** bidirectionally targeting the whole transformed data\n  - **Lenses** targeting a single focus\n  - **Prisms** optionally targeting a matching focus\n  - **Traversals** targeting arbitrary numbers of focuses\n- Operations over optics:\n  - (**C**) Setting an empty focus to a new value\n  - (**R**) Viewing a focus or folding over focuses\n  - (**U**) Mapping over focuses to update them\n  - (**D**) Requesting to remove focuses\n\n## Introduction\n\nThe following subsections introduce some aspects of Loko via simple examples.\n\n### Basics\n\nTo begin, we bind the `Loko` library module to the module abbreviation `L`:\n\n```ocaml\nmodule L = Loko\n```\n\nThis is not strictly necessary, of course, but it helps to keep things a bit\nmore concise. The Loko library does not provide a large number of infix\noperators, so one can usually get away without opening the `Loko` module.\n\nAs a first example, let's just try out the `L.fst` and `L.snd` *optic*s. They\nare a pair of optics that focus on the first and second elements of a pair,\nrespectively. Using the `L.view` _operation_ we can then use those optics to get\nthe first\n\n```ocaml\n# L.view L.fst (42, \"answer\")\n- : int = 42\n```\n\nand second\n\n```ocaml\n# L.view L.snd (42, \"answer\")\n- : string = \"answer\"\n```\n\nelement of a pair of _data_.\n\nOn the other hand, using the `L.over` operation we can use those optics to map\nover or modify the first and second element of a pair and obtain a new pair. For\nexample:\n\n```ocaml\n# L.over L.fst Int.to_string (42, \"answer\")\n- : string * string = (\"42\", \"answer\")\n```\n\nTo summarize, we use an _optic_ to specify focuses inside a _data_ structure for\nan _operation_ to operate on:\n\n```\noperation optic data\n    ▲       ▲    ▲\n    ┃       ┃    ┃\n    ┃       ┃    ┗━━ The whole data structure we operate on\n    ┃       ┃\n    ┃       ┗━━ What parts of the data structure we want to focus on\n    ┃\n    ┗━━ What operation we want to perform on the focuses\n```\n\n### Types of optics\n\nIn the previous section we used the `L.over` operation with the `L.fst` optic to\nupdate the first element of a pair such that the type of the element became\n`string` instead of the `int` as in the original pair. Optics in Loko generally\nallow one to perform *polymorphic update*s where possible. Let's take a closer\nlook at the types of optics.\n\nThe signatures of `L.fst` and `L.snd` can be written as\n\n```ml\nval fst : ('L1 * 'R, 'L1, 'L2, 'L2 * 'R) optic\nval snd : ('L * 'R1, 'R1, 'R2, 'L * 'R2) optic\n```\n\nwhere `optic` is the type abbreviation\n\n```ml\ntype (-'S, +'F, -'G, +'T) optic = ('F, 'G) pipe -\u003e ('S, 'T) pipe\n```\n\nand `pipe`\n\n```ml\ntype (-'S, +'T) pipe\n```\n\nis an opaque type.\n\nWith four type parameters the `optic` type likely seems puzzling. Let's break it\ndown. The following shows the meanings of the type parameters:\n\n```\n(-'S, +'F, -'G, +'T) optic\n  ▲    ▲    ▲    ▲\n  ┃    ┃    ┃    ┃\n  ┃    ┃    ┃    ┗━━ Type of output data structure in a write operation\n  ┃    ┃    ┃\n  ┃    ┃    ┗━━ Type of values to replace focuses in a write operation\n  ┃    ┃\n  ┃    ┗━━ Type of values in focuses to operate on\n  ┃\n  ┗━━ Type of data structure given as input\n```\n\nSo, looking at e.g. the signature of `L.fst`\n\n```ml\nval fst : ('L1 * 'R, 'L1, 'L2, 'L2 * 'R) optic\n```\n\nwe can see that it takes a pair `'L1 * 'R` as input and selects focuses of type\n`'L1` from that input. In case of a write operation, those focuses can take\nvalues of type `'L2` and the end result of a write operation is a pair of type\n`'L2 * 'R`.\n\nNote that the type does not say how many focuses `L.fst` has. In the case of\n`L.fst` it is exactly one, which makes `L.fst` a _lens_, but this is not part of\nthe type.\n\nIn summary, optics are functions and the `optic` type abbreviation takes four\ntype parameters that specify the type of the whole input, the type of focuses\nselected from input, the type of values potentially written to focuses, and the\ntype of the resulting output, respectively.\n\n### Nested composition of optics\n\nWhat makes the separation of optics from operations interesting is the ability\nto compose optics. As we just saw in the previous section optics in Loko are\n\"just\" functions. In this section we will see that we can also compose optics\njust like ordinary functions.\n\nThe `Infix` submodule of Loko provides the `%` operator for function\ncomposition. Let's open it for convenience:\n\n```ocaml\nopen L.Infix\n```\n\nThe `%` operator symbol was chosen as it seems to be provided by some existing\nOCaml libraries.\n\nWhat function composition allows us to do with optics is to compose them in\norder to operate on nested data structures. For example, let's say that we want\nto focus on the second element of the first pair of nested pairs. We can compose\nan optic for that as `L.fst % L.snd` and use that with various operations. For\nexample, we can use `L.view`\n\n```ocaml\n# L.view (L.fst % L.snd) ((3, \"1\"), ('4', true))\n- : string = \"1\"\n```\n\nto view such a nested focus or we could use `L.over`\n\n```ocaml\n# L.over (L.fst % L.snd) int_of_string ((3, \"1\"), ('4', true))\n- : (int * int) * (char * bool) = ((3, 1), ('4', true))\n```\n\nto modify such a nested focus.\n\nUsing ordinary function composition to deal with nested data structures isn't\nthe only way to compose or build more complex optics from simpler optics, but it\nis probably the most common.\n\nTo summarize, using function composition we can compose optics in order to\noperate on nested data structures. Furthermore, to emphasize a previously made\npoint, a single optic composition may be used with many different operations.\nThis separation of the selection of focuses from the operation to perform on\nthem is what can make using optics preferable over traditional methods when\noperating on complex data structures.\n\n### Traversals and folds\n\nWe have so far only used optics called lenses that always have a single focus.\nTraversals are optics that have arbitrarily many focuses. For example, the\n`L.List.elems` traversal focuses on all the elements of a list. Using the\n`L.over` operation with the `L.List.elems` traversal we can map over a list:\n\n```ocaml version\u003e=5.0.0\n# L.over L.List.elems (( + ) 1) [2; 0; 3]\n- : int list = [3; 1; 4]\n```\n\nTraversals can also be composed with other optics. For example, given a list of\npairs, we could use the composition `L.List.elems % L.fst` to operate on all the\nfirst elements of pairs on a list:\n\n```ocaml version\u003e=5.0.0\n# L.over (L.List.elems % L.fst) (( + ) 1) [(3, 2); (6, 6)]\n- : (int * int) list = [(4, 2); (7, 6)]\n```\n\nThe potential number of focuses an optic composition has works like\nmultiplication and composing a traversal with any other optic also yields a\ntraversal.\n\nAside from using `L.over` to map over all the focuses of a traversal we can also\nuse `L.view` to extract the first, if any, focus:\n\n```ocaml\n# L.view L.List.elems [4; 1]\n- : int = 4\n```\n\nIn case there are no focuses, `L.view` fails with an exception. If such a case\nis to be expected, one should e.g. use `L.view_opt`, which returns `None` in\ncase there are no focuses\n\n```ocaml\n# L.view_opt L.List.elems []\n- : 'a option = None\n```\n\nor `Some`\n\n```ocaml\n# L.view_opt L.List.elems [1; 1; 2]\n- : int option = Some 1\n```\n\nin case there is at least one.\n\nMore generally we can also _fold_ over the focuses of a traversal. For example,\nwe could use `L.fold` with `L.List.elems % L.fst` to sum the first elements of\npairs of a list:\n\n```ocaml\n# L.fold 0 ( + ) (L.List.elems % L.fst) [(3, 2); (6, 6)]\n- : int = 9\n```\n\nOr we could use `L.collect` to get a list of the focuses:\n\n```ocaml\n# L.collect (L.List.elems % L.fst) [(3, 2); (6, 6)]\n- : int list = [3; 6]\n```\n\nNotice again how the optic composition, `L.List.elems % L.fst`, remains the same\nwhile the operation performed varies.\n\nIt is also possible to use `L.parts_of` to convert a traversal into a lens that\nproduces an array of the focuses:\n\n```ocaml\n# L.view (L.parts_of (L.List.elems % L.fst)) [(3, 2); (6, 6)]\n- : int array = [|3; 6|]\n```\n\nDoing so allows the array to be treated as a whole. We could, for example, view\nit as a list using `L.Array.as_list` and then use `L.over` with `List.rev` to\nreverse the order of values in the focuses:\n\n```ocaml version\u003e=5.0.0\n# L.over (L.parts_of (L.List.elems % L.fst) % L.Array.as_list) List.rev\n    [(3, 2); (6, 6)]\n- : (int * int) list = [(6, 2); (3, 6)]\n```\n\nIn summary, lenses, which always have a single focus, are not the only class of\noptics. Traversals are optics that have arbitrary numbers of focuses. Different\nclasses of optics can be composed together. We can perform a fold over the\nfocuses an optic selects from a data structure.\n\n### Isomorphisms\n\nIsomorphisms, like lenses, always have a single focus. The difference is that\nwith an isomorphism the focus is the whole data structure given as input to the\nisomorphism. Conversely, the output of an isomorphism is not dependent on the\ninput and is determined solely by the value written through the isomorphism.\nThis means that the read and write directions of an isomorphism are both just\nsingle parameter functions and it is possible to implement them in such a way\nthat they can be inverted \u0026mdash; reversing the read and write directions.\n\nFor example, given an isomorphism `plus 2`, where `plus` is implemented as\n\n```ocaml\nlet plus n = L.iso (fun x -\u003e x + n) (fun x -\u003e x - n)\n```\n\nwe can not only read and write through it\n\n```ocaml\n# L.view (plus 2) 1\n- : int = 3\n# L.set (plus 2) 3 42\n- : int = 1\n```\n\nbut we can also use `L.re` to invert it and still read and write through it:\n\n```ocaml\n# L.view (L.re (plus 2)) 3\n- : int = 1\n# L.set (L.re (plus 2)) 1 42\n- : int = 3\n```\n\n`L.re` only works on isomorphisms, created with `L.iso`, or prisms, which we'll\ntalk about in the next section, and with compositions of such optics. An attempt\nto invert other kinds of optics will raise.\n\nInstead of using `L.set` to run an isomorphism in the inverted direction we can\nalso use the `L.review` operation which doesn't require the (ignored) initial\ninput:\n\n```ocaml\n# L.review (plus 2) 3\n- : int = 1\n```\n\nIsomorphisms in Loko do not need to be strictly invertible functions. For\nexample, the `L.truncate` isomorphism truncates `float`s to `int`s or, in the\nother direction, converts `int`s to `float`s. Both of these directions may be\nlossy.\n\n```ocaml\n# (1 lsl 61) = ((1 lsl 61) + 1)\n- : bool = false\n# L.review L.truncate (1 lsl 61) = L.review L.truncate ((1 lsl 61) + 1)\n- : bool = true\n```\n\nIsomorphisms can also be composed with other optics and the end result generally\nhas the same properties as the other optics.\n\nIn summary, isomorphisms target the whole data structure given as input and can\nbe inverted. Isomorphisms can be composed with other optics.\n\n### Prisms\n\nThe kinds of optics we've discussed so far work in a kind of unconditional\nmanner. In particular, lenses and isomorphisms always have a single focus. Also,\nbasic traversals over collections focus on all the elements of a collection.\nWhat if there isn't always a value to focus on, such as can be the case with sum\ntypes, like `option`al values? Or how would one go about conditional selection\nof focuses from a collection? Focusing on an optional value is not going to work\nwith a lens or an isomorphism. A traversal can focus on an optional value, but\nthere is a more specific class of optics called *prism*s that are not only\ntraversals, but also have at most one focus and can be inverted like\nisomorphisms.\n\nIndeed, the traversal over the elements of an option is also a prism, and can be\ninverted:\n\n```ocaml\n# L.review L.Option.elems 1\n- : int option = Option.Some 1\n```\n\nTODO\n\n### Removal of focuses\n\nA special feature of the Loko library is that it supports removal of focuses. If\nyou are coming from some other optics library, that may sound puzzling. Let's\nbuild up our understanding via a series of examples. Suppose we have an array\nthat contains lists of integers. We can write a simple traversal that targets\nthose and use a fold to collect them:\n\n```ocaml\n# L.collect\n    (L.Array.elems\n    % L.List.elems)\n    [| [3; -1; 1]; [-2; -3]; [-4; 4] |]\n- : int list = [3; -1; 1; -2; -3; -4; 4]\n```\n\nSuppose then that we want to remove the negative elements. We continue by\nrefining the traversal to accept only the negative elements:\n\n```ocaml\n# L.collect\n    (L.Array.elems\n    % L.List.elems\n    % L.accept (fun x -\u003e x \u003c 0))\n    [| [3; -1; 1]; [-2; -3]; [-4; 4] |]\n- : int list = [-1; -2; -3; -4]\n```\n\nTo remove the negative elements we just use the `L.remove` operation:\n\n```ocaml version\u003e=5.0.0\n# L.remove\n    (L.Array.elems\n    % L.List.elems\n    % L.accept (fun x -\u003e x \u003c 0))\n    [| [3; -1; 1]; [-2; -3]; [-4; 4] |]\n- : int list array = [|[3; 1]; []; [4]|]\n```\n\nSuppose further that we'd also like to remove any empty lists from the array. We\ncan achieve that e.g. by composing `L.as_removed []` before the list traversal:\n\n```ocaml version\u003e=5.0.0\n# L.remove\n    (L.Array.elems\n    % L.as_removed []\n    % L.List.elems\n    % L.accept (fun x -\u003e x \u003c 0))\n    [| [3; -1; 1]; [-2; -3]; [-4; 4] |]\n- : int list array = [|[3; 1]; [4]|]\n```\n\n`L.remove o s` is actually a shorthand for `L.over (o % L.removed) ignore s`. In\nother words, an optic can, by itself, signal removal during a write operation.\n`L.as_removed value` signals removal when the element written through it is\nequal to the given `value`.\n\nThe way removal works is that during a write operation a focus can be \"signaled\"\nas being removed. That \"signal\" needs to be handled. Not all optics can handle\nremoval. For example, what if you signal the removal of the first element of a\npair? Well, it cannot work, of course, as we can verify using `L.can_remove`:\n\n```ocaml\n# L.can_remove L.fst (\"Computer\", \"says no\")\n- : bool = false\n```\n\nOptics that cannot handle removal propagate the signal upwards and if no optic\nhandles it, no result can be produced for the operation. Generally speaking\ntraversals for collection types, such as lists, arrays, and options, can handle\nremoval. For example, if we put a pair inside an option, removal can be done:\n\n```ocaml\n# L.can_remove (L.Option.elems % L.fst) (Some (\"Computer\", \"says no\"))\n- : bool = true\n```\n\nThe result is the removal of the whole pair:\n\n```ocaml\n# L.remove (L.Option.elems % L.fst) (Some (\"Computer\", \"says no\"))\n- : ('a * string) option = Option.None\n```\n\nWe could also have the option inside the first element of the pair:\n\n```ocaml\n# L.remove (L.fst % L.Option.elems) (Some (\"Computer\"), \"says no\")\n- : 'a option * string = (Option.None, \"says no\")\n```\n\nIt is also possible to compose an optic to explicitly handle removal in some\nway. For example, `L.removed_as_none` changes the type of the focus on write:\n\n```ocaml\n# L.remove (L.fst % L.removed_as_none) (\"Computer\", \"says no\")\n- : 'a option * string = (None, \"says no\")\n```\n\nAnother option is to replace a removed focus with a given value using\n`L.removed_as`:\n\n```ocaml\n# L.remove (L.fst % L.removed_as \"Nobody\") (\"Computer\", \"says no\")\n- : string * string = (\"Nobody\", \"says no\")\n```\n\nIn summary, Loko has special support to request removal of focuses. Certain\noptics signal removal. The signal to remove an element needs to be handled by\nanother optic in the composition. Some optics handle removal by default and\nthere are optics specifically for specifying how to handle removal.\n\n### Maintaining invariants\n\n```ocaml\nlet eta'1 fn x1 x2 = fn x1 x2\n```\n\n```ocaml\ntype ('k, 'v) bt = [ `Lf | `Br of ('k, 'v) bt * 'k * 'v * ('k, 'v) bt ]\n```\n\n```ocaml\nlet a_tree : (_, _) bt =\n  `Br\n    ( `Br (`Lf, 2, \"s\", `Br (`Lf, 4, \"o\", `Lf)),\n      5,\n      \"g\",\n      `Br (`Lf, 7, \"i\", `Br (`Lf, 11, \"c\", `Lf)) )\n```\n\n```ocaml\nlet on_br p =\n  p |\u003e L.prism (function `Br x -\u003e Right x | `Lf -\u003e Left `Lf) (fun x -\u003e `Br x)\n```\n\n```ocaml\nlet key p = p |\u003e on_br % L.elem_2_of_4\nlet smaller p = p |\u003e on_br % L.elem_1_of_4\nlet greater p = p |\u003e on_br % L.elem_4_of_4\n```\n\n```ocaml\nlet rec naive_bst p =\n  L.rewrite (function\n    | `Lf -\u003e `Lf\n    | `Br (l, k, Some v, r) -\u003e `Br (l, k, v, r)\n    | `Br (`Lf, _, None, t) | `Br (t, _, None, `Lf) -\u003e t\n    | `Br (`Br (l, k, v, m), _, None, r) -\u003e\n      L.set (node_of k) (`Br (l, k, Some v, m)) r)\n  @@ L.removed_as `Lf\n  @@ p\n\nand node_of k' =\n  L.cond_of key\n  @@ L.case (fun k -\u003e k' \u003c k) (smaller % eta'1 node_of k')\n  @@ L.case (fun k -\u003e k \u003c k') (greater % eta'1 node_of k')\n  @@ L.otherwise naive_bst\n```\n\n```ocaml\nlet value_of k =\n  node_of k\n  % L.lens\n      (function `Lf -\u003e None | `Br (_, _, v, _) -\u003e Some v)\n      (fun v -\u003e function\n        | (`Lf as l as r) | `Br (l, _, _, r) -\u003e `Br (l, k, v, r))\n  % L.removed_as_none\n```\n\n```ocaml\nlet rec inorder p =\n  p\n  |\u003e naive_bst % on_br % L.branch'4 inorder L.zero L.removed_as_none inorder\n```\n\n```ocaml\n# a_tree\n  (* Update: *)\n  |\u003e L.set (value_of 2) \"M\"\n  (* Delete: *)\n  |\u003e L.remove (value_of 4)\n  (* Create: *)\n  |\u003e L.set (value_of 3) \"a\"\n  (* Read: *)\n  |\u003e L.concat \"-\" inorder\n- : string = \"M-a-g-i-c\"\n```\n\n## On naming\n\n- `to_x` + `of_x` -\u003e `as_x`\n\n## Background\n\nThe basic implementation technique used in this library was originally developed\nin a C# project (not available as open-source). Later a\n[prototype F# library](https://github.com/polytypic/NetOptics) was also\ndeveloped. This version attempts to take the approach further.\n\nThe implementation technique has two nice properties: optics are just functions\nand higher-kinded types are not needed. On the other hand, compared to some\nother approaches to implementing optics, the class (or the number of focuses) of\nan optic is not encoded in the type (although it could likely be done using\nphantom types). This means that operations that e.g. require an optic to have at\nleast one focus on given data or that require an optic to be invertible may\nraise exceptions.\n\nThe goal to support removing focuses comes after realizing the usefulness of the\nability from working with\n[partial.lenses](https://github.com/calmm-js/partial.lenses). In partial.lenses\nthe ability comes naturally due to the structural nature of data in JavaScript\nand being able to\n[treat all optics as partial](https://github.com/calmm-js/partial.lenses#on-partiality)\n(or optional). In this library the technique is more of an add-on feature\nrequiring more glue to make sure nominal types check out.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpolytypic%2Floko-ml","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpolytypic%2Floko-ml","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpolytypic%2Floko-ml/lists"}