To read the first article in Bunker’s technology series, click here.
Developing a modern web application in javascript or Typescript with immutable objects is a very good practice. However, writing code that also uses ES6 class syntax and Typescript type hierarchies usually requires some major trade-offs. This series of articles will show how we decided on an approach to the problem at Bunker to get the best of both Typescript and immutability!
In these articles I’m going to assume you know what immutability is, and why it’s a good idea to use. If not, here’s a great article to get you started!
Let’s review our priorities for creating our Immutable Record-style classes:
- Immutable, i.e. no easy setters for properties
- Easily discoverable and strongly-type accessors (we like Intellisense….)
- Easy conversion to a plain-old-javascript object (useful for debugging, sending data via JSON web requests, etc)
- Supports inheritance
- Like the existing Immutable.JS Record, you should be able to specify as many or as few properties as you’d like during object construction, and the record will use default values for the rest
The final functionality we hadn’t yet added in our base class was the ability to create a new instance with updated values (a basic Immutability concept). So we made a with method. We used the name with because it makes more sense when read in context in code than the Immutable.JS standard merge:
with(values: any): any { const returnVal = new (this.constructor as any); returnVal._data = this._data.merge(values); return returnVal; }
This method uses the underlying Immutable.JS merge method to create a new copy of the Immutable Map storing our properties. For example:
User joe = new User({ firstName: 'Joe', lastName: 'Smith' }); joe.toJS(); //{firstName: 'Joe', lastName: 'Smith'} joe = joe.with({ lastName: 'Johnson' }); joe.toJS(); //{firstName: 'Joe', lastName: 'Johnson'}
Then, since we needed to return a new instance of our BunkerRecord, we used some fancy Typescript ( new (this.constructor as any) ) to return a new instance of the actual BunkerRecord. This will work with inheritance, so if you have a type tree: BunkerRecord > Child > Grandchild, it will work like this:
Grandchild c = new Grandchild(); Grandchild other = c.with({}); //returns an actual Grandchild instance
Let’s talk about our priority 4: type hierarchies. We actually set ourselves up nicely to use standard TypeScript inheritance. Say we had a User class we wanted to be an Immutable Record. We would define it like this:
import { BunkerRecord } from 'app/shared/models/BunkerRecord'; /** * Interface which defines the types passed into the constructor. */ export interface UserParams { firstName?: string, lastName?: string, email?: string, } const _defaultValues: UserParams = { firstName: '', lastName: '', email: '', }; /** * This is the base class for a User */ export class User extends BunkerRecord implements UserParams { public readonly firstName: string; public readonly lastName: string; public readonly email: string; constructor(params?: UserParams) { if (params) { super({..._defaultValues, ...params} as UserParams); } else { super(_defaultValues); } } /** * return a new version of the User with updated values as specified in the updatedValues parameter * @param updatedValues The new values for various members of the User class. * @returns a new instance of a User class with updated values */ with(updatedValues: UserParams): User { return super.with(updatedValues) as User; } }
A few things here:
- The major downside to this whole approach is that we actually needed to define our object properties in three places. The first, UserParams, is used mainly for strongly typing the parameters passed into the constructor and the with method. The second, _defaultValues, is – surprise, surprise – for defining the default values in case the caller doesn’t specify one during construction. The third is the set of readonly properties defined on the class itself.
- Remember before when we said the runtime code to make sure you can’t set a value on one of these objects is defensive coding? It’s because of those readonly properties. If you make the properties readonly, you will get a typescript error when trying to compile if you attempt to set any of them in your code. But again, in case someone forgot to make a property readonly, we added the runtime code to the BunkerRecord constructor
- We had to make all of the properties on the UserParams interface optional ( e.g. firstName?) so that we could achieve our goal of only being able to pass in some of the object properties at construction or during a call to with.
- We used the powerful javascript spread operator in the constructor to fully achieve priority 5. In this way, we merged the default values and overrode any that the caller specified during construction. This was a key piece of the puzzle. Combining the spread operator with a call to the parent constructor ensured default values were added for each level of the type hierarchy. By the time the BunkerRecord constructor was called, all properties had values.
- The only reason we specified a with method in the User class is so that we could get Intellisense / a correctly typed version of the method. (Priority 2)
Here are some examples using the User class:
User default = new User(); // {firstName: '', lastName: '', email: ''} User user1 = new User({ email: 'abc@test.com' }); user1.toJS(); // {firstName: '', lastName: '', email: 'abc@test.com'} User errorUser = new User({company: 'foo'}); // Won't compile because company isn't a property of UserParams User updatedUser1 = user1.with({ firstName: 'John', lastName: 'Doe' }); updateUser1.toJS(); // {firstName: 'John', lastName: 'Doe', email: 'abc@test.com'} console.log(user1 === updatedUser1); // false, it's a new object
The final piece was creating a child class. For an example, pretend there is a particular type of user: a Manager. We could now make a subclass:
import { User, UserParams } from './user'; export interface ManagerParams extends UserParams { positionName?: string; alertNotificationsEnabled?: boolean; } const _defaultValues: ManagerParams = { positionName: 'Admin', alertNotificationsEnabled: false, } export class Manager extends User { public readonly positionName: string; public readonly alertNotificationsEnabled: boolean; constructor(params?: ManagerParams) { if (params) { super({..._defaultValues, ...params} as ManagerParams); } else { super(_defaultValues); } } with(updatedValues: ManagerParams): Manager { return super.with(updatedValues) as this; } }
Only one special note:
- Notice that the ManagerParams extends UserParams. This, combined with the spread operator usage in all constructors, is what allowed us to specify parent-only properties (like firstName or lastName) when constructing a child class.
So now we could do fun stuff like this:
Manager m1 = new Manager({ firstName: 'Jane', lastName: 'Doe', alertNotificationsEnabled: true }); m1.toJS(); // {firstName: 'Jane', lastName: 'Doe', alertNotificationsEnabled: true} Manager m2 = m1.with( { firstName: 'Janis', alertNotificationsEnabled: false }; m2.toJS(); // {firstName: 'Janis', lastName: 'Doe', alertNotificationsEnabled: false}
In summary, what did we accomplish? Since Immutable.JS records don’t play nice with Typescript class hierarchies, we created our own base class and inheritance architecture to combine the power of Immutable.JS with the flexibility of designing object-oriented data models.
Happy Coding!
If your interested in learning more visit https://www.buildbunker.com/ or contact us at support@buildbunker.com
Freelancing?
Get business insurance tailored to developers and IT professionals, starting at $22/month! You’re just a few clicks away from the coverage you need to protect your business, and meet your clients’ insurance requirements.