Going through changes with TypeScript

Alejandro Dustet

typescript compiler vim

Software is always changing, and it is our responsibility as developers to build solutions that can adapt to these modifications. Type systems are an extraordinary tool to build flexible solutions that can evolve to our ever-shifting needs. From my time working with TypeScript, I want to share an experience where the type system was essential to this goal. I bring an example where we produce an event, and we also need to handle it; it can be a user input, new push notifications, or an update in the underlying data.

Typically, we might have different places and methods to deal with each event. There are several benefits to this approach; having each class be responsible for one thing is one of them. However, there is a price we have to pay when we break responders for the same events; we now have more modifications to perform once the original solution shifts. We have to deal with many responders and handlers; we have to keep many locations updated. With a type system, we define the way everything is going to be managed and emitted, by presenting a complete API to respond to all the cases. Let’s take, for example, a new user status event handler:

  interface UserStatusResolver {
    userOnline: () => void,
    userOffline: () => void,
  }

With this definition, in our implementations, we respond to each message.

class MenuUserStatusHandler implements UserStatusResolver {
  userOnline = () => { ... }
  userOffline = () => { ... }
}
class UserStatusHandler implements UserStatusResolver {
  userOnline = () => {  ... }
  userOffline = () => {  ...  }
}

By making use of a resolver interface declaration, we make sure only to emit messages that can be handled by our resolvers:

type UserStatusEvent = keyof UserStatusResolver

The presence of a type alias is to use computed properties. The downside is that type aliases can not extend another type alias. So if we need a more granular control for our event emitters, an alternative is to use Pick, Omit, and some type intersection.

type UserLeftEvent = keyof Pick<UserStatusResolver, "userOffline">

type UserJoinedEvent = keyof Pick<UserStatusResolver, "userOnline">

type UserStatusChangeEvent = UserLeftEvent & UserJoinedEvent

Going through changes

This post is about changing things, so let’s shake things up. If we need to respond to new user status; we can add it to the interface declaration and have the type system leads us through the changes. If we add a new userAway event, we can follow error messages like:

Class 'MenuUserStatusHandler'
incorrectly implements interface 'UserStatusResolver'.

Property 'userAway' is missing in type 'MenuUserStatusHandler'
but required in type 'UserStatusResolver'.

We now have the compiler handling the heavy lifting of remembering where to respond to that event. Thanks, compiler! To take full advantage of this, we want to have our development environment integrated with these benefits. Here are a couple of resources depending on which text editor you use:

Conclusions

Handle all the cases, consider each alternative, make sure our implementation is complete. Sound familiar? If you use code to solve problems, you probably hear it a few times a day. A fully deterministic system is our eternal pursuit as software developers, and with dynamic and complex problems that change continuously, achieving this is particularly difficult.

Our solutions are bound to change, so we should make use of all the available tools to direct us through them. Having a flexible implementation is a great asset, and the features of a type system can guide us as requirements evolve. The ability to combine interfaces is a powerful TypeScript feature that can make our solutions more and more deterministic. We can provide a full description of the problem through the shape of the data and the APIs we expose. Introducing variations in our logic with no guarding rails is often difficult, find a subject matter expert, your compiler.