TypeScript's type system has grown steadily more powerful over the past five years, allowing you to precisely type more and more patterns in JavaScript. The upcoming TypeScript 4.1 release includes a particularly exciting new addition to the type system: template literal types.
Template literal types solve a long-standing gap in TypeScript's type system and, as I'll argue at the end of the post, they solve it in a particularly TypeScripty way.
To understand template literal types, let's start with a seemingly simple question: what can't you type?
The limits of type safety in TypeScript
My standard example of a pattern you couldn't type has always been the camelCase
function, which maps something like "foo_bar"
→ "fooBar"
. It's easy to implement in JavaScript using a regular expression:
|
This function is trivial to simply type:
|
So that's not quite what I'm getting at. Ideally you'd like to be able to use this to convert objects with snake_cased
properties (like you'd get from a database) into ones with camelCased
properties (like you typically use in JS/TS). In other words, what should the return type of this function be to make the following code type check (or not) as you'd expect?
|
Prior to TypeScript 4.1 (now a release candidate) this just wasn't possible. The reason was that string literal types like "foo_bar"
were "atomic" in the sense that you couldn't observe any structure inside of them. They were indivisible. But clearly there is structure in strings. Just look at all the methods on String.prototype
.
Enter: TypeScript 4.1!
TypeScript splits the atom
TypeScript 4.1 introduce a few features that make it possible to precisely type the objectToCamel
function:
- Template literal types This is the key advance. Template literal types allow you to find structure inside string literal types and create infinite, strict subsets of
string
(think "strings starting withon
"). - Key Remapping in Mapped Types While it was possible to change the keys in an object before using tricks like Unionize and Objectify, this new feature makes it much more straightforward.
Let's use these two features to implement objectToCamel
.
First, let's look at template literal types. They look like ES template literals:
|
This lets you create a type for "strings starting with on
." Before TypeScript 4.1, you either had string
or an enumerated union of string literal types ("a" | "b" | "c"
). Now you can define structured subsets of string
.
Here are a few other patterns:
|
What makes this really powerful is that you can use the infer
keyword in a template literal type to do pattern matching:
|
The conditional matches string literal types of the form "head_tail"
. The "_
" acts as a delimiter to split the string. Because conditional types distribute over unions, this also works for union types:
|
There's a big issue, though. What if there's two _
s in the string literal type?
|
We can't stop after the first "_
", we need to keep going. We can do this by making the type recursive:
|
The recursive bit is where we call ToCamel<Tail>
.
Pretty neat! Now let's put it all together.
A typed objectToCamel
Recall that a mapped type in TypeScript looks and works something like this:
|
The keyof T
here produces a union of string literal types ("x" | "y"
) and the mapped type produces an object type from this given a way to produce the values (the Promise<T[K]>
). But the keys are set by the union. You can't change them.
With Key Remapping, you can add an as
clause to the key in a mapped type to change things around. This works particularly well with template literal types:
|
(The & string
is there for technical reasons that I don't want to get into.)
Using this, we can plug in our ToCamel
generic to put it all together:
|
Here's a complete playground.
What can should you do with template literal types?
After template literal types landed, the TypeScript Twittersphere went crazy. I shared a use case around express, which quickly became the most popular tweet I've ever posted:
Another use of @TypeScript 4.1's template literal types: extracting the URL parameters from an express route. Pretty amazing you can do this in the type system! https://t.co/gfZQy70whg pic.twitter.com/aEyfMwjjqX
— Dan Vanderkam (@danvdk) September 4, 2020
A JSON parser made the rounds and then someone implemented a full SQL engine in the type system. Hacker news was impressed.
As with any new tool, it will take some time for the community to figure out the best ways to use it. Here are a few ideas. We'll see how they pan out!
Dotted access: easy win
Lodash allows you to write "iteratee" expressions like
xs.map('a.b.c')
, which is roughly the same asxs.map(x => x.a.b.c)
. Template literal types will make it possible for this sort of API to be typed.I've never been a big fan of this style. I'd prefer to write
x => x.a.b.c
. But perhaps some of this is just bias from not being able to type these properly in the past. Using string literals for enums, for example, is frowned upon in Java as unsafe, stringly typed, code. But it turns out to be fine in TypeScript because the type system is rich enough to capture it. So we'll see!Parsing routes: huge win!
See my
ExtractRouteParams
tweet above. Parsing{userId: string}
out of/users/:userId
will be a big win for express users.Going the other direction is also compelling. In a server I use at work, we issue API calls via something like
get('/users/:userId', {userId: 'id'})
. We have types defined for the parameters for each route. But now we can just let TypeScript infer them to ensure that nothing will ever get out of sync.Similar considerations apply to routes with react-router.
Better types for
querySelector
/querySelectorAll
: nice winThe DOM typings are clever enough to infer a subtype of
Element
here:const input = document.querySelector('input');
// Type is HTMLInputElement | nullBut once you add anything more complex to the selector, you lose this:
const input = document.querySelector('input.my-class');
// Type is Element | nullWith template literal types, it will be possible to fix this. I wouldn't be surprised if it becomes common practice to replace calls to
getElementById
with equivalent calls toquerySelector
:const el1 = document.getElementById('foo');
// type is Element | null
const div = document.querySelector('div#foo');
// type is HTMLDivElement | nullThis will no doubt require me to rewrite Item 55 of Effective TypeScript ("Understand the DOM hierarchy"). Oh well!
Parsing options in Commander or docopt: a small win
With Commander, you define your command line tool's arguments using something like this:
program
.option('-d, --debug', 'output extra debugging')
.option('-s, --small', 'small pizza size')
program.parse(process.argv);
console.log(program.debug, program.small);Setting aside the mutation style, which is hard to model in TypeScript, template literal types should make it possible to extract the parameter names from the calls to
.option
.Parsing SQL or GraphQL: I could go either way!
The ts-sql demo raised some eyebrows, but it also made a real point about the power of template literal types. Given a TypeScript version of your database schema (which can be generated using schemats or pg-to-ts), it should be possible to infer result types for a SQL query:
import {Schema} from './dbschema';
async function getStudentsByAge(db: Pool, age: number) {
const result = await db.query<Schema>(`
SELECT first_name, last_name FROM students
WHERE age = $1;
`, [age]); // checks that type of age is number
return result.rows;
// type is {first_name: string, last_name: string}[]
}This seems potentially amazing, but also perhaps brittle. You'd have to work in the subset of SQL that your types understood: presumably you wouldn't want to implement all of PL/pgSQL in the type system. But I could imagine getting a large class of queries, including joins, to work.
So I'm on the fence on this one! Similar considerations apply to GraphQL queries, which would be a bit easier to join with a schema in the type system than raw SQL.
Template literal types open up many new doors for TypeScript library authors and should improve the overall experience of using TypeScript for everyone by capturing more JavaScript patterns in the type system.
I'd like to conclude by pointing out that this is a very TypeScripty solution to this problem. TypeScript is full of "puns" between value and type syntax. Depending on the context, "foo"
could either be the literal value "foo"
or a type consisting of the single value "foo"
. (I explore this in Item 8 of Effective TypeScript, "Know How to Tell Whether a Symbol Is in the Type Space or Value Space"). Another famous example is index types:
|
Template literal types continue this pattern by repurposing a runtime JavaScript syntax (template strings) into something that makes sense in the type system (template literal types). The concat
function really hammers this home:
|
On the return line, `${a}${b}`
is the runtime JavaScript template literal and `${A}${B}`
is the TypeScript type. It's not an accident that they look identical!
I never would have thought to do it this way, but kudos to Anders and the TypeScript team for coming up with such an on-brand solution!
Image credit: modified version of File:Nuclear fission chain reaction.svg from Wiki Commons