DEV Community

Ken Aguilar
Ken Aguilar

Posted on • Edited on

Typescript - Manipulating Deeply Nested Immutable Objects with Lenses

Javascript doesn't really have an immutable object but with typescript I can
prevent compilation if there's a rogue function that will try to mutate an object.

Let's say I have this object, and let's assume the values are always there
because if I start talking about the possibility of null then I have to talk
about prisms. So let's take it easy and stick with lenses for now.

const bankIdentity: BankIdentity = { account: { owner: { address: { data: { city: "Malakoff", region: "NY", street: "2992 Cameron Road", postal_code: "14236", country: "US" }, primary: true } } } }; 

It has this type.

interface Address { readonly city: string; readonly region: string; readonly street: string; readonly postal_code: string; readonly country: string; } interface AddressData { readonly data: Address; readonly primary: boolean; } interface Owner { readonly address: AddressData; } interface Account { readonly owner: Owner; } interface BankIdentity { readonly account: Account; } 

Accessing a value

Without using any library I can manipulate this object no problem.
When I want to access a field, I just do the dot syntax and it gives me the
value of that field.

const cityRes = bankIdentity.account.owner.address.data.city; // "Malakoff" 

Setting a new value and returning the whole object

It becomes a hassle when I have to set a new value.

const cityRes = Object.assign({}, bankIdentity, { account: { owner: { address: { data: Object.assign({}, bankIdentity.account.owner.address.data, { city: "Another City" }) } } } }); // { account: // { owner: // { address: // { data: // { city: 'Another City', // region: 'NY', // street: '2992 Cameron Road', // postal_code: '14236', // country: 'US'  // }  // }  // }  // }  //} 

Applying a function and returning the whole object

Same ugliness can be seen when applying a function to the field.

const capitalize = (s: string): string => s.toUpperCase(); const cityRes = Object.assign({}, bankIdentity, { account: { owner: { address: { data: Object.assign({}, bankIdentity.account.owner.address.data, { city: capitalize(bankIdentity.account.owner.address.data.city) }) } } } }); // { account: // { owner: // { address: // { data: // { city: 'MALAKOFF', // region: 'NY', // street: '2992 Cameron Road', // postal_code: '14236', // country: 'US'  // }  // }  // }  // }  //} 

monocle-ts

Lenses to the rescue! Unfortunately I don't think it's possible or at least easy
to generate lenses based on the objects like what makeLenses does,
so I have to hand code all of them.

import { Lens } from "monocle-ts"; const account = Lens.fromProp<Bankdentity>()("account"); const owner = Lens.fromProp<Account>()("owner"); const address = Lens.fromProp<Owner>()("address"); const data = Lens.fromProp<AddressData>()("data"); const city = Lens.fromProp<Address>()("city"); const region = Lens.fromProp<Address>()("region"); const street = Lens.fromProp<Address>()("street"); const postalCode = Lens.fromProp<Address>()("postal_code"); const country = Lens.fromProp<Address>()("country"); 

Well... accessing a value with monocle-ts looks pretty verbose.

const cityRes = account .compose(owner) .compose(address) .compose(data) .compose(city) .get(bankIdentity); // "Malakoff" 

I guess I can do it like this

const cityRes = Lens.fromPath<BankIdentity>()(["account", "owner", "address", "data", "city"]).get(bankIdentity); // "Malakoff" 

but I think it's better to just use the dot syntax, at least in my opinion.

Lenses shine when it comes to updating and applying a function to a deeply
nested value.

Setting a value and returning the whole object

const cityRes = account .compose(owner) .compose(address) .compose(data) .compose(city) .set("Another City")(bankIdentity); // { account: // { owner: // { address: // { data: // { city: 'Another City', // region: 'NY', // street: '2992 Cameron Road', // postal_code: '14236', // country: 'US'  // }  // }  // }  // }  //} 

I think that looks a lot cleaner than using Object.assign.

Applying a function and returning the whole object

Yep. That definitely looks a lot cleaner.

const capitalize = (s: string): string => s.toUpperCase(); const cityRes = account .compose(owner) .compose(address) .compose(data) .compose(city).modify(capitalize)(bankIdentity); // { account: // { owner: // { address: // { data: // { city: 'MALAKOFF', // region: 'NY', // street: '2992 Cameron Road', // postal_code: '14236', // country: 'US'  // }  // }  // }  // }  //} 

References

Top comments (2)

Collapse
 
vitoke profile image
Arvid Nicolaas • Edited

I always liked the idea of immutability and lenses, but disliked the indirectness of the resulting code. The @rimbu/deep library, part of the Rimbu immutable collections library, offers a function called patch and an object called Path that can perform lens-like operations on plain objects.

See:

[Disclaimer] I am the author of Rimbu

Collapse
 
piq9117 profile image
Ken Aguilar

Nice! I'm glad a lot more people are exploring this space in typescript. I use optics coz it's what i'm used to but I gotta admit, the ergonomics isn't that good in typescript.