Author: Jakub Stawiarski


TypeScript’s type safety and object-oriented features made it quite simple for web developers accustomed to languages such as Java or C# to start using it. I experienced this switch first hand, having spent most of my career thinking in Java.

There is a pitfall, though. One might be tempted to apply the same solutions he knows from other object-oriented languages. Those might either not work exactly the same, may have simpler alternatives, or may solve problems that do not exist here at all. As an example, let’s take a look at a couple of well-known (or a bit more exotic) design patterns and see how the problems they try to solve can be approached differently in this language.


Singleton pattern

Explaining why we should steer away from this one should be simple, since this pattern is no longer really considered good practice, even back in the object-oriented world. To use a real-life example, let’s assume that we are introducing multiple language support to an app, and we want a single, globally usable way of translating a text. Using a singleton, it could look something like this:

1



That is quite a lot of boilerplate we need to type even before we start implementing the actual translation logic. What may also be surprising is that the instance isn’t perfectly protected against being changed. We could make it a bit better by using a symbol to refer to the instance, using the JavaScripts # notation for private fields, making everything read-only, .etc., but before we make the singleton even more complex let’s consider different options.

In our simplistic example, the singleton is used just to expose a single method and does not need to hold any state. Since TypeScript is a superset of JavaScript and functions do not need to be a part of a class here all of that could possibly be replaced with just the following.

2




But what if there were multiple functions we want to expose, and we want to create a sort of namespace for them and group them? Maybe we specifically need an object so that we can pass a reference to it? In that case, we can use the fact that not all objects in TypeScript need to be instances of a specific, named class. We could use an object literal and create a constant instead.

3



That is still much less code than what we had to write in the singleton example. It also gives us some additional benefits. The client code seems more readable. We are also protected against accidentally changing the object.


Strategy pattern

The strategy pattern turns a group of behaviours into objects. This allows us to decide what implementation is used at runtime. We can also add new behaviours without modifying existing classes or introducing new subclasses.

4




If we think about it, this pattern allowed us to simulate callbacks in a purely object-oriented way. You know, back in the days when something being purely object-oriented was still considered a good thing. In JavaScript (and TypeScript) functions are already objects and callbacks are a thing. That means we do not need to simulate them.

5



Note that most object-oriented languages made a similar thing possible already with lambda expressions. If our strategy needs to hold some state, we can still work with functions. We can use a closure in that case.

6




Builder pattern

The builder pattern is a creational pattern that is supposed to help us build complex objects by breaking up the creation into steps. Example code that uses this pattern to create a Car instance could look something like the following.

7



There are a couple of things that I would not be happy about when I would have to work with code like that. First of all, it is quite verbose for the thing it is trying to achieve. Secondly, there are some optional and some required properties that the client code needs to provide in order to get a Car instance, but the compiler cannot help us distinguish which are which. The only way to notice that the make and model properties are required is to run the code and see the error in runtime.

One way to make it a bit better would be to accept an object with all the properties and make the client code use an object literal to create it.

8




The implementation is a bit less verbose even in this simple case and will surely scale better. Notice that we made the constructor private so that we can still use the shorthand form and not have to declare all the properties separately. The if statement can be quite safely removed, as the compiler will now be able to warn the client when he does not set all the required properties.

9




If we wanted to be a little fancier (and were using at least TypeScript 4.1) we could construct a type for our defaults. That way, the compiler could warn us when there are new properties added to our CarDetails type that do not have any defaults set. Here is how it could look like:

10




While it may look a bit scary for the uninitiated in TypeScripts mapped types, all this does is keep the optional properties of a given type T and remove all the required properties. This could become a utility type like the built-in Omit, Pick and other similar types.

The usage could look something like the following.

11




Visitor pattern

To show how can we replace the visitor pattern, let’s first take a look at why we would use it in the first place. One of the reasons is to solve the clash between the single responsibility and the open-closed principles from SOLID. Say we are writing an app that both draws and calculates the area of multiple types of shapes. While this example might not feel very life like, we could face similar issues when working with any type of app that should be split into multiple layers or modules. Think separating some type of API from the business logic and storage.

One way to implement something like this could be to use polymorphism.

12



What is nice about this solution is that we can add new shapes to the app without modifying the client code. What is not so nice is that our shape classes now have multiple responsibilities. They clearly break the single responsibility principle. Both the complexity and the number of their dependencies may quickly grow.

To fix this, we might try to separate the drawing logic and try to inspect the type of shape there.

13



While this solves the single responsibility issue, it introduces a new one. To introduce a new Shape we would now need to go through all of the code and find all places where such distinction was being done and add a new if statement. The compiler would not be able to help us with this task. This breaks the open-closed principle.

Let’s now use a visitor instead.

14a



14b




One might argue that this is the best out of a set of bad solutions. The drawing logic is separated so our Shape classes will not grow uncontrollably. We could even implement calculating the area as another visitor. That means we can add new functionality to a hierarchy of classes easily. If we add a new Shape subclass, we will be forced to add a method to the ShapeVisitor interface. The client code will need to change, but at least the compiler can clearly show us which places in the code need to change in response.

One big drawback is that the solution is quite complex and verbose. The visitor pattern also seems to be not as popular as other patterns I mentioned before. If someone comes across it the first time, it might be challenging to get what is going on. An alternative coming from the functional languages could be to use pattern matching. Unfortunately, Typescript does not include it natively. We can quite nicely simulate it using discriminated union types, though.

15




Since the type property of our shapes can only have two possible values, the compiler can know which type of object will be used in which branch. That is why we can safely access the properties of the shape in each of them. The compiler could also warn us if there would be a new shape added later on and the switch statement would not be updated.

16

This not perfect compilation error means, that this function can sometimes return `undefined` even though it’s signature says it cannot.



Even if we did not specify the return type of our function explicitly, we could still detect the missing branches as long as we had the noImplicitReturns config flag enabled when compiling TypeScript.

17

The compilation error becomes cleaner.



This solution becomes a bit more complex when we want to write a function that doesn’t return anything (which isn’t really that functional, btw.). We can then rethink our solution and actually return something, even if it’s just a wrapper for the success or error message, or use a bit of TypeScript magic (a.k.a. a hack).

18




The handleNonExhaustiveSwitch the function is a way of telling the compiler that the code that calls it should never be reached. That is why it accepts the never type as an argument. Thanks to this, it could be declared once in the project and then reused in multiple files.

If after some future change in the code the last branch of the switch statement could be reached, the compiler will warn us about it.

19



It is also worth mentioning that even though TypeScript does not offer pattern matching out of the box, its type system is powerful enough that the feature can be built on top of it with exhaustiveness checks. In fact, it was built already. One example is the quite nice  ts-pattern library.


Summary

I hope this article helped someone see how some language features, mostly stemming from the fact that TypeScript builds on top of JavaScript, let us achieve similar or better results than trying to copy the solutions from other languages.

I also hope that it inspires someone to take a step back, deepen their understanding of TypeScript and find better and simpler solutions to the problems he or she is trying to solve.







Liknande blogg
Du kanske också gillar