05 · Data boundaries
Do I have to hand-write a validator for every API?
No. In Glyph a type is its own validator. Declare a
type and the compiler generates a runtime descriptor beside it, so an
untrusted request or response body validates with one call — no separate
zod schema to write and keep in sync.
Why it matters
In most TypeScript stacks every payload is described twice: once as a
type for the compiler, and again as a hand-written validator so the value is
actually that shape at runtime. The two drift, and the drift is exactly where bad data
slips in. Glyph collapses them into one declaration — the type produces the
validator, so they cannot disagree.
See it
Declare the DTO once. Its descriptor gives you .parse (validate an untrusted
value into the type) and .is (a type guard) for free:
type NewTask = {
title: string,
done: bool,
}
fn create(req: Request)
-> Result<Response, string> {
return match NewTask.parse(req.body) {
Ok(task) => Ok(json(201, task)),
Err(issues) => Ok(json(400, {
error: "invalid task",
})),
}
}
// No zod schema:
const NewTask = z.object({
title: z.string(),
done: z.boolean(),
});
// No drift between the type and
// the validator, because there is
// only one declaration.
// A bad body — {"title": 123} —
// takes the Err arm. Only a
// well-formed body reaches Ok.
Composing your own — the output type is derived, not trusted
Sometimes you want a zod-style combinator: build a validator by composing
smaller ones. The trap in TypeScript is that the combinator's output type and the
shape you passed it drift apart — you write the shape, then repeat yourself
in a second type parameter, and nothing checks that they agree. Glyph's
infer_shape<Shape> derives the output type from the shape,
and tsc checks your annotation against it. A shape that omits a field of the
type you claim doesn't compile:
fn object_schema<Shape: Record<string, Schema<unknown>>>(
shape: Shape,
) -> Schema<infer_shape<Shape>> { ... }
type User = { name: string, age: number }
// The shape must produce a User, or this
// line does not compile:
const user_schema: Schema<User> =
object_schema({
name: string_schema(),
age: number_schema(),
})
// TS/zod: shape and output type are two
// separate things you keep in sync by hand.
const s = z.object({ name: z.string() });
type User = { name: string; age: number };
// `s` is inferred { name } — but nothing
// checks it against User. The `age` you
// forgot is caught at runtime, if ever.
// Glyph: drop `age` from the shape and
// `Schema<User>` fails to compile. The
// shape is the single source of truth.
The descriptor is the same one that makes json.parse<T> safe: give it a
JSON string and it parses and validates in one step. A request body arrives
already decoded as an unknown, so there you reach for
T.parse(value) instead — same guarantee, no cast.
The rest of the untrusted boundary is typed the same way: header(req, name)
and query_param(req, name) return Option<string>, so a
missing header or query parameter is None and the match
forces you to handle it — omit the None arm and it won't
compile. Absent input can't be read as if it were present.
You don't even have to write the types
If the shape already exists in an OpenAPI spec or a TypeScript declaration file, generate
the Glyph types from it — real, committed, greppable type declarations,
each with its descriptor, not an inferred phantom:
glyph gen openapi petstore.yaml \ --out src/
glyph gen dts vendor/types.d.ts \ --out src/
Regeneration is idempotent, so a spec change is a clean, reviewable diff. Generation is wire-faithful: where a construct has no exact Glyph form — a string enum, an undiscriminated union — it narrows and prints a note rather than emit a validator that would reject real data.
Generic types validate too. A Paginated<T> gets a descriptor whose
parse takes the type argument — Paginated.parse<User>(body)
checks every items entry as a User, not just for
presence, and match v { is Paginated<User> => ... } narrows the same way.
The honest edge: descriptors cover the shapes most payloads are made of
— objects, primitives, arrays, references, optional and nullable fields, and now
generic types at a given instantiation. A type Glyph did not generate (an external
.d.ts you only import) is type-checked but carries no descriptor until you
materialize it with glyph gen dts. That boundary is deliberate: a type Glyph
didn't emit can't promise runtime validation.
Where it stands
Shipping today
Every declared type, generic ones included, carries a runtime descriptor (.parse, .is, .schema); json.parse<T> validates JSON; infer_shape<Shape> derives a combinator's output type from its shape so the two can't drift; and glyph gen openapi / glyph gen dts generate committed, descriptor-bearing types from a spec or a .d.ts.
On the way
Wider generation coverage (discriminated unions, richer enums). Typed client and server code from a spec already ships — see client & server.