authored by claude, rubber stamped by Bryan MacLee
TL;DR: Stop writing API routes. The compiler does it.
Every framework dev has shipped a bug where they thought a function ran on the server but it didn't, or thought it ran on the client but it didn't. I am not an experienced framework developer. I can hobble through React if I HAVE TO. But across about twenty compiler attempts the same shape kept showing up: the compiler should know which side of the wire each function is on, because the wire is part of the program.
I'm obsessed with performance. I'm also a believer in "do it right, the first time, even if it takes more time." The server boundary is a place where most languages do neither, and the runtime pays for both. So this is the second of six features the browser-language overview piece promised to unpack later. The boundary, in detail.
What "shipping a typed POST endpoint" actually costs
Pick a framework. Next, Remix, Express plus a React frontend, doesn't matter. The minimum table-stakes for a single server endpoint that takes some data, validates it, persists it, and returns a typed response looks like this:
On the server:
// app/api/orders/route.ts
import { z } from "zod";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
const SubmitOrderInput = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
qty: z.number().int().min(1).max(99),
})),
});
const SubmitOrderOutput = z.object({
orderId: z.string().uuid(),
total: z.number(),
});
type SubmitOrderOutput = z.infer<typeof SubmitOrderOutput>;
export async function POST(req: Request) {
const session = await auth(req);
if (!session) return new Response("Unauthorized", { status: 401 });
let body: unknown;
try { body = await req.json(); }
catch { return new Response("Bad JSON", { status: 400 }); }
const parsed = SubmitOrderInput.safeParse(body);
if (!parsed.success) {
return new Response(JSON.stringify(parsed.error), { status: 400 });
}
// ... actual business logic finally starts here ...
const order = await db.orders.insert(...);
return Response.json({ orderId: order.id, total: order.total });
}
On the client:
// app/cart/page.tsx
type SubmitOrderInput = { items: { productId: string; qty: number }[] };
type SubmitOrderOutput = { orderId: string; total: number };
async function submitOrder(input: SubmitOrderInput): Promise<SubmitOrderOutput> {
const res = await fetch("/api/orders", {
method: "POST",
headers: { "content-type": "application/json", "x-csrf-token": getToken() },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(await res.text());
return res.json() as Promise<SubmitOrderOutput>;
}
That's the minimum. No retry. No timeout. No proper error type. No loading state. The shapes are typed twice, once with zod and once with hand-written TS. The CSRF token plumbing is manual. If you change the input shape on the server and forget to update the client TS type, nothing fails until production traffic hits it.
This is the seam. It is the most expensive seam in the application. It is also the seam that no framework can close, because the framework doesn't own both sides of it.
The same feature in scrml
< db src="./app.db" tables="orders,order_items">
server fn submitOrder(items: List<Item>(@length > 0 && @length < 100)) -> OrderResult {
let total = items.sum(it => it.price * it.qty)
let orderId = ?{`INSERT INTO orders (total) VALUES (${total}) RETURNING id`}.get().id
items.forEach(it => {
?{`INSERT INTO order_items (order_id, product_id, qty) VALUES (${orderId}, ${it.productId}, ${it.qty})`}.run()
})
return OrderResult { orderId: orderId, total: total }
}
</>
And the call site, anywhere in the same file or another .scrml file in the project:
let result = submitOrder(@cart.items)
That's the entire feature. Both halves.
What the compiler did with server fn:
- Generated a server-side route handler. Route name is compiler-internal; you don't reference it.
- Generated the client-side
fetchcall that invokes the route, with arg serialization, response deserialization, andawaitinsertion. The developer SHALL NOT writeJSON.stringify,JSON.parse, orfetchto consume server function return values (§12.5). - Type-checked the call site.
submitOrder(@cart.items)checks the argument shape against the server fn signature in the same compile pass. There is no client-sidetype SubmitOrderInputdeclaration to drift. - Emitted the function body to the server output only. The client gets a fetch stub.
- Enforced the predicate at the server boundary.
(@length > 0 && @length < 100)is checked at function entry, on the server, before any database write. The check runs even if the client already validated (§53.9.4).
What the compiler refuses
Six refusals, every one of them a real diagnostic with an E-code you can look up.
1. Reading a server-only field on the client. (E-PROTECT-001)
If < db> declares protect="passwordHash", then passwordHash does not exist on the client type. A client-side function trying to read it is a compile error, not a runtime exposure.
2. Code that accesses a protected field but might run client-side. (E-PROTECT-002)
The compiler verifies at compile time that no function accessing protected fields executes on the client. Any code path that could route to the client and touches a protected field fails compile.
3. A server fn calling a client-only fn. (E-ROUTE-002)
If a server fn transitively calls a function that touches the DOM or reads a client-only derived value, compile fails with the call chain printed. The error message names the server function, the client-only callee, and the path between them, then suggests three resolutions: extract a pure function, duplicate the logic, or re-evaluate the classification.
4. A non-serializable return type from a server fn. (E-ROUTE-003)
Try to return a function, a DOM node, or a class instance from server fn and the compile fails. The wire is JSON; the type system enforces it.
5. A client-local @var used as a bound parameter in INSERT, UPDATE, or DELETE outside a server fn. (E-AUTH-001)
The compiler refuses to silently persist client-local state. The error message tells the developer to pass the value to a server function first.
6. A predicate violation at the boundary. (E-CONTRACT-001 at compile time, E-CONTRACT-001-RT at the boundary.)
If the compiler can prove a literal violates a predicate, the build fails. If the value is only known at runtime, a server-side boundary check fires before any business logic runs and rejects the request.
That's six refusals. Every one of them is a type-system answer to a question that, in framework land, is "hope your tests catch it."
What gets generated for free
Beyond refusing the wrong things, the compiler also generates the things you would have written by hand:
-
The fetch stub. Argument serialization, response deserialization, automatic
await. NoJSON.stringify. NoJSON.parse. No manualawait. - The route handler. With its name as a compiler-internal detail you never see.
-
CSRF plumbing, when
<program csrf="on">is set. A token-mint server fn, a<meta name="csrf-token">injection in the generated HTML, a request interceptor that adds theX-CSRF-Tokenheader to every state-mutating request, and a server-side validator that returns 403 if the token is missing or invalid (§39.2.3). - Predicate validation at the boundary. Inline predicate constraints on server function parameters are enforced server-side, before any database write or business logic, independently of any client-side check. A server function's parameter constraint cannot be bypassed by raw HTTP requests (§53.9.4).
-
Async parallelization. Independent server calls in the same function body are parallelized in generated code; dependent ones are sequenced. The developer writes flat synchronous-looking code; the compiler emits
Promise.allandawaitcorrectly (§13.2).
The compiler is the dev's best friend. That phrase comes up a lot in my notes. This is what it means in practice. Every line of the framework boilerplate above is moved into the compiler, where it cannot drift, cannot be skipped under deadline pressure, and cannot be wrong without the build failing.
What this kills
- The
app/api/directory. There aren't any route files. There are functions. - Hand-written fetch wrappers. There is no
apiClient.ts. - tRPC and OpenAPI codegen steps. The type goes through the boundary natively because the compiler owns both sides.
- Zod-on-the-wire. Inline predicates are the type. A schema file isn't a separate artifact. Why would anyone bring zod into a scrml project?
- Type-drift bugs that ship to prod. The client and server agree on the shape because there is one declaration.
What is still real
Server-side concerns don't disappear. They just stop being plumbing.
-
Auth. Still your job. The
serverannotation is described in the spec as a security escape hatch precisely because compile-time inference of "what touches protected data" is not always sufficient on its own (§11.4). Annotate auth-touching functions withserverexplicitly. -
Rate limiting. A
<program ratelimit="100/min">attribute generates a sliding-window limiter (§39.2.4). Tune the rate to your business; the mechanism is built in. -
Input validation against business rules. Predicates handle shape and range. Business rules ("this user can submit at most 3 of these per day") are still business logic. They live inside the
server fn. They benefit from running where the data lives.
The line between "plumbing the framework forced you to write" and "actual business logic" gets a lot brighter when one side of it is gone.
The deeper claim
A reactive system that wires its dependencies at compile time does no work at runtime to figure out what to update. A query that batches itself at compile time doesn't need a DataLoader. A boundary that is enforced at compile time doesn't need a validator on the wire.
The runtime does less because the compiler did more. The seam between client and server stops being a place where bugs live and starts being a place where the type system has the most leverage. That is the design. A little short of perfect is still pretty awesome.
Further reading
- Why programming for the browser needs a different kind of language. The high-altitude six-feature overview that this piece zooms in on.
- What npm package do you actually need in scrml?. The package-list-collapses argument worked through one tier at a time.
- What scrml's LSP can do that no other LSP can, and why giti follows from the same principle. What vertical integration unlocks for tooling and version control.
- Introducing scrml: a single-file, full-stack reactive web language. The starting-point overview if you haven't seen scrml before.
-
Null was a billion-dollar mistake. Falsy was the second.. On
not, presence as a type-system question, and why scrml refuses to inherit JavaScript's truthiness rules. - scrml's Living Compiler. The transformation-registry framing.
- scrml on GitHub: github.com/bryanmaclee/scrmlTS. The working compiler, examples, spec, benchmarks.
Top comments (0)