Nikola Ristić     ← back to home

Tips for making APIs with TypeScript

04/04/2021

Using TypeScript is no longer in question, it is a requirement for all new projects. The level of confidence it provides is quite high (on par with unit tests?), and it is really fun to write. Whether on front-end projects using React or back-end projects using Node and a database. (I hope I’ll soon test Deno in a real-world project.)

In this short article I’ll share some tips on how you can improve safety and usefulness of your types. The API we’re going to consider is built using Express and uses Postgres as a database. Pretty standard.

Database Types

There are a number of TypeScript SQL libraries out there, but I found that Zapatos hits the sweet spot. It’s not an ORM, but still provides shortcut functions for everyday CRUD actions, which let’s admit are boring to write. It’s very easy to set up Zapatos, and after running npx zapatos (or yarn zapatos) you’ll get a single schema.d.ts. You would need to re-run the command every time you update your database schema.

This is how you use it afterwards:

import * as db from 'zapatos/db' // loads core library
import type * as s from 'zapatos/schema' // loads local schema.d.ts

// example type usages
const PUBLIC_COLUMNS: s.account.Column[] = [
	'id',
	'first_name',
	'last_name',
	'email',
	'email_verified',
]

// postgres enum
function checkRole(role: s.account_role = 'user') {
	// ...
}

// writing raw sql
const account: s.account.Insertable = {
	first_name: 'Rile',
	last_name: 'Brat',
}

// first type - what SQL is allowed
// second type - what is the result
const [acc] = await db.sql<s.account.SQL, s.account.Selectable[]>`
insert into ${'account'} (${db.cols(account)})
values (${db.vals(account)}) returning *`
	.run(pool)

Even if you keep your usage of Zapatos here it is already a huge improvement. It’s easy to adopt in already writen APIs piece-by-piece. Next time you modify your database schema the API won’t compile.

You can add a "postdbmigrate": "zapatos" to your package.json scripts to be sure not to forget to update the types.

If you’re just starting work on your API I suggest using the shortcut functions I mentioned, it will save you lots of trouble and boilerplate code.

Endpoint Request/Response Types

It’s very easy to type your Express endpoints, but I can see how people can miss it. Let’s see how we go about doing that.

import { Request, Response } from 'express'
import type * as s from 'zapatos/schema'

// define URL params
interface Params {
	id: string
}

// root handler for /accounts/:id
export default async function handleAccount(
	req: Request<Params>,
	res: Response,
) {
	if (req.method === 'PUT') {
		await handlePut(req, res)
		return
	}

	// ...

	res.status(405).send('Method not allowed')
	return
}

// example put request body
// using Zapatos types but it can be any sort of interface
type PutReqBody = Partial<
	Pick<s.account.JSONSelectable, 'first_name' | 'last_name'>
>

// example put response body
// two options based on update success
type PutResBody =
	| {
			ok: true
			account: s.account.JSONSelectable
	  }
	| {
			ok: false
			error: string
	  }

async function handlePut(
	req: Request<Params, PutResBody, PutReqBody>,
	res: Response<PutResBody>,
) {
	// check if valid and update the record
	// ...

	if (valid) {
		res.status(200).json({ ok: true, account })
	} else {
		res.status(400).json({ ok: false, error: 'invalid body' })
	}
}

If you forgot to return the account with the successful response, for example, TypeScript will yell at you.

It is also good for documentation as you can easily see the req/res for the endpoint without going through the code.

TypeScript Paths

If you are using TypeScript’s Paths feature to have nice import paths — you’ll notice they will stay the same after you compile the project to JavaScript, which will throw an error.

The following tsconfig.json will let you import modules like import logger from 'src/logger'.

{
	"compilerOptions": {
		"baseUrl": "src",
		"paths": {
			"src/*": ["*"]
		}
	}
}

But after compiling and running the JavaScript code, you’ll get an error.

$ NODE_ENV=production node dist/app.js
node:internal/modules/cjs/loader:927
  throw err;
  ^

Error: Cannot find module 'src/logger'
Require stack:
- /Users/rista/Projects/testapi/dist/app.js

The quick and easy fix is to install the module-alias package and put this on top of your entry file, here being src/app.ts:

import moduleAlias from 'module-alias'
moduleAlias.addAlias('src', __dirname)