By Dr. Giuseppe Maggiore
Modern Single Page Applications (SPA's) built in TypeScript and JavaScript need to manage more and more complex and nested data structures. For example, we could need to manage a state such as the following:
interface AppState {
loginForm:{
firstPage:{
userName:string
password:string
},
secondPage:{
email:string
accountType:number
}
}
}
Whenever we have to immutably update the userName
as a result of a user action, we might end up writing code looking like the following:
const setUserName = (newUserName:string) => (s0:AppState) : AppState => ({
...s0,
loginForm:{
...s0.loginForm,
firstPage:{
...s0.loginForm.firstPage,
userName:newUserName
}
}
})
While it is, I believe, pretty awesome that TypeScript offers us a type-safe spread operator, the nesting is a bit painful to watch. The fact that we cannot take advantage of the implicit context that we are copying s0
, and as such we want to stick to it when updating its nested objects such as loginForm
and firstPage
requires repetition, which increases cognitive load when maintaining the code, and is error-prone (one might mistakenly write s1.loginForm.firstPage
if the scope contains another state, which sometimes is the case, without the compiler being able to offer any helpful warnings).
In order to tackle this problem, I have built a simple lenses library that takes on the task of performing updates on TypeScript records in a way that is type-safe, and as contextually smart as possible.
In order to run this example, please first install the package via
npm install ts-lenses
, and addimport { Entity } from "ts-lenses"
to the top of your file!
Consider a simple, shallow type such as:
interface Person {
name:string,
surname:string,
age:number
}
We can wrap an object of type Person
into a lazy Entity
that can be updated with some smarter operators:
const p1 = Entity<Person>({ name:"John", surname:"Doe", age:27 })
We can now set values as follows:
const q1 = p1.set("age", a => a+1).set("name", _ => "Jane")
Notice that, in order to enable method chaining, the set
operator does not return the final result, but rather a new Entity
on which further operations can be performed. Of course, set
is type-safe: "age"
must be a valid attribute, and the setter function that updates the value must process an input and produce an output of the correct type.
When we are done with chaining operations, we can commit and then we get the resulting object with the values set correctly:
const q1 = p1.set("age", a => a+1).set("name", _ => "Jane").commit()
We can also do some nice things like change the structure (and thus the type) of the resulting record:
const q2 = p1.rename("age", "birthday", x => new Date("1-1-2001")).commit()
The type of q2
now has no age
attribute anymore, and instead has a birthday
of type Date
:
q2 : {
name:string,
surname:string,
birthday:Date
}
This means that further operator chaining after a rename cannot access the old attribute, but rather only the new:
We can also work on nested objects. For example, consider a fictitious type such as:
interface NestedState {
nesting1:{
nesting2:{
nesting3:{
nesting4:{
nesting5:{
obscenelyNestedValueWeNeedToUpdate:number
},
slightlyLessObscenelyNestedValueWeNeedToUpdate:number
}
}
}
}
}
Imagine that we were tasked with incrementing both nested numbers by 1
. A bit of a daunting task, especially if we consider the amount of repetition involved. Writing something like {...s0.nesting1.nesting2.nesting3.nesting4.nesting5, obscenelyNestedValueWeNeedToUpdate:s0.nesting1.nesting2.nesting3.nesting4.nesting5.obscenelyNestedValueWeNeedToUpdate+1}
is not exactly that paragon of elegance that gives most developers that feeling of "yes! I love my job" :)
The library can help us a bit. The setIn
operator facilitates setting nested values. The resulting code would look as follows:
const p2 = Entity<NestedState>({ nesting1:{ nesting2:{ nesting3:{ nesting4:{ slightlyLessObscenelyNestedValueWeNeedToUpdate:0, nesting5:{ obscenelyNestedValueWeNeedToUpdate:0 } } } } } })
const q21 = p2.setIn("nesting1", e =>
e.setIn("nesting2", e =>
e.setIn("nesting3", e =>
e.setIn("nesting4", e =>
e.set("slightlyLessObscenelyNestedValueWeNeedToUpdate", v => v + 2)
.setIn("nesting5", e =>
e.set("obscenelyNestedValueWeNeedToUpdate", v => v+1)
)
)
)
)
).commit()
Of course, we enjoy type-safety all the way down, and we can mix and match set
and setIn
as needed. We could even rename some of the nested attributes, in order to both update and restructure the input state for update-and-convert tasks:
The original "challenging" bit of code then becomes:
const setUserName = (newUserName:string) => (s0:AppState) : AppState =>
Entity(s0)
.setIn("loginForm", e => e
.setIn("firstPage", e => e
.set("userName", _ => newUserName)))
.commit()
Of course, it is a matter of personal preference, but I find this much more attractive than the original version!
Managing immutable update operations on complex nested states is a recurring challenge. In this article I present a small, new library that wraps these operations in a type-safe way, inspired from the lenses concept from Haskell.
Thanks to this library, which can be found on npm
, you can process data quickly and easily, with enhanced productivity and less bugs.
Thank you for coming all the way to the end, I hope you enjoyed reading this article as much as I enjoyed writing it ;)
Hi! I am Giuseppe Maggiore. I have an academic background (PhD) in Computer Science, specifically compilers and functional programming (not so surprising eh...). I am now CTO of Hoppinger, a wonderful software development company in the heart of Rotterdam (Netherlands).
I am always looking for talented software engineers who get excited at the thought of type safety, reliable software, functional programming, and so on. If that is the case, do get in touch with us, we always have open positions for smart people!