A (partial) porting of scala monocle library to TypeScript
Motivation
(Adapted from monocle site)
Modifying immutable nested object in JavaScript is verbose which makes code difficult to understand and reason about.
Let's have a look at some examples:
interface Street { num: number, name: string }
interface Address { city: string, street: Street }
interface Company { name: string, address: Address }
interface Employee { name: string, company: Company }
Let’s say we have an employee and we need to upper case the first character of his company street name. Here is how we could write it in vanilla JavaScript
const employee: Employee = {
name: "john",
company: {
name: "awesome inc",
address: {
city: "london",
street: {
num: 23,
name: "high street"
}
}
}
}
function capitalize(s: string): string {
return s.substring(0, 1).toUpperCase() + s.substring(1)
}
const employee2 = {
...employee,
company: {
...employee.company,
address: {
...employee.company.address,
street: {
...employee.company.address.street,
name: capitalize(employee.company.address.street.name)
}
}
}
}
As we can see copy is not convenient to update nested objects because we need to repeat ourselves. Let's see what could we do with monocle-ts
import { Lens, Optional } from 'monocle-ts'
const company = Lens.fromProp<Employee, 'company'>('company')
const address = Lens.fromProp<Company, 'address'>('address')
const street = Lens.fromProp<Address, 'street'>('street')
const name = Lens.fromProp<Street, 'name'>('name')
company.compose(address).compose(street).compose(name)
compose
takes two Lenses
, one from A
to B
and another one from B
to C
and creates a third Lens
from A
to C
.
Therefore, after composing company
, address
, street
and name
, we obtain a Lens
from Employee
to string
(the street name).
Now we can use this Lens
issued from the composition to modify the street name using the function capitalize
company
.compose(address)
.compose(street)
.compose(name)
.modify(capitalize, employee)
Here modify
lift a function string => string
to a function Employee => Employee
. It works but it would be clearer if we could zoom
into the first character of a string
with a Lens
. However, we cannot write such a Lens
because Lenses
require the field they are directed
at to be mandatory. In our case the first character of a string
is optional as a string
can be empty. So we need another abstraction that
would be a sort of partial Lens, in monocle-ts
it is called an Optional
.
import { some, none } from 'fp-ts/lib/Option'
const firstLetter = new Optional<string, string>(
s => s.length > 0 ? some(s[0]) : none,
(a, s) => a + s.substring(1)
)
company
.compose(address)
.compose(street)
.compose(name)
.asOptional()
.compose(firstLetter)
.modify(s => s.toUpperCase(), employee)
Similarly to compose
for lenses, compose
for optionals takes two Optionals
, one from A
to B
and another from B
to C
and creates a third Optional
from A
to C
.
All Lenses
can be seen as Optionals
where the optional element to zoom into is always present, hence composing an Optional
and a Lens
always produces an Optional
.