Enforcing business logic with your types

Some Context

Earlier this year I got pulled into some urgent CCPA-related work. In short, the service wasn’t properly closing related tickets as they came off of the queue, and this was causing a massive backlog of manual work. Absolutely no fun for anybody involved.

During my investigation, I discovered a couple of distressing errors in the record we were sending:

  • The mandatory serviceNowTaskId was misspelled (inconsistently to boot) in several key places. For example, servideNowTaskid 😬
  • Worse, clientRequestId (also necessary) was completely missing from every call, compounding the failures 💨
// An example of what this looked like in practice
sendRecords({
records: [
{
status: 'OK',
payload: {
assetId: 12345,
servidenowTaskid: 'doomed for failure',
workNotes: 'What could go wrong?',
},
},
],
topic: 'It be like this sometimes',
});

Keep in mind, this project is written in TypeScript, so I was very curious as to why this was even possible in the first place. A quick bit of snooping around for the culprit interface revealed, to my horror, this:

export interface SendRecordRequest {
readonly records: ReadonlyArray<any>; 🤦‍♂️
readonly topic: string;
}

The dreaded any ! That explained why there was no type checking to catch the error originally. Very likely a rushed feature, and in need of a bit of diligence.

Fleshing out types and gaining peace of mind

We could definitely put a little more work into the record types here to save ourselves and anyone in the future a lot of grief. In order to make sure all the records we send have the right properties, I put together a simple interface, and saw all the resulting red squiggles in my IDE…

export interface SendRecordRequest {
readonly records: SnowRecord[]; // 😀 type-checked records!
readonly topic: string;
}
// Flesh out the type for an individual record
export interface SnowRecord {
status: 'OK';
payload: {
clientRequestId: string;
serviceNowTaskId: string;
status: SnowStatus;
workNotes?: string | null;
};
};
// Idea, move common strings into an enum for easy refactors...
export enum SnowStatus {
WIP = 'Work in Progress',
CLOSED = 'Closed Complete',
...
};

This resulted in immediate compiler warnings everywhere where serviceNowTaskId and other properties were misspelled, if we were missing a status, were including something unexpected, and all the other general goodness from TypeScript like autocomplete suggestions.

// The new developer experience!
sendRecords({
records: [
{
status: 'OK',
payload: { 🚨 TS error, no `status`!
assetId: 12345,
clientRequestId,
servicenowTaskid: snowId, 🚨 TS error, unexpected prop!
workNotes: "Wow, that's handy",
},
},
],
topic: 'Confidence!',
});

At last, all was good and right with this tiny slice of the world.

Here come the new requirements

Shortly after I laid that embarrassing issue to rest, a new ask came from management to add a mandatory “closure code” to our payloads. It would give them more insight into why a ticket had been closed. Fair enough, but including the closure code only made sense if a given ticket was being set to CLOSED ; you wouldn’t want that on a ticket in progress! Additionally, the queue would reject a record if it contained a closure code and a non-closed status.

This presented a problem for the clean, simple interface I’d just made as well as my dreams of having the code help guide future developers that fall into this area. I could certainly add a closureCode?: string to the payload and call it a day, but then we wouldn’t be enforcing the relationship between closureCode and status. I could just add comments, but I wanted to make something better.

Wait, we can use unions here

Let’s look at a simple approach, then clean it up!

export interface SnowRecord {
status: 'OK';
payload: SnowPayloadWip | SnowPayloadClosed;
}
interface SnowPayloadWip {
assetId: number;
serviceNowTaskId: string;
status: SnowStatus.WIP;
workNotes?: string | null;
}
interface SnowPayloadClosed {
assetId: number;
serviceNowTaskId: string;
status: SnowStatus.CLOSED;
workNotes?: string | null;
closureCode: SnowClosureCode;
}

And in practice, usage is really slick, too!

sendRecords({
records: [
{
status: 'OK',
payload: { 🚨 TS error, expected `closureCode`
assetId: 12345,
clientRequestId,
serviceNowTaskId: snowId,
status: SnowStatus.CLOSED,
workNotes: "Wow, that's handy",
},
},
],
topic: 'Flexibility!',
});
// Alternatively
sendRecords({
records: [
{
status: 'OK',
payload: {
assetId: 12345,
clientRequestId,
serviceNowTaskId: snowId,
status: SnowStatus.WIP,
closureCode: SnowClosureCode.NO_DATA, 🚨 TS error!
},
},
],
topic: 'Flexibility!',
});

Let’s look at this from the payload-level at the key point I’d like to make:

payload: SnowPayloadWip | SnowPayloadClosed

This is a union of two types, meaning that payload can be either the WIP shape or the closed shape (or more if we extend the union!), but it can’t be a combination of both. This is perfect for our case, because then it warns the developer when they attempt to create an invalid combination of properties! For example, if a developer tried to write a block of code that sends a record with a status of WIP and included a closure code they’d get a nice warning from their IDE about closureCode being an unexpected property! Likewise, they would get a warning for failing to include closureCode if the status was closed.

This works, but those interfaces look really repetitive, and as developers we hate unnecessary repetition!

DRY time!

Using a little bit of extra TypeScript-fu, we can DRY this up by making a base interface of all the common properties, and then extending it with the configurations for each combination we need!

interface SnowPayload {
assetId: number;
serviceNowTaskId: string;
status: SnowStatus;
workNotes?: string | null;
}
interface SnowPayloadWip extends SnowPayload {
status: SnowStatus.WIP;
}
interface SnowPayloadClosed extends SnowPayload {
status: SnowStatus.CLOSED;
closureCode: SnowClosureCode;
}

Because we’re extending SnowPayload, each of these new interfaces includes its properties, and can also override their original definitions (such as status) or add others. This removes the repeated properties between the two interfaces, and more succinctly describes the unique bits!

All-together, the result looks like this:

export interface SendRecordRequest {
readonly records: SnowRecord[];
readonly topic: string;
}
export interface SnowRecord {
status: 'OK';
payload: SnowPayloadWip | SnowPayloadClosed; ✨
}
interface SnowPayload {
assetId: number;
serviceNowTaskId: string;
status: SnowStatus;
workNotes?: string | null;
}
interface SnowPayloadWip extends SnowPayload {
status: SnowStatus.WIP;
}
interface SnowPayloadClosed extends SnowPayload {
status: SnowStatus.CLOSED;
closureCode: SnowClosureCode;
}
export enum SnowStatus {
WIP = 'Work in Progress',
CLOSED = 'Closed Complete',
...
}
export enum SnowClosureCode {
...
}

That went a little long, but hopefully now you might have a slightly improved appreciation for what TypeScript can do to help catch errors in your own or your teams’ code. Additionally, I hope this serves as a real-world example of how you can use unions to tightly describe software constraints, let your IDE protect you from making business logic errors, and also help your teammates avoid similar issues if they have to maintain the code with less context than you had 😃.

For more tidbits straight from the the source on unions in TS, check out the docs!

Have ideas for a more elegant solution? Think I can make this article better? Let me know in a comment or a message; I’m always up for more learning!