Typescript, it turns out, can do a lot of things that other type systems can't.
That may seem counter-intuitive - after all, it's pretty much just annotations that get compiled away, and seems much less built in than the type systems of e.g. Java or C#.
In this post, I'll dive into how you can lean into Typescript's powerful type inference, its system for deriving types from values, and use it to ensure you cover all bases by using abstractions that will also make your code easier to reason about.
Let's think up a case first. Imagine a meeting management system. We currently support inviting people to two kinds of meetings: regular old meetings in an office, and (our spiffing new feature!) video meetings.
In our frontend we want to ensure that we are receiving and saving valid meeting information. For that reason, we have an array of valid meeting types in plain Javascript:
const validMeetingTypes = [ "meeting", "video_meeting" ];
This lets us validate the meeting type in various contexts:
if (!validMeetingTypes.includes(meetingType)) {
// Oops, something's wrong! We don't know this meeting type!
throw new Error("...");
}
// If we make it here, we can assume we have a valid meeting type.
We want to lean into the type system and get the most bang for the buck. We can extract a type from the list of valid meeting types, which requires defining the validMeetingTypes
as a const array, which means it will never be changed:
const validMeetingTypes = [ "meeting", "video_meeting" ] as const;
type MeetingType = typeof validMeetingTypes[number];
This MeetingType
gets the type of "meeting" | "video_meeting"
, which means it can contain exactly these two string values and nothing else.
In order to get the type assigned automatically, we need to extract a validation function, which does a bit of type trickery:
function meetingTypeIsValid(meetingType: string): meetingType is MeetingType {
return validMeetingTypes.includes(meetingType as MeetingType);
}
Here, we pass in the meetingType
as a string, and set the return type to meetingType is MeetingType
. This means "if this function returns true, meetingType
is of the type MeetingType
".
If we use the validation function like this, we automatically get the type assigned:
if (!meetingTypeIsValid(meetingType)) {
// Here, `meetingType` is still of type `string`
throw new Error("...");
}
// But here the type inference system knows that the type is `MeetingType`!
This uses Typescript's type inference system and gives us more type safety.
If we add a new type of meeting to our validation array, we automatically get it included in the MeetingType
type definition, which is neat. But in isolated cases like this, that type safety can seem ... perhaps a bit pointless. We already validate, so what extra safety does the type get us?
I'll show you how it can become really valuable!
Imagine we want slightly different behavior when accepting a meeting invite depending on which type of meeting it is. Say, for video meetings we want to remind people that they need Zoom downloaded before the meeting.
The common naïve approach will be checking the type with an if-statement:
if (invitation.meetingType === "meeting") {
await acceptInvitation(invitation);
return;
}
await acceptInvitation(invitation);
notifyZoomNeeded(invitation.zoomUrl);
There are lots of different approaches to optimizing this code. Some people prefer a switch statement. Some would extract the acceptInvitation
line to be run no matter the case:
await acceptInvitation(invitation);
if (invitation.meetingType === "video_meeting") {
notifyZoomNeeded(invitation.zoomUrl);
}
But in all of the approaches leave us open to mistakes when we change the code. They all make assumptions about the meeting types that we simply cannot know will be true for all future meeting types we may introduce. And these mistakes will be hard to discover without running the code.
Say we add the meeting type "open_house"
for which we don't need to formally accept, instead just sending information by email to the user.
In the first code example, we would default to trying to show information about Zoom.
With a switch statement, depending on implementation, we would probably run the default case or do nothing.
In the latest code example, we will default to accepting the invitation.
All of this behavior is wrong. Now, we can hope that we will be aware of the different cases being handled here, and that we need to add a new one. But this won't be the only place where different types of meetings need to be handled differently. Even the best developer will miss a case (or, honestly, spend too long thinking through the problem).
Instead, we can lean into the type system and have it help us with finding all the places where we need to implement new behavior.
We'll use a strategy pattern for making decisions. In this case, we define a const
object where the keys correspond to MeetingType
values and the values are functions we execute in the correct case. Notice that we define the type as const
(implied by the structure) instead of assigning the MeetingType
explicitly:
const invitationAcceptanceStategies = {
"meeting": async (invitation) => await acceptInvitation(invitation),
"video_meeting": async (invitation) => {
await acceptInvitation(invitation);
notifyZoomNeeded(invitation.zoomUrl);
},
} as const;
We can now pick a strategy based on meetingType
and execute it:
const strategy = invitationAcceptanceStrategies[invitation.meetingType];
stategy(invitation);
If we add a new validMeetingType
, the MeetingType
value will change, and we'll get an error on the lines trying to pick a strategy: an entry for the new meeting type does not yet exist in the object.
We no longer run the risk of fall-through to defaults, and all of this internal validation is tied to a single source.
We're using the type system to warn us when we are doing things we want to be considerate about.
There are also some runtime advantages to the strategy pattern: we only allocate these functions once and can reuse them many times, always with similar looking objects, which allows the Javascript runtime optimization to optimize these calls.
There are many more things we could do with the type system, but this once slice shows how to get it to warn you at points you deem critical. The example isn't complete, but I'll leave it here for now to be a single point. In future posts, I will expand on similar examples.
Niels Roesen Abildgaard is Staff Software Consultant and Co-founder at deranged, a team of developer trainers, coaches and software engineers that integrate with and help improve other teams' performance. Leaving a sustainably stronger team, every time.
Top comments (1)
I'm sure I can explain it better, so please help me understand what context would be useful!
I think this is a good place to start on the advantages of monomorphism (objects with similar class shapes) and how Javascript runtime optimizes for them: builder.io/blog/monomorphic-javasc...
I think the point of only allocating the functions once vs allocating them several times is clear, but perhaps I'm missing the counterpoint (i.e. why would anyone ever allocate them more than once?), which I maybe just see so often in code that I assume everyone knows what I'm talking about :-)