The TypeScript converging point
HomeSlides

The TypeScript converging point

Stefan Baumgartner

Stefan on Mastodon

More on Talk, Slides, TypeScript

Usually, when doing TypeScript talks, I just open up a code editor and hack away some cool types that help in a certain scenario. This time, I was asked to do the same thing but within a 20-minute time limit. This has been super tough, so I scripted the entire thing and resorted to slides that have certain progress. Fewer chances for me to screw up! This allows me to give you not only the slides but also a write-up of this talk. I’ll give myself a bit of freedom and flesh it out where appropriate. Enjoy!

Transcript #

So recently I came upon a nice little library called commander. It helps you create Node.js CLIs, parsing your arguments and providing you with an object with all the flags you set. The API is glorious, as you’d expect by its author.

The API looks something like this:

const program = new Commander();

const opts = program
.option("--episode <number>", "Download episode No. <number>")
.option("--keep", "Keeps temporary files")
.option("--ratio [ratio]", "Either 16:9, or a custom ratio")
.opts();

if (!opts.keep) {
// Remove all files
}

What I like is that you write your application like you would write your man page or your help dialog. You write it as you would read it. This is fantastic and one of the nice things in JavaScript that I miss from many other programming languages: The flexibility you get with strings.

In this example, we deal with three possibilities:

  • Mandatory arguments, where we are required to pass a string value
  • Flags, either true or false
  • Optional arguments, either not set (false), set (true), or set with a string value.

Also, there’s a nice fluent interface. A builder pattern. This is the stuff that makes APIs just nice.

One thing that bugs me though is that I always need to refer to the options I set to know which flags are available and what they mean. This is where clumsy me constantly stumbles upon errors and typos. You know what happens if I call my option --keeps but ask for not keep? Yes, since keep would be undefined, we always execute the part where we delete our files.

Or what if I change ratio to a mandatory argument instead of an optional one? Suddenly all checks where I assume ratio is a boolean would be wrong.

There’s lots of potential for types here. So I tried to design some!

Basic types #

The first thing I do when designing types is get the basic types right. Here, I design a Command type that features two methods.

type Command = {
option(command: string, description?: string): Command
opts(): Record<string, any>
}
  • option takes a command of type string and an optional description. It returns Command again. This is how we describe the fluent interface.
  • opts gives us the result. Right now it’s a Record with string keys. So it’s any object. TypeScript will just let you pass once you access props with key.

Frankly, those types are not that helpful. But we’re getting there.

Next, we also create the constructor function that creates a Command object.

type Commander = {
create(): Command
}

Nothing out of the ordinary. Let’s declare a class (so we don’t need to bother with the implementation) and see what we can do already.

declare const Commander: Commander;

const program = Commander.create();

Nothing much. Plus the API is not what we expect. We don’t want to call Commander.create(). We want to instantiate a new class:

const program = new Commander();

Achieving this is remarkably easy. Check this out.

type Commander = {
- create(): Command
+ new(): Command
}

One line. We only need to change one single line. The new() function tells TypeScript that this is an actual constructor function, which means we can call new Commander() to instantiate a new class. This works because every class in JavaScript gives you two interfaces: one for the static parts and the constructor function, and one for the elements of an instance. There’s a similarity to how prototypes and constructor functions worked before there were classes. You can read up on constructor interfaces in this article.

So now that this works, we want to create better types for the instance we create.

Adding generics #

The next step in this progress is adding generics. We can use generics to get to the actual value types or literal types of the strings we add as parameters. We replace the first argument command with a generic variable U that extends string.

type Command = {
option<U extends string>(command: U, description?: string): Command
opts(): Record<string, any>
}

With that, we are still able to pass strings, but something interesting happens. Every time we put in a literal string, we can narrow down the type to the exact literal type. Look at this identity function for example:

function identity<T>(t: T):T { return t }

const x = identity<string>("Hello World")
const y = identity("Hello World")

The only purpose of this is to bind T to a type and return the same value. If we instantiate the type variable with a type like in the first example, the return value’s type – the type of x – is also string. In the second example, we let TypeScript infer by usage. The second example’s return type – the type of y – is the literal string "Hello World". So every value is also a type. And we can get to this type by using generic type variables. This is I guess the most important lesson on generic type variables. If you take one thing home, it’s this.

Back to our example. So with every call of .option we bind the literal string to U. We now need to collect this literal string and pass it along with every usage. To do so, we add another generic type variable T as an accumulator.

type Command<T> = {
option<U extends string>(command: U, description?: string): Command<T>
opts(): Record<string, any>
}

And instantiate this generic type variable with the empty object.

type Commander = {
new(): Command<{}>
}

Now, with every call of option, we take U and add it to the empty object. We use a Record for now.

type Command<T> = {
option<U extends string>(command: U, description?: string)
: Command<T & Record<U, any>>
opts(): T
}

We also return T when calling opts(). Remember, T stores our accumulated options. The effect? Check it out:

const opts = program
.option("episode", "Download episode No. <number>")
.option("keep", "Keeps temporary files")
.option("ratio", "Either 16:9, or a custom ratio")
.opts();

When calling opts(), we get back an object of the following type:

const opts:
Record<"episode", any> &
Record<"keep", any> &
Record<"ratio", any>

This means we can access opts with the keys episode, keep, and ratio. Cool, that’s pretty close to the real deal!

Going further #

But we are not there yet. The API of commander is much more advanced. We can write man pages! We can use double-dashes to tell our intent.

const opts = program
.option("--episode", "Download episode No. <number>")
.option("--keep", "Keeps temporary files")
.option("--ratio", "Either 16:9, or a custom ratio")
.opts();

With the current types, the type of opts looks like this:

const opts:
Record<"--episode", any> &
Record<"--keep", any> &
Record<"--ratio", any>

This means we would access our options like this: opts["--episode"]. Not cool. Let’s improve!

Instead of using a Record to collect keys, we replace it with a new type called ParseCommand<T>.

type Command<T> = {
option<U extends string>(command: U, description?: string)
: Command<T & ParseCommand<U>>
opts(): T
}

ParseCommand is a conditional type that looks like this.

type ParseCommand<T extends string> =
T extends `--${string}` ? { [k in T]: boolean } : never;

We check for T, which extends string, if the T we pass extends a string that starts with "--". We say “are you a subset of all strings that start with a double dash”? If this condition is true, we return an object where we add T to our keys. Since we only pass one literal string each time we call .option(), we effectively check if this string starts with two dashes. In all other cases we return never. never is great because it tells us that we are in a situation which can never happen. An intersection with never makes the entire type never. We can’t access any key at all from opts. This is great! It shows us that we added something to .option which might cause an error. Our software wouldn’t work and TypeScript tells us so by adding red squiggly lines everywhere we want to use the result!

One conditional type more, still no progress. We are not only interested if our string starts with two dashes, we also are interested in the part that comes after those dashes. We can instruct TypeScript to fetch that literal type out of this condition, to infer the literal type, and use this one instead:

type ParseCommand<T extends string> =
T extends `--${infer R}` ? { [k in R]: boolean } : never;

And with this single line change, we completed our type. Just two lines of code, and we can write something like this:

const opts = program
.option("--episode", "Download episode No. <number>")
.option("--keep", "Keeps temporary files")
.option("--ratio", "Either 16:9, or a custom ratio")
.opts();

And get a type that looks like this. Simply beautiful.

const opts: {
episode: boolean;
} & {
keep: boolean;
} & {
ratio: boolean;
}

But we not only want to check for flags, but we also have optional or mandatory arguments. We can extend our string template literal type that strips away the double dashes with more use cases:

type ParseCommand<T extends string> =
T extends `--${infer R} <${string}>` ?
{ [k in R]: string } :
T extends `--${infer R} [${string}]` ?
{ [k in R]: boolean | string } :
T extends `--${infer R}` ?
{ [k in R]: boolean } :
never;

Nested conditional types that check on string template literal types. Wow! What a mouthful. The result: We write something like this:

const opts = program
.option("--episode <number>", "Download episode No. <number>")
.option("--keep", "Keeps temporary files")
.option("--ratio [ratio]", "Either 16:9, or a custom ratio")
.opts();

And get this type for opts.

const opts: {
episode: string;
} & {
keep: boolean;
} & {
ratio: string | boolean;
}

Stunning!

More extravaganza! With a union type of a nested string template literal type and the empty string inside a string template literal type in a nested conditional type – breathe, breathe – we can even check for optional shortcuts.

type ParseCommand<T extends string> =
T extends `${`-${string}, ` | ""}--${infer R} <${string}>` ?
{ [k in R]: string } :
T extends `${`-${string}, ` | ""}--${infer R} [${string}]` ?
{ [k in R]: boolean | string } :
T extends `${`-${string}, ` | ""}--${infer R}` ?
{ [k in R]: boolean } :
never;

So when we write something like this:

const opts = program
.option("-e, --episode <number>", "Download episode No. <number>")
.option("--keep", "Keeps temporary files")
.option("--ratio [ratio]", "Either 16:9, or a custom ratio")
.opts();

Hah… no, check it out yourself. Head over to the playground and give it a go.

The converging point #

What we got is type safety for programs that live by using a flexible, string-based API. We transformed string types to strong types. All with just a couple lines of code and some of the more advanced features of TypeScript.

With all that power, I wonder: Have we reached a converging point? Can we express every JavaScript program through TypeScript types?

The answer is: No. TypeScript is powerful, no doubt. But one thing that I’ve hidden from you is that those types only work so well because I use them in a specific way. When I stick to the builder pattern, everything is hunky-dory. If I use my program differently, I end up in a state I can’t express through types. Not even with assertion signatures.

program
.option("-e, --episode <number>", "Download episode No. <number>")
.option("--keep", "Keeps temporary files");

program
.option("--ratio [ratio]", "Either 16:9, or a custom ratio")

const opts = program.opts(); // The empty object :-(

Well, at least not yet. TypeScript’s goal is to make as much of JavaScript expressable through its type system. And as you’ve seen, we got pretty far already. If use-cases like this become even more popular, TypeScript will inevitably add a feature to support this. And it’s ok for TypeScript to catch up to JavaScript. It always did. And JavaScript’s flexibility lead us to wonderful APIs that help us create good programs, that continuously led to a lower barrier for newcomers, and that made libraries like jQuery, express.js, or Gulp so popular. And I like that even in 2022 I can get excited by a lovely, little library like commander. And I’m excited to see what TypeScript will have in stores for situations like this.

Resources

Public presentations

More articles on Talk

Stay up to date!

3-4 updates per month, no tracking, spam-free, hand-crafted. Our newsletter gives you links, updates on oida.dev, conference talks, coding soundtracks, and much more.