Overview
Written by Shaun Stone, this book has 62 specific ways you can write better TypeScript. Below, I’ll share some of those ways that struck me as useful or interesting.
Structural Typing
This is based on item 4: Get Comfortable with Structural Typing. TypeScript is structurally typed. That means that the shape or structure of a type makes it that particular type. The name of the type doesn’t matter, only its structure.
type PlatformTeamMember = {
name: string;
DOB: string;
dopest: boolean;
}
type Employee = {
name: string;
DOB: string;
}
type Person = {
name: string;
}
/* A terrible mash-up of Will, Kyle, Abdul, Matt, and Aaron C
* pronounced While-a-dool-uh-ton
* */
const Wyledulatton: PlatformTeamMember = {
name: 'Wyledulatton',
DOB: '1480-01-01',
dopest: true
}
/*
* Notice that PlatformTeamMember has the Employee substructure in
* it, and objects of that type are assignable to objects of the
* Employee type
* */
const employee: Employee = Wyledulatton;
/* And down even further */
const person: Person = employee;
type Dude = {
name: string;
}
/* Names don't matter, only structure */
const dude: Dude = person;
Type Function Expressions, not Statements
This is based on item 12: Apply Types to Entire Function Expressions When Possible. TypeScipt lets us type function expressions, making it easier to type parameters and return types. Consider this block of code:
function add(a: number, b: number) {
return a + b;
}
function subtract(a: number, b: number) {
return a - b;
}
function multiply(a: number, b: number) {
return a * b;
}
function divide(a: number, b: number) {
return a / b;
}
There’s a lot of type repetition here. It’s better to type a function expression, like so:
type BinaryFn = (a: number, b: number) => number;
const add: BinaryFn = (a, b) => a + b;
const subtract: BinaryFn = (a, b) => a - b;
const multiply: BinaryFn = (a, b) => a * b;
const divide: BinaryFn = (a, b) => a / b;
This last block is much clearer than the original block. This also lets us apply types from libraries pretty easily, if they’re exported (though, with typeof
and ReturnType
, it’s easy to construct types for non-exported types).
Only Represent Valid States with Types
This is based on item 28: Prefer Types that Always Represent Valid States. This was my favorite item of the book! Consider this type, which allows for representations of invalid states:
interface DirectMessage {
message: string;
isLoading: boolean;
error?: string;
}
async function loadDirectMessage (dm: DirectMessage){
dm.isLoading = true;
try {
// async request for dm, but something goes wrong
throw new Error('woops, something went wrong');
// unreachable code! state.isLoading remains `true`
dm.isLoading = false;
} catch (e) {
// error handling
}
}
Notice that the isLoading
didn’t get cleaned up in loadDirectMessage
. If that happens, we’ll run into an invalid state: the direct message has both an error
and yet isLoading
. Assume that the business logic forbids that. A better representation goes like this:
interface RequestPending {
state: 'pending';
}
interface RequestError {
state: 'error';
error: string;
}
interface RequestSuccess {
state: 'ok';
value: string;
}
type RequestState = RequestPending | RequestError | RequestSuccess;
interface DirectMessage {
[request: string]: RequestState;
}
async function loadDirectMessage (dm: DirectMessage){
switch (dm.message.request.state) {
case 'pending': {
// load the dm
}
case 'ok': {
// return the dm
}
case 'error': {
// do some error handling
}
}
}
Each valid state is represented, and they’re “tagged”. Tags are introduced in item 3: Understand that Code Generation is Independent of Types. The big idea behind them is that since types don’t exist at runtime, we “tag” objects to represent type-relevant data at runtime. In our case above, we’re tagging objects with their states and then using those tags in loadDirectMessage
.
Use Brands to Mimic Nominal Typing
Based on item 37: Consider “Brands” for Nominal Typing. Sometimes you might not want the structure of a type to be the sole determiner of which type it is. Imagine, for example, you want to address certain close friends as dude
, but everyone else as merely person
. Using the types from the structural example above, we might run into confusion: Dude
and Person
are structurally identical. We want only some function params, say, to be represented by the Dude
type. Brands can help with that:
type Person = {
name: string;
_brand: 'Person';
}
type Dude = {
name: string;
_brand: 'Dude';
}
const dude: Dude = { name: 'Wyledulatton', _brand: 'Dude'}
const person: Person = dude;
~~~~~~
Type 'Dude' is not assignable to type 'Person'.
Types of property '_brand' are incompatible.
Type '"Dude"' is not assignable to type '"Person"'.
Nicely separating the sheep and goats.
const person: Person = { name: 'some-rando', _brand: 'Person' }
const dude: Dude = person;
~~~~
Type 'Person' is not assignable to type 'Dude'.
Types of property '_brand' are incompatible.
Type '"Person"' is not assignable to type '"Dude"'.
Inspired by the Book
What if Teams Maintained an Internal DefinitelyTyped?
TypeScript is surprisingly social. The community has written a bunch of types for JavaScript libraries. Check out DefinitelyTyped if you haven’t yet.
For larger companies using primarily TypeScript, what if instead of Swagger or READMEs, request payloads were represented in a standalone repository akin to DefinitelyTyped? That would be awesome. You could make an NPM module from it and install whichever service’s types you needed. To help teams get started, there are certain tools for generating types (including the TypeScript compiler itself with --declaration
and --allowJs
[for JavaScript codebases] that help the process by creating declaration files).