2021-11-04

Strongly typed Web 1.0 with TypeScript

Or more generically - "Using declarative descriptions to strongly type system boundaries with TypeScript".

You're building your Web 1.0 site (maybe more Web 1.5 ), you've got proper <form> s, /urls/with/:params , ?request=query&parameters=here . All the good stuff. You're rendering your html in the same codebase you're serving routes from, and you're doing it all in TypeScript.

How can we strongly type all the interactions between client and server so that we can't make stupid mistakes like:

<form method="POST" action="/foo">
    <input name="achieve">
</form>

With a typo in our Express app:

app.post("/foo", (req, res) => {
    const achieve = req.body.acheive  // note the typo!
    ...
})

Let's go!

I'm going to come back to the implementation, for now let's just write some staticly typed GET links. Firstly, let's describe our routes:

import * as http from "./lib/http"  // our small helper library - see below

const get = {
    index: {
        url: "/",
        params: [],
        query: [],
        queryNotRequired: [],
    },
    "/v1/:foo": {
        url: "/v1/:foo",
        params: ["foo"],
        query: ["a", "b"],
        queryNotRequired: ["c"],
    },
} as const
const GET = http.makeGET(get)

In our template, we now want to make a link like:

<a href="/v1/hey?a=A&b=B">...</a>

We're dynamically rendering the element from React or Mithril or whatever.

With our declarative definition set up, we can now use:

href = GET["/v1/:foo"].makeUrl({foo: "hey"}, {a: "A", b: "B"})

Let's see with typechecking in VSCode:

All our parameters were typechecked, wahoo!

Now, let's look at the server-side in Express:

app.get(GET["/v1/:foo"].url, (req, res) => {
    const { params, query } = GET["/v1/:foo"].req(req)
})

And again with typechecking:

A very similar approach is taken for POST routes.

The description:

const post = {
    "/v2/:bar": {
        url: "/v2/:bar",
        params: ["bar"],
        body: ["email", "message"],
    },
} as const
const POST = http.makePOST(post)

During rendering:

const form = POST["/v2/:bar"].makeForm({bar: "there"})

Where form has the following to help build your html: .form , .inputs , .assertAllInputsReferenced() - this final bit uses some Proxy magic to ensure you reference all the form body parts that you needed to.

Now for the server-side usage in Express:



How does it work?

I'm not going to go into crazy detail, you can inspect the source yourself. In summary, these bits of TypeScript were used heavily (see the video below):

const l = ["a", "b"] as const  // make a value's interior visible to TypeScript
const foo = {bar: l} as const
type Bar = (typeof foo)["bar"]  // convert readonly -> type and access a property
type BarUnion = Bar[number]  // convert literal[] -> union of literals
type BarMap = { [K in BarUnion]?: string}  // make a map from a union of strings
const maker = <L extends readonly string[]>(l: L): L[number] => l[0]  // use generics
const made = maker<typeof l>(l)

Conclusions