Building Intuitive APIs with TypeScript

by Jake Lazaroff

jake.nyc

my job: Parsec

Parsec

my band: babygotbacktalk

babygotbacktalk

Why?

The I in "API" stands for interface!

What's TypeScript?

TypeScript is a statically typed superset of JavaScript that compiles to plain JavaScript.

What's "statically typed"?

Everything in a program has a type, like "number" or "function that returns a string".

With static types, we can make sure a program's types work together without even running it.


            function square(x: number) {
              return x * x;
            }
          

            square(2);
          

            square("how YOU doin");
          

The gameplan:

We're gonna go on a whirlwind tour of some cool features of TypeScript and a nifty design pattern.

Feature 1: Unions and Intersections

Unions and intersections are ways of combining different types into one.

In a union, an object of the new type can have properties of either type. In an intersection, an object of the new type will have properties of both types.

Unions


              let foo: string | number;

              foo = "test";
              foo = 123;
              foo = false;
            

Intersections


              let obj: { foo: string } & { bar: number };

              obj = {
                foo: "moo",
                bar: 2
              };
              obj = { foo: "moo" };
            

Feature 2: Generics

Generics let you write your program so that it doesn't know which types it's using ahead of time.

If types were functions, generics would be their arguments.


            function concat(x: any[], y: any[]) {
              return x.concat(y);
            }
          

            const a = [1, 2, 3]; // number[]
            const b = [4, 5, 6]; // number[]
            const result = concat(a, b);
            result; // any[] (we lost the type!)
          

            function concat<T>(x: T[], y: T[]) {
              return x.concat(y);
            }
          

            const a = [1, 2, 3]; // number[]
            const b = [4, 5, 6]; // number[]
            const result = concat(a, b);
            result; // number[] (the type is safe!)
          

            function concat<T extends { concat(x: T): T }>(x: T, y: T) {
              return x.concat(y);
            }
          

            const a = [1, 2, 3]; // number[]
            const b = [4, 5, 6]; // number[]
            const result = concat(a, b);
            result; // number[] (works for numbers…)
          

            const a: string = "foo"; // string
            const b: string = "bar"; // string
            const result = concat(a, b);
            result; // string (…and strings!)
          

Feature 3: Mapped types

Mapped types let you transform one type into another.

They're kinda like map in normal JavaScript, but for types.


            function stringify<T>(x: T): { [K in keyof T]: string } {
              // some code that changes all the object keys to strings
            }
          

            const obj = {
              foo: 1,
              bar: 2
            };

            const result = stringify(obj);
            result; // { foo: string; bar: string; }
          

Design pattern: Builder

The Builder decouples the creation of an object from its internal representation.

It does this by using a separate class to create it, step-by-step.


              class Car {
                color: string;
                doors: number;
              }
            

              class CarBuilder {
                private car = new Car();

                setColor(color: string) {
                  this.car.color = color;
                }

                setDoors(doors: number) {
                  this.car.doors = doors;
                }

                private getCar() {
                  return this.car;
                }
              }
            

            const car = new CarBuilder()
              .setColor('red')
              .setDoors(2)
              .getCar();
          

Putting it all together

To illustrate how these all work together, we're going to create a simple SQL query builder.

What it looks like:


              interface Schema {
                users: {
                  id: number;
                  name: string;
                  age: number;
                }
                jobs: {
                  id: number;
                  name: string;
                  salary: number;
                }
              }
            

              const query = new Query<Schema>('users');

              const result = query
                .select("id")
                .select("name")
                .exec();
            

First, we make a Query class that knows the database schema and the table from which it's selecting.


            class Query<Schema, Table extends keyof Schema> {
              private table: Table;

              constructor(table: Table) {
                this.table = table;
              }
            }
          

Next, we add another generic to the class with the type of the queried result. We also need to keep track of which columns we're selecting.


            class Query<Schema, Table extends keyof Schema, Result = {}> {
              // ...
              private columns: Array<keyof Schema[Table]> = []:

              constructor(data: Schema, columns: Array<keyof Schema[Table]>) {
                // ...
                this.columns = columns;
              }
            }
          

Now, the secret sauce: every step of the builder, we intersect the result type with the selected key.


            class Query<Schema, Table extends keyof Schema, Result = {}> {
              // ...

              select<Key extends keyof Schema[Table]>(key: Key) {

                type NextResult = Result & { [K in Key]: Schema[Table][K] };

                return new Query<Schema, Table, NextResult>(this.table, [
                  ...this.columns,
                  key
                ]);
              }
            }
          

Finally, we need a method to execute the query.


            class Query<Schema, Table extends keyof Schema, Result = {}> {
              // ...

              exec(): Result[] {
                const columns = this.columns.join(', ');
                const query = `SELECT ${columns} FROM ${this.table};`;
                // some code actually running the query
              }
            }
          

              interface Schema {
                users: {
                  id: number;
                  name: string;
                  age: number;
                }
                jobs: {
                  id: number;
                  name: string;
                  salary: number;
                }
              }
            

              const query = new Query<Schema>('users');

              const result = new
                .select("id")
                .select("name")
                .exec();
            

Why is it intuitive?

It knows the type of the result!


            const foo = new Query<Schema>('users')
              .select("id")
              .select("name")
              .exec();
            foo; // Array<{ id: string; name: string }>

            const bar = new Query<Schema>('jobs')
              .select("salary")
              .exec();
            bar; // Array<{ salary: number }>

            const baz = new Query<Schema>('users').exec();
            baz; // Array<{}>
          

It won't let you select columns that don't exist!


            const foo = new Query<Schema>('users')
              .select("height")
              .result();
          

(And if you use an editor that supports TypeScript, you'll get autocomplete for the columns names!)

Extra credit

  • Disallow the names of already-selected columns!
  • Alias column names!
  • Join only tables with relationships!

Thanks!