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:

api.glyph
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",
    })),
  }
}
what you did not write
// 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:

schema.glyph
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(),
  })
the drift Glyph removes
// 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:

from an OpenAPI / JSON Schema spec
glyph gen openapi petstore.yaml \
  --out src/
from a TypeScript .d.ts
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.