Union types are useful when you have a collection of classes that do not act, but must all be acted upon in the same way.
As a software developer at Swimlane, I sometimes get the opportunity to learn new things that benefit not only me and the rest of the team, but also make our products more effective. In a recent project I was working on, I was tasked to add an Audit Log. The requirement was to record every change (e.g. Create, Update, Delete) to any of our database collections in a new collection, AuditLog, and label each record with the action that had occurred. Simple enough.
However, Update actions posed a complication: Different objects in the workflow engine can be updated in different ways (e.g., users can be enabled, disabled or logged in; plugins can be upgraded), and I didn’t want to clutter the Update functions themselves with logging logic. I needed to write a function that took as arguments the before and after state of the document being modified. Then, based on the changes in the document, determine the action that had modified them. But what type should the before and after arguments be?
class AuditLogsService { async log(before: ???, after: ???): Promise<void> { //... } }
One solution would have been to use an interface. Our documents don’t already implement a base class or an interface, so this sounded simple. Create a BaseDocument interface and make all eight document classes implement it. Then before and after can be typed to the interface.
interface AuditLogable {}
The problem with that solution is that the interface would be meaningless because none of the document classes share any properties or have any methods. Well, that’s not entirely true – they share an “id” field. So I’d be modifying eight different classes just to implement an interface with a single property and no methods.
interface AuditLogable { id: string; }
What about adding the log method to the interface and allowing each document type to define its own logging behavior? That’s proper object orientation, isn’t it? Except now I have to implement eight logging methods, each with mostly similar behavior. Why don’t I use a base class instead of an interface to DRY (Don’t Repeat Yourself) things out? Because each logging method’s behavior is only mostly similar. Plugins, for example, are the only document type that don’t have a “create” action. Instead, we call it “install”.
I realized that I was approaching this problem backwards. Documents should not do the audit logging, they should have the audit logging done to them. That way I could write it all in a single function without implementing any meaningless interfaces or confusing abstraction.
Fortunately, Typescript has a simple way to construct this pattern: Union Types. With a union type, I don’t need to abstract out any properties or behavior. I can effectively say,
“Argument ‘before’ is one of any of the types that can be audit logged.” type AuditLogable = User | Plugin | Playbook; // … class AuditLogsService { async log(before: AuditLogable, after: AuditLogable): Promise<void> { //... } }
Because type information is lost at runtime, I also need to pass an additional parameter indicating which type from within the union I am passing as the before and after arguments.
enum SwimType = { user, plugin, playbook, // ... } class AuditLogsService { async log(before: AuditLogable, after: AuditLogable, type: SwimType): Promise<void> { //... } }
Now I’ve implemented AuditLogging with a single function and one new Union Type. None of the documents being logged need to be modified, so adding a new class to the logger is as simple as adding it to AuditLogable and adding any additional required behavior to the single log function. Everything is easy to test, and easy to understand.