The open-closed principle declares that you should be able to extend a class' behavior, without modifying it. This principle is raised by Bertrand Meyer in 1988:
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
A program depends on all the entities it uses, that means changing the already-being-used part of those entities may just crash the entire program. So the idea of the open-closed principle is straightforward: we'd better have entities that never change in any way other than extending itself.
That means once a test is written and passing, ideally, it should never be changed for newly added features (and it needs to keep passing, of course). Again, ideally.
Consider an API hub that handles HTTP requests to and responses from the server. We are going to have several files written as modules, including http-client.ts
, hub.ts
and app.ts
(but we won't actually write http-client.ts
in this example, you will need to use some imagination).
Save the code below as file hub.ts
.
import { HttpClient, HttpResponse } from './http-client'; export function update(): Promise<HttpResponse> { let client = new HttpClient(); return client.get('/api/update'); }
And save the code below as file app.ts
.
import Hub from './hub'; Hub .update() .then(response => JSON.stringify(response.text)) .then(result => { console.log(result); });
Bravely done! Now we have app.ts
badly coupled with http-client.ts
. And if we want to adapt this API hub to something like WebSocket, BANG.
So how can we create entities that are open for extension, but closed for modification? The key is a stable abstraction that adapts. Consider the storage and client example we took with Adapter Pattern in Chapter 4, Structural Design Patterns we had a Storage
interface that isolates implementation of database operations from the client. And assuming that the interface is well-designed to meet upcoming feature requirements, it is possible that it will never change or just need to be extended during the life cycle of the program.
Guess what, our beloved JavaScript does not have an interface, and it is dynamically typed. We were not even able to actually write an interface. However, we could still write down documentation about the abstraction and create new concrete implementations just by obeying that description.
But TypeScript offers interface, and we can certainly take advantage of it. Consider the CommandResult
class in the previous section. We were writing it as a concrete class, but it may have subclasses that override the print
or render
method for customized output. However, the type system in TypeScript cares only about the shape of a type. That means, while you are declaring an entity with type CommandResult
, the entity does not need to be an instance of CommandResult
: any object with a compatible type (namely has methods print
and render
with proper signatures in this case) will do the job.
For example, the following code is valid:
let environment: Environment; let command: Command = { environment, print(items) { }, render(items) { }, execute() { } };
I double stressed that the open-closed principle can only be perfectly followed under ideal scenarios. That can be a result of two reasons:
So when we are expecting the entities to be closed for modification, it does not mean that we should just stand there and watch it being closed. Instead, when things are still under control, we should refactor and keep the abstraction in the status of being open to extension and closed to modification at the time point of refactoring.
18.118.139.224