One of the primary design goals of TypeScript was to bring some much needed structure to large and unwieldy JavaScript projects.
In aid of this goal, TypeScript provides modules of various flavors, namespaces, classes and enums.
Use of these structures is usually the second step (after resolving type issues by liberal application of the "any" type) in the conversion of a large JavaScript code base into TypeScript. After which, developers can now start applying their newly minted class types, modules and namespaces to existing code.
Often though, due to the somewhat mechanical nature of the conversion of raw JavaScript functions into classes and modules/namespaces, only the structural aspects of encapsulation are attended to. The "data hiding" aspect of encapsulation takes some more thought, but is potentially more valuable.
In this post, I will lay out a few steps that can be taken to increase encapsulation in your project and begin to decouple its logic from its state.
Many of these steps will be familiar to you, as they are not TypeScript specific. Also, you may think that your code just needs to be refactored as a whole, and these steps are piecemeal refactoring at best. This well may be, but the great advantage of these techniques is that they are concrete, straight forward steps that will decouple much of your code from state and prepare it for further refactoring, if necessary.
I will include some examples in this post, but with some reservations. By its nature, it is difficult to show how a large and complex application can be simplified using short samples.
Nevertheless, here is an example of how an app might look at the beginning of the process I will describe:
class Person {
name: string;
vacationDays: number;
logFriendlyMessage(message: string) {
this.appendToMessages(this.name + ", " + message);
}
logVacationDays() {
this.appendToMessages("You have " + this.vacationDays + " days of vacation left");
};
appendToMessages(message: string) {
let newElement = document.createElement("div");
newElement.innerHTML = message;
document.getElementById("divMessages").appendChild(newElement);
}
}
document.addEventListener("DOMContentLoaded", (_) => {
let person:Person = new Person();
person.name = "Tom";
person.vacationDays = 10;
person.logFriendlyMessage("you're a clever programmer");
person.logVacationDays();
});
In the beginning our class exposes 3 methods, 2 data members and an implicit constructor.
Step 1: make it private if you can; export it only if you must
Walk through all your classes, and use TypeScript's access modifiers (private, protected and public) to hide as much of your classes inner workings from the outside world. If you must expose a data member, consider doing so via getter and setter functions and exposing only what you must.
class Person {
constructor(private name: string, private vacationDays: number) {
}
logFriendlyMessage(message: string) {
this.appendToMessages(this.name + ", " + message);
}
logVacationDays() {
this.appendToMessages("You have " + this.vacationDays + " days of vacation left");
};
private appendToMessages(message: string) {
let newElement = document.createElement("div");
newElement.innerHTML = message;
document.getElementById("divMessages").appendChild(newElement);
}
}
document.addEventListener("DOMContentLoaded", (_) => {
let person:Person = new Person("Tom", 10);
person.logFriendlyMessage("you're a clever programmer");
person.logVacationDays();
});
After this step, our class exposes only 2 methods and a constructor.
Step 2: make functions static if you can, even at a cost
One of the reasons we prefer encapsulation over a sea of globals, is that when maintaining code, we have
some idea of the scope of possible side effects of a function just by seeing in which class or module it resides.
When we change class instance methods into static methods, we further reduce this scope, making it easier to reason about our code.
This is often worth the price of adding another layer of indirection:
class Person {
constructor(private name: string, private vacationDays: number) {
}
logFriendlyMessage(message: string) {
Person.logFriendlyMessage(this.name, message);
}
private static logFriendlyMessage(name: string, message: string) {
Person.appendToMessages(name + ", " + message);
}
logVacationDays() {
Person.logVacationDays(this.vacationDays);
};
private static logVacationDays(vacationDays: number) {
Person.appendToMessages("You have " + vacationDays + " days of vacation left");
};
private static appendToMessages(message: string) {
let newElement = document.createElement("div");
newElement.innerHTML = message;
document.getElementById("divMessages").appendChild(newElement);
}
}
document.addEventListener("DOMContentLoaded", (_) => {
let person:Person = new Person("Susan", 42);
person.logFriendlyMessage("you're a clever programmer");
person.logVacationDays();
});
After this step, our external exposure is the same, but our methods that do the work are easier to reason about because their parameters are explicit; there are becoming more
pure.
Again, since the example is necessarily short, the benefits are not as obvious as I would like. If you imagine these static methods were each 40 lines long, containing many conditionals and assignments, you can start to see the value.
But, regardless, Person
looks more complex, so...
Step 3: move static functions into "library" modules/namespaces
One of the side benefits of decoupling these methods from the object state is that, as our class gets larger, we can simply move them into a new module/namespace.
One note: often it is hard for me to come up with a good name for these libraries, and I feel I shouldn't break out functions into another object unless they have some cohesive theme. My advice is just go ahead split out your statics and name it something like "MyClassLib". Later, some natural groupings will emerge and you can reorganize and rename your libraries.
class Person {
constructor(private name: string, private vacationDays: number) {
}
logFriendlyMessage(message: string) {
PersonLib.logFriendlyMessage(this.name, message);
}
logVacationDays() {
PersonLib.logVacationDays(this.vacationDays);
};
}
namespace PersonLib {
export function logFriendlyMessage(name: string, message: string) {
UILib.appendToMessages(name + ", " + message);
}
export function logVacationDays(vacationDays: number) {
UILib.appendToMessages("You have " + vacationDays + " days of vacation left");
};
}
namespace UILib {
export function appendToMessages(message: string) {
let newElement = document.createElement("div");
newElement.innerHTML = message;
document.getElementById("divMessages").appendChild(newElement);
}
}
document.addEventListener("DOMContentLoaded", (_) => {
let person:Person = new Person("Susan", 42);
person.logFriendlyMessage("you're a clever programmer");
person.logVacationDays();
});
After this step, Person is less complex, and is more maintainable. Person is maintaining state, and PersonLib is handling the logic. We are starting to see some separation of concerns.
When a library function is called, the developer can tell 2 things at a glance:
1) The inputs are the function parameters, and nothing else.
2) The outputs/side effects are limited to those parameters that are passed by reference and the return value.
Or the developers
could make these assumptions, if it was't for globals. Globals are still wide open.
Decoupling library code from globals will be the subject of my next post.
Ref:
Refactoring Into Pure Functions