TypeScript Splits the Atom!

Splitting a string type

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:

function camelCase(term) {
return term.replace(/_([a-z])/g, m => m[1].toUpperCase());
}

This function is trivial to simply type:

declare function camelCase(term: string): string;

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 one 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?

function objectToCamel<T extends object>(obj: T) {
const out: any = {};
for (const [k, v] of Object.entries(obj)) {
out[camelCase(k)] = v;
}
return out;
}

const snake = {foo_bar: 12}; // type is {foo_bar: number}
const camel = objectToCamel(snake);
// camel's value at runtime is {fooBar: 12}
const val = camel.fooBar; // type of val is number
const val2 = camel.foo_bar; // should be a type error

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:

  1. 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 with on").
  2. 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:

type OnString = `on${string}`;
const onClick: OnString = 'onClick';
const handleClick: OnString = 'handleClick';
// ~~~~~~~~~~~ Type '"handleClick"' is not assignable to type '`on${string}`'.

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:

type IdNum = `id${number}`;
const id1: IdNum = 'id123'; // ok
const id2: IdNum = 'idABC';
// ~~~ Type 'idABC' is not assignable to IdNum

type Digit = '0' | '1' | '2' | '3' | '4' |
'5' | '6' | '7' | '8' | '9';
type ThreeDigitNum = `${Digit}${Digit}${Digit}`;

What makes this really powerful is that you can use the infer keyword in a template literal type to do pattern matching:

type ToCamel1<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<Tail>}`
: S;

type T = ToCamel1<'foo_bar'>; // type is "fooBar" (!!!)

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:

type TU = ToCamel1<'first_name' | 'last_name'>;
// type is "firstName" | "lastName"

There's a big issue, though. What if there's two _s in the string literal type?

type T2 = ToCamel1<'foo_bar_baz'>;  // type is "fooBar_baz"

We can't stop after the first "_", we need to keep going. We can do this by making the type recursive:

type ToCamel<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<ToCamel<Tail>>}`
: S;
type T0 = ToCamel<'foo'>; // type is "foo"
type T1 = ToCamel<'foo_bar'>; // type is "fooBar"
type T2 = ToCamel<'foo_bar_baz'>; // type is "fooBarBaz"

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:

interface Vector {
x: number;
y: number;
}
type Promisify<T extends object> = {
[K in keyof T]: Promise<T[K]> // <-- the mapping
};
type VectorPromise = Promisify<Vector>;
// type is { x: Promise<number>; y: Promise<number>; }

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:

interface Student {
name: string;
age: number;
}
type Evented<T extends object> = {
[K in keyof T as `${K & string}Changed`]: (val: T[K]) => void;
}
type StudentEvents = Evented<Student>;
// type is {
// nameChanged: (val: string) => void;
// ageChanged: (val: number) => void;
// }

(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:

type ObjectToCamel<T extends object> = {
[K in keyof T as ToCamel<K>]: T[K]
};

function objectToCamel<T extends object>(obj: T): ObjectToCamel<T> {
// ... as before ...
}

const snake = {foo_bar: 12}; // type is {foo_bar: number}
const camel = objectToCamel(snake);
// type is { fooBar: number }
const val = camel.fooBar; // type is number
const val2 = camel.foo_bar;
// ~~~~~~~ Property 'foo_bar' does not exist on type
// '{ fooBar: number; }'. Did you mean 'fooBar'?

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:

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 as xs.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 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 win

    The DOM typings are clever enough to infer a subtype of Element here:

    const input = document.queryQuerySelector('input');
    // Type is HTMLInputElement | null

    But once you add anything more complex to the selector, you lose this:

    const input = document.queryQuerySelector('input.my-class');
    // Type is Element | null

    With 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 to querySelector:

    const el1 = document.getElementById('foo');
    // type is Element | null
    const div = document.querySelector('div#foo');
    // type is HTMLDivElement | null

    This 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:

const obj = {x: 12};
const value = obj['x']; // JS index operator, value at runtime is 12.
type T = (typeof obj)['x']; // TS index operator, type is number.

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:

function concat<A extends string, B extends string>(a: A, b: B) {
return `${a}${b}` as `${A}${B}`;
}

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

Like this post? Consider subscribing to my newsletter, the RSS feed, or following me on Twitter.
comments powered by Disqus
Effective TypeScript Book Cover

Effective TypeScript shows you not just how to use TypeScript but how to use it well. The book's 62 items help you build mental models of how TypeScript and its ecosystem work, make you aware of pitfalls and traps to avoid, and guide you toward using TypeScript’s many capabilities in the most effective ways possible. Regardless of your level of TypeScript experience, you can learn something from this book.

After reading Effective TypeScript, your relationship with the type system will be the most productive it's ever been! Learn more »