Over the past few months, I've been working on a TypeScript project, where I decided to challenge myself to use Functions only. This week, I refactored the codebase to use IOC everywhere, and it feels like I leveled up. ๐
There's been a lot of articles these past couple of years about "functional programming" in JavaScript, and for some reason these are mostly concerned with immutability, sets, map/reduce, and so on. I come from a background of mostly OOP, where the answer to IOC is largely just "use constructors and interfaces", so this hasn't been really helpful.
What was missing for me, was a functional perspective on IOC and dependency injection.
In this article, I will try to illustrate the problems and solutions with a silly example for illustration purposes: for some reason, your boss wants the browser to display a personalized welcome message using an old-fashioned alert. Yikes. Well, whatever you say, boss, but I expect this requirement will change in the future.
To make the most of this article, you should know some basic TypeScript, and you should be familiar with the terms "inversion of control" or "dependency injection" - at least in the sense of using constructors and interfaces.
While
DEV
thinks this is "an 8 minute read", I recommend you open the Playground and spend 20-30 minutes getting a feel for this.
Okay, let's say you come up with function like this:
function showMessage(window: Window, message: string) {
window.alert(message);
}
As you can see, I am already doing dependency injection. Rather than reaching out for the window
global, this function asks for an instance of Window
, which makes it easy to unit-test this function on a mock Window
instance. So far so good.
๐ญ So we're done, right? ๐
Not quite.
Pretty soon, you will introduce functions that depend on showMessage
- and, in order for another function to call showMessage
, the other function needs to supply the window
parameter - which means the dependency on Windows
spreads to other functions:
function showWelcomeMessage(window: Window, user: string) {
showMessage(window, `Welcome, ${user}`);
}
But wait, now showWelcomeMessage
internally depends on showMessage
- we really should use dependency injection for that too, right?
type showMessage = typeof showMessage;
function showWelcomeMessage(showMessage: showMessage, window: Window, user: string) {
showMessage(window, `Welcome, ${user}`);
}
๐ญ This looks wrong. ๐คจ
showWelcomeMessage
had to depend on Window
, only so it could pass it along to showMessage
- but it doesn't actually do anything with the Window
object itself.
And while showMessage
happens to use Window
today, we might change that in the future, when someone realizes what a sad idea it was to use that alert. Maybe we decide to have it display a toast message on the page instead, and so the dependency changes from Window
to Document
. That's a breaking change. Now we have to run around and refactor everything that calls showMessage
.
Calling any function gets increasingly cumbersome - anytime any of the dependencies of any function changes, we have to manually correct the calls and introduce more dependencies everywhere. We're in dependency hell, and by now we're wasting most of our time refactoring.
๐ญ There has to be a better way. ๐ค
My first realization was, why should someone who wants to call showMessage
need to know anything about it's internal dependencies? What I really want, is a function that is internally bound to an instance of Window
, so that the caller doesn't need to know or care.
That means we need a factory-function for the actual function:
function createShowMessage(window: Window) {
return function showMessage(message: string) {
window.alert(message);
}
}
We'll need to extract the inner function-type - the one that has the message
argument only, so that other units can depend on that:
type showMessage: ReturnType<typeof createShowMessage>;
(Note the user of ReturnType
here - you could have manually typed out the function signature of the inner function, but this helps avoid the duplication and the extra refactoring chore going forward.)
With that in place, our showWelcomeMessage
no longer needs to care that showMessage
internally uses window
:
function showWelcomeMessage(showMessage: showMessage, user: string) {
showMessage(`Welcome, ${user}`);
}
This also makes showWelcomeMessage
easier to test, since now we don't need to mock window
anymore - we can mock showMessage
instead and test that it's being called. The code and the tests will now refactor much better, as they have fewer reasons to change.
๐ญ So we're done, right? ๐
Yeah, but No.
Consider now what happens to the next function up the call hierarchy. Let's say we have a login
function, and showing the welcome message happens to be part of what it does - and we apply dependency injection here, too:
type showWelcomeMessage = typeof showWelcomeMessage;
function login(showWelcomeMessage: showWelcomeMessage, user: string) {
showWelcomeMessage(user)
}
This problem doesn't go away by just fixing it at one level - we need to apply the same pattern we applied to showMessage
, wrapping it in a createShowMessage
factory-function. And what happens when something else needs to call login
? Same thing again.
In fact, as you may have realized by now, we might as well apply this pattern consistently, as a convention, to every function we write.
๐ญ Really? To every function?
Yes, really - and bear with me, because it doesn't look pretty:
function createShowMessage(window: Window) {
return function showMessage(message: string) {
window.alert(message);
}
}
type showMessage = ReturnType<typeof createShowMessage>;
function createShowWelcomeMessage(showMessage: showMessage) {
return function showWelcomeMessage(user: string) {
showMessage(`Welcome, ${user}`);
}
}
type showWelcomeMessage = ReturnType<typeof createShowWelcomeMessage>;
function createLogin(showWelcomeMessage: showWelcomeMessage) {
return function login(user: string) {
showWelcomeMessage(user);
}
}
type createLogin = ReturnType<typeof createLogin>;
It does what we wanted though. We can do all of our dependency injection from the top down now - we can now bootstrap everything from a single function in our entry-point script:
function bootstrap(window: Window) {
const showMessage = createShowMessage(window);
const showWelcomeMessage = createShowWelcomeMessage(showMessage);
const login = createLogin(showWelcomeMessage);
return {
login
}
}
// usage:
const { login } = bootstrap(window);
login("Rasmus");
Note that, in this example, bootstrap
returns only login
- if you have multiple entry-points, you can return more functions.
Now, as helpful as this pattern was, this approach to bootstrapping does not really scale well. There are two problems:
We're creating everything up front. In this simple example, we do need every component - but applications with multiple entry-points might only need some of the components, some of the time.
The code is very sensitive to reordering: you have to carefully arrange your factory-function calls, so that the previous function can be passed to the next. It requires a lot of thinking about dependencies.
We can solve both of these problems by deferring the creation of dependencies until they're required - that is, by making the calls to the factory-functions from within another function. Let's call this a getter-function.
Now, since these getter-functions could potentially be called more than once (although, in this simple example, they're not) we want them to return the same dependency every time - rather than generating new ones.
We can solve this by adding a tiny helper-function once
to construct these wrapper-functions and memoize the result:
function once<T>(f: () => T): () => T {
let instance: T;
return () => {
if (instance === undefined) {
instance = f(); // first call
}
return instance;
}
}
Let's refactor again: we'll wrap all of our initializations in closures and apply once
to them - and our bootstrap
function will now return the getLogin
function.
(Note that the once
function would generate singletons, if you were to call it from the global scope - but since we're calling it from the bootstrap
function scope, new instances of all dependencies will be generated for every call to bootstrap
.)
The new bootstrap-function looks like this:
function bootstrap(window: Window) {
const getLogin = once(() => createLogin(getShowWelcomeMessage()));
const getShowWelcomeMessage = once(() => createShowWelcomeMessage(getShowMessage()));
const getShowMessage = once(() => createShowMessage(window));
return {
getLogin
}
}
// usage:
const app = bootstrap(window);
const login = app.getLogin();
login("Rasmus");
I've purposely mixed-up the order of these getter-functions, to illustrate the fact that the order no longer matters: we're now free to arrange and group these lines in any order that makes sense - and we're also no longer creating anything before one of the getter-functions is actually called, which removes any concerns about potential future performance problems.
๐ญ So we're...?
Yes, done! ๐โจ
Footnote: When not to apply this pattern
You don't need to apply this pattern to every function. Some functions don't have dependencies, or maybe they depend only on standard JavaScript environment functions.
For example, there's no benefit to injecting the Math.max
function, since that's a pure function with no side-effects. Whereas, on the other hand, there's a clear benefit to injecting Math.random
, since a mock can return values that aren't actually random - making it possible to write predictable tests for your function.
Bonus: Mutable State
I made one more little discovery this week that I'd like to share.
I think we've all been here one time or another?
let loggedInUser: string | undefined;
function setLoggedInUser(user: string) {
loggedInUser = user;
}
function getLoggedInUser(): string {
return loggedInUser;
}
It's dangerously easy and natural to do this in JavaScript. ๐ฃ
But even if you put this inside a module, this is global state - and it makes things difficult to test, since setLoggedInUser
leaves behind in-memory state that persists between tests. (And you could write more code to clear out this state between tests, but, ugh.)
If you must have mutable state, we need to model that mutable loggedInUser
state as a dependency, and then apply the create-function pattern described above.
interface LoginState {
loggedInUser: string | undefined;
}
function createSetLoggedInUser(state: LoginState) {
return function setLoggedInUser(user: string) {
state.loggedInUser = user;
}
}
function createGetLoggedInUser(state: LoginState) {
return function getLoggedInUser(user: string) {
return state.loggedInUser;
}
}
I could have abbreviated this more, but I actually like seeing the word state
here, clarifying the fact that a shared state is being either read or written.
It might be tempting to just take the previous version of this code, wrap it all in a single create-function, and return both of the functions, bound to the same state
- but I wouldn't recommend that, because you could end up with many functions that depend on this state, and you don't want to be forced to declare them all in the same create-function. (Also, if you have to write a function that depends on several different state objects, that approach does not work.)
One more piece of advice: don't just create one big state object for all of your mutable state - this will muddy your dependencies, as functions will appear to depend on "the entire application state", even when those functions only actually depend on one property. (If you have multiple properties in the same state object, the cohesion should be high - ideally 100%, meaning every function depends on all of the properties of that object.)
The setLoggedInUser
function does have a side-effect, but now the effect is on state that you instantiate and control - making it easy to inject a new state for every test.
I'm not a functional programming Guru yet, and maybe there is more to learn here, but it's definitely a step up from global state. ๐
Conclusion
I feel like I've finally found a JS/TS code-style that really scales - both in terms of complexity and performance.
Applying this to my codebase has been an absolute breeze. I'm spending considerably less time juggling dependencies or refactoring things. Unit-testing is never a problem anymore.
For years, I've heard proponents of functional programming talk about the benefits - but the articles are mostly about arrays and immutability, which is great, and I've heard all the other great arguments. But it didn't really help me write software, and the outcome of prior attempts too often was either unmanageable or untestable. (But usually both.)
Unlocking this feels like the "next level" for me, and I really hope this puts somebody else on the path to more productive and scalable codebases with TypeScript or JavaScript.
Thanks for reading. Have fun! ๐โ