DEV Community

loading...

Typescript - Manipulating Deeply Nested Immutable Objects with Lenses

Ken Aguilar
* Functional Programmer * All my posts are cross post from my site https://www.taezos.dev/notes.html
Updated on ・3 min read

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

Discussion (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 Author

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.