06. Null Object Pattern
Are your functions overly distrustful? Weâll see just how the Null Object Pattern can restore a culture of trust and cut down flow control bugs on todayâs episode of TL;DR, the JavaScript codecast series that teaches working web developers to craft exceptional software in 5 minutes a week.
Transcript
We just hit 100 subscribers, and I want to thank you so much for watching, sharing and subscribing! This series is based on problems faced in real-world consulting projects, so each episode is designed to teach powerful patterns that save you and your team time. But that takes a lot of time! Each 5â7 minute episode takes around 30 hours from inception to release.
So I want to ask you to consider supporting me on Patreon so I can keep crafting great content that helps you craft exceptional code. Think of it as a pay-what-you-like screencast subscription.
Alright, on to todayâs pattern.
When each line of code has to defend against previous lines, weâre left with a tangle of branching and flow control constructs, like ifâŠelse and early returns. This is especially tricky for error handling code, which can turn an otherwise docile function into a flow control nightmare.
Today weâre working on authentication middleware thatâs taken from a chapter in my book, Functional Design Patterns for Express.js. If youâre hopping into a Node backend and have loved the design-oriented approach we take on TL;DR, I encourage you to check it out.
Weâre making authenticated HTTP requests to an Express backend and supplying a special token called a JSON Web Token. If youâre new to JWTs, think of them as the picture page of a passport: they encode details about the user, and are securely signed so the backend knows theyâre authentic.
makeRequestWithToken('g00d_t0k3n');
// => 'â
200: Welcome to the backend.'
makeRequestWithToken('3mpt1_t0k3n');
// => 'đ« 401: Bad token.'
makeRequestWithToken('0ld_t0k3n');
// => đ„ TokenExpiredError: jwt expired
makeRequestWithToken('f@k3_t0k3n');
// => đ„ JsonWebTokenError: jwt malformed
As long as the token is valid, the backend lets us use any of its APIs. But if the token is missing some information, has expired, or has been tampered with, the backend halts the request in its tracks.
The function responsible for this guard behavior is a middleware function called checkToken()
:
let checkToken = (req, res, next) => {
let payload = jwt.verify(req.token, 's3cr3t');
if (payload && payload.user) {
req.user = payload.user;
} else {
res.status(401).send('Bad token.');
return;
}
next();
};
It tries to decode the contents of the JSON Web Token, called the payload. If the token is successfully decoded, it stores the user information on the request object and invokes next()
to continue. But if the token is bad, it halts the request and immediately responds with a 401 Unauthorized status code.
But a lot of other things can go wrong. A client could supply an expired token, or they might tamper with it; in either case, the jwt.verify()
function throws an exception. Right now, the checkToken()
function is completely oblivious to these potential errors.
We should never allow a known exception to go uncaught, otherwise the backendâs response will just hang. So instead, we need to catch any JWT-related errors and respond with a 401 status code.
let checkToken = (req, res, next) => {
- let payload = jwt.verify(req.token, 's3cr3t');
+ let payload;
+ try {
+ payload = jwt.verify(req.token, 's3cr3t');
+ } catch (error) {
+ /* Suppress the error */
+ }
if (payload && payload.user) {
...
};
To do that, we can wrap tryâŠcatch around the verify()
call. But as we learned in the last two episodes, an unqualified catch is almost always a bug. We must only catch error types we intend to handle. Weâll use an ifâŠelse statement to rethrow the error if it isnât a TokenExpiredError
or JsonWebTokenError
.
let checkToken = (req, res, next) => {
let payload;
try {
payload = jwt.verify(req.token, 's3cr3t');
} catch (error) {
if (error instanceof TokenExpiredError
|| error instanceof JsonWebTokenError) {
/* Suppress the error */
} else {
throw error;
}
}
if (payload && payload.user) {
req.user = payload.user;
} else {
res.status(401).send('Bad token.');
return;
}
next();
};
makeRequestWithToken('g00d_t0k3n');
// => 'â
200: Welcome to the backend.'
makeRequestWithToken('3mpt1_t0k3n');
// => 'đ« 401: Bad token.'
makeRequestWithToken('0ld_t0k3n');
// => 'đ« 401: Bad token.'
makeRequestWithToken('f@k3_t0k3n');
// => 'đ« 401: Bad token.'
This is the correct way to handle all these edge cases, but now checkToken()
is swimming in flow control constructs: early returns, tryâŠcatch, throw, and an unhealthy dose of ifâŠelse statements too. And sadly, this style is typical of most popular middleware libraries.
Each line of code is constantly on guard, as though it canât trust the lines before it. So how do we nuke these flow control constructs?
Last episode we derived a helper called swallow()
that could help. swallow()
is a higher-order function that runs some code that could potentially blow up. If it does, it suppresses the error and instead returns the result of another function.
let swallow = (type) => (fail) => (fn) => (...args) => {
try {
return fn(...args);
} catch (error) {
if (!(error instanceof type)) { throw error; }
return fail(error);
}
};
let safeFindBlog = swallow(NotFound)(
() => 'Missing blog'
)(unsafeFindBlog);
unsafeFindBlog({ id: 5 });
// => { title: 'I đ JS' }
unsafeFindBlog({ id: 100 });
// => đ„ NotFound
safeFindBlog({ id: 100 });
// => 'Missing blog'
Letâs try using swallow()
in place of the tryâŠcatch and ifâŠelse statements. If jwt.verify()
throws a TokenExpiredError
, weâll catch it and instead return null to make it mirror the old behavior.
let checkToken = (req, res, next) => {
let payload =
swallow(TokenExpiredError)(
() => null
)(
() => jwt.verify(req.token, 's3cr3t')
)();
if (payload && payload.user) {
...
};
Since swallow()
is a higher-order function, we can also catch a JsonWebTokenError
by composing it with another swallow()
.
let checkToken = (req, res, next) => {
let payload =
+ swallow(JsonWebTokenError)(
+ () => null
+ )(
swallow(TokenExpiredError)(
() => null
)(
() => jwt.verify(req.token, 's3cr3t')
+ )
)();
if (payload && payload.user) {
...
};
This is horrible to read, but it behaves correctly and removed several flow control constructs. What about the remaining conditionals? It would help if we could go ahead and destructure the payloadâs user property. Then the following code could be less defensive about the shape of payload.
Well if a TokenExpiredError
is thrown, swallow()
will return null, which isnât an object and canât be destructured. So what if instead of returning null, we returned a benign value that has the shape of a valid payload, such as an object with a user property? Then even if an exception is thrown, we can be sure that the payload will have the right shape.
let checkToken = (req, res, next) => {
- let payload =
+ let { user } =
swallow(JsonWebTokenError)(
- () => null
+ () => ({ user: null })
)(
swallow(TokenExpiredError)(
- () => null
+ () => ({ user: null })
)(
() => jwt.verify(req.token, 's3cr3t')
)
)();
- if (payload && payload.user) {
+ if (user) {
- req.user = payload.user;
+ req.user = user;
} else {
...
};
By substituting a benign value as early as possible, we donât have to be defensive later on. In Object-Oriented Programming, this benign value is called a Null Object. Itâs often a subclass of the expected object type, and should respond to the same messages.
class User {
constructor({ id, name, email }) {
this.name = name;
this.email = email;
this.id = id || generateId();
}
}
class NullUser extends User {
constructor() {
super({
id: '00000000',
name: 'NullUser',
email: '[email protected]'
});
}
}
Since weâre taking a more functional approach, we wonât create a Null Object class, but we can still lift this Null Object into a variable called nullPayload
to better communicate intent.
let nullPayload = { user: null };
I use this pattern so often, I like to create a utility called rescueWith()
that behaves exactly like swallow()
, except that we donât need the extra function wrapping around the nullPayload
.
let rescueWith = (type) => (fallback) =>
swallow(type)(() => fallback);
let checkToken = (req, res, next) => {
let { user } =
rescueWith(JsonWebTokenError)(nullPayload)(
rescueWith(TokenExpiredError)(nullPayload)(
() => jwt.verify(req.token, 's3cr3t')
)
)();
if (user) {
...
};
That helps cut down the syntactic noise, and once we move the arguments for jwt.verify()
to the end:
let checkToken = (req, res, next) => {
let { user } =
rescueWith(JsonWebTokenError)(nullPayload)(
rescueWith(TokenExpiredError)(nullPayload)(
jwt.verify
)
)(req.token, 's3cr3t');
if (user) {
...
We now see the entire function can be extracted from checkToken()
altogether! Letâs call it safeVerifyJWT
since it works exactly like jwt.verify()
but just replaces errors with a safe value.
let safeVerifyJWT =
rescueWith(JsonWebTokenError)(nullPayload)(
rescueWith(TokenExpiredError)(nullPayload)(
jwt.verify
)
);
let checkToken = (req, res, next) => {
let { user } = safeVerifyJWT(req.token, 's3cr3t');
if (user) {
...
Finally, letâs whip out our compose()
helper to remove the nesting.
let safeVerifyJWT = compose(
rescueWith(JsonWebTokenError)(nullPayload),
rescueWith(TokenExpiredError)(nullPayload),
)(jwt.verify);
This refactor has helped us discover the boundary we should have seen all along: all that tryâŠcatch and ifâŠelse nonsense was just about making a version of jwt.verify()
that behaved a little differently â just the sort of thing higher-order functions do so well.
And now checkToken()
is back to focusing on the naive happy path. With all the noise out of the way, we can confidently reason that next()
will only be called if thereâs a user, so we can move it into the if clause and eliminate the early return in the else. This code now has zero flow control constructs!
let checkToken = (req, res, next) => {
let { user } = safeVerifyJWT(req.token, 's3cr3t');
if (user) {
req.user = user;
+ next();
} else {
res.status(401).send('Bad token.');
- return;
}
- next();
};
Optionally, we can even rewrite the remaining ifâŠelse statement into a ternary expression to prohibit any flow control constructs at all. But whether or not you use the ternary, the final checkToken()
function reads nicely thanks to small, well-behaved functions and a predictable flow.
let nullPayload = { user: null };
let safeVerifyJWT = compose(
rescueWith(JsonWebTokenError)(nullPayload),
rescueWith(TokenExpiredError)(nullPayload),
)(jwt.verify);
let checkToken = (req, res, next) => {
let { user } = safeVerifyJWT(req.token, 's3cr3t');
return user
? (req.user = user, next())
: res.status(401).send('Bad token.');
};
Weâve been building up to this refactor for a few episodes, but by letting things get ugly instead of skipping directly to rescueWith()
, we saw how composition always wins in the end â even if the process seems to produce more code.
And that journey helped us identify and solve the underlying problem: trust. Each line of code was defensive because it couldnât safely trust the results of lines before it. With this variation of the Null Object Pattern, we replaced edge cases with benign values. Once we did that, the boundaries became detangled so we could extract a safe version of jwt.verify()
.
Trust is a powerful refactoring tool. Today, look for tryâŠcatch statements, followed by ifâŠelse statements, and use the Null Object Pattern and rescueWith()
to restore a culture of trust.
Thatâs it for today! If you loved todayâs episode, please consider supporting the channel on Patreon. Want to keep leveling up your craft? Donât forget to subscribe to the channel for more rapid codecasts on design patterns, refactoring and development approaches.