Go type yourself!

BackerLeader ●2 ●14 ●52
calendar_today ago â€Ē schedule4 min read

DHH said that typescript is pure "type masterbation". And though I agree with his premise, It is a fixable problem. And I say we use the type system to do just that.

There is a familiar complaint about TypeScript: too much of the code is types talking to other types. Generic wrappers, schemas restating the same shapes, never tricks to force a switch to be complete. Very little of it does anything at run time.

The complaint is fair, but it usually lands on the wrong target. The TypeScript in a typical full-stack feature is not careless. It is what you have to write to get the guarantees, because TypeScript can't see the parts that matter: what crosses the network, which errors can happen, and whether you handled all of them.

Here is a small, ordinary feature. A page loads a post's title and shows a different message for each way loading can fail.

In TypeScript

// shared/types.ts — the wire contract, stated as types AND as a runtime schema
import { z } from "zod";

export const PostRow = z.object({ title: z.string(), owner: z.string() });
export type PostRow = z.infer<typeof PostRow>;

export type LoadError =
  | { kind: "NotFound"; id: string }
  | { kind: "Forbidden" }
  | { kind: "Unavailable" };

export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export type ApiResponse = Result<string, LoadError>;

// the same contract again, so the client can check at runtime what TS only assumes
export const ApiResponseSchema = z.discriminatedUnion("ok", [
  z.object({ ok: z.literal(true), value: z.string() }),
  z.object({ ok: z.literal(false), error: z.discriminatedUnion("kind", [
    z.object({ kind: z.literal("NotFound"), id: z.string() }),
    z.object({ kind: z.literal("Forbidden") }),
    z.object({ kind: z.literal("Unavailable") }),
  ]) }),
]);

export function assertNever(x: never): never {
  throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}

// app/api/post/route.ts
export async function GET(req: Request): Promise<Response> {
  const id = new URL(req.url).searchParams.get("id") ?? "";
  const viewer = await getViewer(req);
  let row: PostRow | undefined;
  try {
    row = PostRow.optional().parse(
      db.prepare("SELECT title, owner FROM posts WHERE id = ?").get(id));
  } catch {
    return Response.json({ ok: false, error: { kind: "Unavailable" } } satisfies ApiResponse);
  }
  if (!row) return Response.json({ ok: false, error: { kind: "NotFound", id } } satisfies ApiResponse);
  if (row.owner !== viewer) return Response.json({ ok: false, error: { kind: "Forbidden" } } satisfies ApiResponse);
  return Response.json({ ok: true, value: row.title } satisfies ApiResponse);
}

// client
type ClientError = LoadError | { kind: "Transport" };

async function loadTitle(id: string): Promise<Result<string, ClientError>> {
  let res: Response;
  try { res = await fetch(`/api/post?id=${encodeURIComponent(id)}`); }
  catch { return { ok: false, error: { kind: "Transport" } }; }
  const parsed = ApiResponseSchema.safeParse(await res.json());
  return parsed.success ? parsed.data : { ok: false, error: { kind: "Transport" } };
}

function label(r: Awaited<ReturnType<typeof loadTitle>>): string {
  if (r.ok) return r.value;
  switch (r.error.kind) {
    case "NotFound":    return `No post ${r.error.id}`;
    case "Forbidden":   return "Not yours";
    case "Unavailable": return "Try again later";
    case "Transport":   return "Network trouble";
    default:            return assertNever(r.error);
  }
}

In scrml

<program db="./app.db">

type LoadError:enum = { NotFound(id: string), Forbidden, Unavailable }

function loadTitle(id: string, viewer: string) ! LoadError {
    let row = ?{`SELECT title, owner FROM posts WHERE id = ${id}`}.get() !{
        _ :> { fail .Unavailable }
    }
    if (row is not) { fail .NotFound(id) }
    if (row.owner != viewer) { fail .Forbidden }
    return row.title
}

<msg> = ""

function show(id: string) {
    @msg = loadTitle(id, "me") !{
        .NotFound(mid) :> "No post " + mid
        .Forbidden     :> "Not yours"
        .Unavailable   :> "Try again later"
        .Transport(_)  :> "Network trouble"
    }
}

<button onclick=show("1")>Load</>
<p>${@msg}</p>

</program>

Now is'nt that just so much nicer to read?

Where the TypeScript went

TypeScript scrml
LoadError as a union of objects tagged with kind type LoadError:enum: variants with payloads are part of the language
Result<T, E>, and ok: true/false wrapping at every return ! LoadError on the signature, fail .X in the body. A function that can fail is a language feature, not a library type
A Zod schema restating the wire type so the client can check it at run time Nothing. loadTitle runs on the server because it touches the database, and the compiler writes the encoding for its result and its errors
An API route, a URL, fetch, a try around fetch, and a hand-made "Transport" error loadTitle(id, "me") is an ordinary call. A network failure arrives as the built-in .Transport variant
assertNever and a default: branch to make the switch complete The compiler refuses an incomplete handler. Delete the .Forbidden line and the build fails: E-TYPE-080: Non-exhaustive error handler for LoadError. Missing variant(s): Forbidden
satisfies ApiResponse on every Response.json Nothing to satisfy, because no response is written by hand
Awaited<ReturnType<typeof loadTitle>> Nothing to derive, because no type is threaded through generics

The point

None of the TypeScript above is bad. Every line restates something TypeScript cannot see on its own:

  • the shape of what crosses the wire;
  • the set of errors a call can produce;
  • whether every one of them was handled.

You write those types because the compiler is blind to the server, the network and the client as one program.

scrml's compiler is not blind to them. It compiles the server function, the encoding between server and browser, and the client in one pass. So it already knows the error set, already writes the encoding, and can check exhaustiveness itself. You don't need fewer guarantees. You just don't have to restate them by hand.

1 Comment

0 votes
ðŸ”Ĩ Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelski - Apr 23

Merancang Backend Bisnis ISP: API Pelanggan, Paket Internet, Invoice, dan Tiket Support

Masbadar - Mar 13

Tuesday Coding Tip 02 - Template with type-specific API

Jakub Neruda - Mar 10

Is there any way to make type conversion sexy? Nope

Bryan MacLeeverified - Oct 20

Your Game’s GC Spikes? Blame Yourself.

PrabashanaDev - Jul 15
chevron_left
4.1k Points â€Ē 68 Badges
Vernal UT â€Ē scrml.dev
19Posts
42Comments
58Connections
I am a truck drive specializing in the oil and gas industry, who happens to love coding.
i have been... Show more

Related Jobs

View all jobs →

Commenters (This Week)

6 comments
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!