adamesterline.com

Functional Programming with Type Guards

Typescriptadvanced types

Typescript is impressive in what it can infer about types. I am impressed almost every day what Typescript can infer. Let's look at an example that impressed me this week.

interface InsertEvent {
type: "insert";
newData: Customer;
}

interface ChangeEvent {
type: "change";
newData: Customer;
oldData: Customer;
}
interface DeleteEvent {
type: "delete";
oldData: Customer;
}
type DataEvent = InsertEvent | ChangeEvent | DeleteEvent;

const events: DataEvent[] = [
{ type: "insert", newData: customer1 },
{ type: "delete", oldData: customer3 },
];

function isInsert(event: DataEvent): event is InsertEvent {
return event.type === "insert";
}

events.filter(isInsert).map((event) => {
// The Typescript compiler knows that event is now
// an InsertEvent. It will only you to access the `type` and
// `newData` properties.
return event.newData.name;
});

There are a couple of interesting Typescript language features in this code.

Discriminated Union #

The DataEvent type in the above code is a discriminated union. It is a discriminated union because we defined three types (InsertEvent, ChangeEvent and DeleteEvent)
all with the same property: type. type is called the discriminate. We then union the three types together into a single type: DataEvent. The discriminate name type
isn't necessary. There is nothing special about type. We could have named it foo, and Typescript will still recognize type as the discriminate. We use the
common property to determine the type of the DataEvent.

User-Defined Type Guards #

isInsert is a User-Defined Type Guard. isInsert tells the Typescript compiler how to know if a DataEvent is an InsertEvent. This exciting part about the isInsert
function is the return type. The return type event is Insert is called a type predicate. This is the special sauce that allows Typescript to narrow the type to an InsertEvent
after it is used as the filter expression.

Why is this cool? #

We could have defined DataEvent like this.

interface DataEvent {
type: "insert" | "change" | "delete";
newData?: Customer;
oldData?: Customer;
}

The issue with this definition is it doesn't provide any help to the user of the type as to when newData and oldData will be undefined or have a value.
This is a source of bugs waiting to happen. If we can define types that don't rely on data that is sometimes missing, we can eliminate a source of bugs. I was impressed
that in a filter, map chain, Typescript could determine that the DataEvent had been narrowed to an InsertEvent. I knew that Typescript could do this
for if blocks inside of methods, but I didn't realize it could do it across method calls.

I am spending a lot of time learning Typescript at work, so stay tuned for more Typescript Tips.