There are many reasons why people like Object-Oriented Programming, but personally, my main reason for the use of classes in TypeScript is making dependencies explicit and replaceable. For example, suppose you have a module, Calendar, which depends on another module, GCalClient - i.e. Calendar uses the functionality from GCalClient but not vice versa. The most straightforward way to write that code is by simply using the functionality from GCalClient when needed:
// calendar.ts
import {bookAppointment} from "google-calendar-client"
function createMeeting() {
bookAppointment();
}
// google-calendar-client.ts
function bookAppointment() {
}
In fact there's absolutely nothing wrong with writing it this way, and usually that's the first thing I'll do. However, eventually I'll run into a situation where I want to be able to use a Calendar with a GCalClient that's configured differently than normal. In particular, I want to test Calendar while providing a special GCalClient that doesn't actually make any requests to external HTTP servers but instead is hard-wired to return a pre-configured response.
At that point, I'll typically refactor the code to use classes:
// calendar.ts
import {GCalClient} from "google-calendar-client"
export class Calendar {
gCalClient: GCalClient;
constructor({gCalClient}) {
this.gCalClient = gCalClient;
}
createMeeting() {
this.gCalClient.bookAppointment();
}
static create() {
return new Calendar({gCalClient: GCalClient.create()});
}
}
// google-calendar-client.ts
export class GCalClient {
bookAppointment() {
}
static create() {
return new GCalClient();
}
}
This gives me a few benefits:
- I can see at a glance what dependencies Calendar has: it clearly depends on a GCalClient.
- In production code, I don't need to care about any of this. As a consumer, I can just call
Calendar.create().createMeeting()
without worrying about GCalClients - the fact that Calendar depends on GCalClient is a mere implementation detail that's hidden. From a consumer standpoint, all that changes is that I need to callCalendar.create().createMeeting()
rather thancreateMeeting()
. - But when writing a test, I can now replace the GCalClient dependency with a special GCalClient that doesn't make HTTP requests to the cloud. This is incredibly useful because it allows me to write a meaningful test that runs in milliseconds (rather than seconds) without relying on any out-of-process dependencies.
What's the alternative? I could provide the dependencies as regular function parameters:
function create(gCalClient: GCalClient, moreDeps..., phoneNumber: number, moreArgs...) {
}
But while that works, it has downsides compared to including dependencies as constructor arguments. Passing dependencies as function arguments makes the function signature hard to read, especially when there are many dependencies (because it mixes dependencies and runtime arguments). And it makes it trickier to create alternate versions of dependencies (because I don't have a class to clearly specify what the contract is). So while classes are not always my first choice in software design, I will write classes to manage dependencies, even if that means having to deal with more boilerplate (which by the way LLMs are very good at writing).
That's really all there is to it. Classes used in this way - as a container for functions bundled with dependencies - help with testing. What's more, they have a strong positive effect on code quality, because they make dependencies clear and explicit and guide you towards being mindful about what dependencies a class should or shouldn’t have. That's a big deal, because awkward dependencies and the related concept of high coupling are the number one factor why code is expensive to change – with real economic consequences for the business.
James Shore, Dependency Injection Demystified
great write up!! thanks a lot