Documentation menu

Schema language reference

Complete reference for SchemaClient's schema language: types, constraint chains, named formats, and the @http, @graphql, @websocket, @sse and @grpc decorators.

Updated

The schema language is a compact DSL for API contracts: you declare the shape traffic is supposed to have, and SchemaClient validates real traffic against it — live in the app across REST, GraphQL, WebSocket, SSE and gRPC, and in CI via the CLI. One source file can describe every protocol your API speaks. This page is the complete syntax reference; for the workflow, start with schema validation.

Comments, type aliases and schema blocks

Comments start with # and run to the end of the line — chosen so they never collide with // inside URL strings. There are two top-level declarations:

# A reusable alias
type Email = string & format("email")

# A named contract — body is an object block or `= Type`
schema User {
  id:    string & format("uuid")
  email: Email
  role:  "admin" | "user" | "guest"
}

Commas and semicolons between fields are optional — newlines are enough. A schema can extend another object schema; redeclaring an inherited field requires the override prefix, otherwise you get a diagnostic. Type aliases take generic parameters:

type Page<T> = { items: T[], total: integer }

schema Response(200) = Page<User>

Primitives and constraint chains

Eight primitives: string, number, integer, boolean, object, any, unknown, null. integer rejects non-whole numbers; any and unknown match everything; object matches any non-array object.

Constraints chain onto a type with &:

type Username = string & minLength(3) & maxLength(32) & pattern("[a-z0-9_]+")
type Age      = integer & min(0) & max(150)
type Tags     = string[] & minItems(1) & maxItems(20) & uniqueItems

| Applies to | Constraints | | --- | --- | | string | minLength(n), maxLength(n), pattern("regex"), format("name") | | number / integer | min(n), max(n) (aliases: minimum, maximum), multipleOf(n) | | arrays | minItems(n), maxItems(n), uniqueItems | | any type | enum(a, b, c) | | documentation only | description, example, default, deprecated — no runtime effect |

pattern is anchored: the regex must match the entire string, not a substring. An unknown constraint name is a warning, not an error, and a duplicated constraint is ignored with a warning.

Named formats

format("name") validates a string against one of sixteen built-in formats:

| Format | Example | | --- | --- | | email | user@example.com | | uuid | 550e8400-e29b-41d4-a716-446655440000 | | date | 2026-01-15 | | time | 14:30:00Z | | date-time | 2026-01-15T14:30:00Z | | ipv4 | 192.168.1.1 | | ipv6 | 2001:db8::1 | | hostname | api.example.com | | uri / url | https://example.com/path | | json-pointer | /users/0/name | | e164 | +14155552671 | | slug | hello-world | | alphanumeric | abc123 | | hex-color | #ff8800 | | credit-card | 4111111111111111 (Luhn check) |

An unknown format name downgrades to a warning so a typo never silently passes as a hard failure in the wrong direction.

Fields and type expressions

schema Example {
  id:        string                        # required
  nickname?: string                        # optional — may be absent
  bio:       string?                       # nullable — may be null
  avatar?:   string?                       # both
  tags:      string[]                      # array
  role:      "admin" | "user"              # union of literals
  point:     [number, number]              # tuple — fixed length and order
  address:   { city: string, zip?: string }  # nested object, any depth
  "x-trace": string                        # quote names that are not identifiers
}

Fields are required by default. ? after the name makes the field optional; ? after the type makes the value nullable — these are independent, and the validator reports missing-required and forbidden-null as distinct errors. Literals can be strings, numbers or booleans. Unions must be unambiguous: a value that matches more than one variant is an error, not a pass, which keeps discriminated unions honest.

Protocol decorators

A decorator binds a schema to live traffic. The role names (Request, Response, Params, Send, …) are scoped to their decorator address, so every endpoint declares its own set without collisions.

@http

@http("POST /users")
schema Request { email: Email, name: string & minLength(1) }

@http("POST /users")
schema Response(201) { id: string & format("uuid") }

@http("POST /users")
schema Response(400) { error: string, field: string? }

@http("GET /users/{id}")
schema Params { id: string & format("uuid") }

Roles: Request, Response(status), Params, Headers, Cookies. A bare Response defaults to status 200. @endpoint is accepted as an alias for @http.

@graphql

@graphql("query GetUser")
schema Variables { id: string & format("uuid") }

@graphql("query GetUser")
schema Data { user: User }

Roles: Variables, Data, Errors. Schemas are matched by the operation name in a query, mutation or subscription — see GraphQL requests.

@websocket

@websocket("/ws/chat", "type")
schema Send("message")    { text: string }

@websocket("/ws/chat", "type")
schema Receive("message") { from: string, text: string }

The second argument names the discriminator field inside each JSON message; the tag in Send("…") / Receive("…") is the value that selects the schema. Send covers messages you transmit, Receive covers messages from the server.

@sse

@sse("/events/orders", "event")
schema Event("created")   { id: string & format("uuid"), total: number & min(0) }

@sse("/events/orders", "event")
schema Event("cancelled") { id: string & format("uuid"), reason: string }

Event("name") is matched against the frame-level event: name of the SSE stream — not a field inside the data payload. That is the key difference from WebSocket discriminators.

@grpc

@grpc("UserService.GetUser")
schema Request  { id: string & format("uuid") }

@grpc("UserService.GetUser")
schema Response { user: User }

Roles: Request, Response, plus RequestStream and ResponseStream for the streaming halves of a method.

How matching works at runtime

  • Paths. Literal segments match exactly. {id} (or :id) captures one segment, * matches any one segment, ** matches everything after it. Query strings and trailing slashes are ignored.
  • Status. Response(404) validates only 404 responses. A bare Response defaults to 200 — there is currently no catch-all "any status" response schema, so declare each status you care about.
  • WebSocket. A message is matched by direction, path, and the discriminator value. Frames whose tag matches no declared schema — and non-JSON text frames — are reported as no schema, which is distinct from matched and failed.
  • SSE. Events are matched by path plus the frame's event: name.
  • gRPC. Messages are matched by service, method, and direction.

Strictness and extra fields

Missing required fields, null in non-nullable fields, type mismatches, and failed constraints are errors. Fields present in the payload but not declared in the schema are flagged as warnings by default — you see the drift without the check failing. The CLI keeps the same default and adds --schema-strict, which promotes extra fields to failures; turn it on in CI once a contract is stable. Unknown formats and unknown constraint names also degrade to warnings, so an imperfect schema tells you it is imperfect instead of lying in either direction.

Frequently asked questions

What is the difference between an optional field and a nullable field in a SchemaClient schema?

name?: string means the field may be absent from the object. name: string? means the field must be present but may be null. They are independent — name?: string? allows both — and the validator reports them as different errors: a missing required field versus a null in a non-nullable field.

What formats can SchemaClient validate with format()?

Sixteen named formats: email, uuid, date, time, date-time, ipv4, ipv6, hostname, uri, url, json-pointer, e164, slug, alphanumeric, hex-color, and credit-card. format() only applies to strings, and an unknown format name produces a warning rather than an error.

Does SchemaClient fail validation when a response contains extra fields?

Not by default. Fields present in the payload but not declared in the schema are flagged as warnings, so you see the drift without failing the check. In the CLI, --schema-strict promotes extra fields to hard failures — useful in CI once a contract is stable.