by Jake Lazaroff
my job: Parsec
my band: babygotbacktalk
The I in "API" stands for interface!
TypeScript is a statically typed superset of JavaScript that compiles to plain JavaScript.
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");
We're gonna go on a whirlwind tour of some cool features of TypeScript and a nifty design pattern.
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.
let foo: string | number;
foo = "test";
foo = 123;
foo = false;
let obj: { foo: string } & { bar: number };
obj = {
foo: "moo",
bar: 2
};
obj = { foo: "moo" };
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!)
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; }
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();
To illustrate how these all work together, we're going to create a simple SQL query builder.
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();
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!)