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.