Exploring TypeScript Template Literal Types
Another one in the short sharp blog posts that I am trying to do more of - this time on TypeScripts new Template Literal Types. Up until version 4.1, TypeScript had three literal types, namely strings, numbers and booleans. If you're familiar with TypeScript there's no doubt you use these every day, maybe something like this:
let maxPostLength: number;
// Later on you might do:
maxPostLength = 1000;
// Or
let postContent: string;
// Later on you might do:
postContent = "James here! Thanks for reading my blog post";
With the release of TypeScript 4.1, we introduce another literal type, namely Template Literals. I'm going to assume you have a basic to mid level understanding of TypeScript and work through some examples revolving around some ficitional user events code (i.e. pretend we're building a social site!). Let's take a look at a simple example:
type CreateEventType = "create";
type MessageEntityType = "message";
type MessageEntityEvent = `${EventType}-${EntityTypes}`;
If you're familiar with template literals in JavaScript the syntax might look familar with the curly braces. Here MessageEntityEvent
is equivalent to a string literal type of "create-message"
. This doesn't give us much on it's own, but if we start to mix it with string literal union types, you'll start to see the magic:
type EventType = "create" | "read" | "update" | "delete";
type EntityTypes = "message" | "post" | "comment";
type EntityEvents = `${EventType}-${EntityTypes}`;
It might not be a initially obivious what we've done here, but the easiest way to explain why this is cool/useful is that EntityEvents
is equivalent to a union of string literals like this:
type EntityEvents =
| "create-message"
| "create-post"
| "create-comment"
| "read-message"
| "read-post"
| "read-comment"
| "update-message"
| "update-post"
| "update-comment"
| "delete-message"
| "delete-post"
| "delete-comment";
I'm sure you'd agree that the equivalent string union types are firstly lengthy, but also harder to maintain. I say it's harder to maintain because if we decide we want to add another entity type (maybe we add a like
event to go with our posts), we have to add a line for each entity type. This being a manual process means we have to first write it out, but also that we could miss an event.
We can go beyond this though, as template literal types allow us to create combinations of union string literals; you can even union unions (inception!). Let's say we add in the role of the given user into the event:
type EventTypes = "create" | "read" | "update" | "delete";
type EntityTypes = "message" | "post" | "comment";
type EntityEvents = `${EventTypes}-${EntityTypes}`;
type Roles = "admin" | "user";
type RoleEntityEvents = `${Roles}-${EntityEvents}`;
In turn this creates:
"admin-create-message" |
"admin-create-post" |
"admin-create-comment" |
// etc
"user-create-message
"admin-create-post" |
"admin-create-comment"
// etc
There are actually 25 different combinations there, but I commented them to //etc
to save your sanity. I'm hoping though here that the value is starting to show. So we can safely say template literal types save a lot of typing (forgive the double entendre). 25 handcraft string literal types vs 5 lines of using template literal types.
Now let's look how we can mix this with other cool TypeScript features. Here we'll use template literals types with generics and conditional typing. Specifically, here we'll add some type safety via conditional typing to ensure that admins can fire admin events and regular users can't:
type RegularEventTypes = "read" | "create" | "update";
type AdminEventTypes = "delete";
type EntityTypes = "message" | "post" | "comment";
type Events = `${RegularEventTypes}-${EntityTypes}`;
type AdminEvents = `${RegularEventTypes | AdminEventTypes}-${EntityTypes}`;
type AdminRole = "admin";
type UserRole = "user";
type Roles = UserRole | AdminRole;
interface User<T extends Roles> {
role: T;
fireEvent: (
message: T extends AdminRole ? `${T}-${AdminEvents}` : `${T}-${Events}`
) => void;
}
// Later on
const admin: User<AdminRole> = {
role: "admin",
fireEvent: (event) => {
if (event === "admin-delete-comment") {
// Do something specific
}
},
};
const user: User<UserRole> = {
role: "user",
fireEvent: (event) => {
if (event === "user-read-comment") {
// Do something specific
}
},
};
This is pretty powerful, right? Alongside this useful addition, template literal types also provide a series of what they call Intrinsic String Manipulation Types, which essentially allow you to manipulate the strings within the template literals, doing things like uppercasing, lowercasing, capitalising the first letter (useful for camel case properties) etc. Here's an example of how we could implement the Uppercase
manipulation type into our example:
type EventTypes = "read" | "create" | "update";
type EntityTypes = "message" | "post" | "comment";
type Events = Uppercase<`${RegularEventTypes}_${EntityTypes}`>;
And now we'd get the equivalent of this:
type Events =
| "READ_MESSAGE"
| "READ_POST"
| "READ_COMMENT"
| "CREATE_MESSAGE"
| "CREATE_POST"
| "CREATE_COMMENT"
| "UPDATE_MESSAGE"
| "UPDATE_POST"
| "UPDATE_COMMENT";
Quite useful if we want to selectively decide when to have event names upper case (passing to the server perhaps) and lowercase (using them as property keys for example).
Overall it was quite fun playing with the new feature and I look forward to using them day-to-day. I'd love to take them further and perhaps use them in conjunction with Mapped Types. The TypeScript site has quite a good section on this which I'll let you explore directy. Have fun!
Published