Starting from an annoying production incident
The most common trap in front/back-end integration: the backend assumes price is a number, the frontend sends "128.5" (a string) or omits userId, and it only blows up with a 500 at runtime. Human review cannot catch this class of bug, and intuition won't cover all cases—the right move is to use JSON Schema to declare up front, as a contract, "this must be a number, this is required, the array needs at least one element".
This article walks you from validation to type generation.
A minimal schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["id", "total"],
"properties": {
"id": { "type": "string", "minLength": 1 },
"total": { "type": "number", "minimum": 0 },
"items": { "type": "array", "minItems": 1,
"items": { "type": "object",
"properties": {
"sku": { "type": "string" },
"price": { "type": "number", "minimum": 0 }
}, "required": ["sku", "price"] } },
"status": { "enum": ["paid", "pending", "cancelled"] }
}
}
This schema expresses four hard rules: id/total required, total ≥ 0, items a non-empty array whose elements all contain sku+price, and status limited to one of three values. Run a validator against it and problems surface immediately.
Core keyword cheat sheet
| Keyword | Purpose |
|---|---|
type |
object/array/string/number/integer/boolean/null |
required |
required field list |
properties |
per-field sub-schemas of an object |
items / prefixItems |
array element schema |
enum |
allowed value set |
const |
fixed value (discriminator) |
allOf/anyOf/oneOf |
combine multiple sub-schemas |
$ref |
reference an already-defined schema (reuse) |
format / pattern |
format and regex constraints |
Tightening the generated schema with strictness
Auto-generating from a JSON sample (json-to-jsonschema) yields loose inference—a fine starting point but with no enforcing power. To make it actually validate, add three things by hand:
- Explicit
required: list every field that must be present; - Value constraints: add
enum/format(date-time,email) to strings,minimum/maximumto numbers,minItems/maxItemsto arrays; - A single entry via
$ref + $defs: extract reusable objects into$defsand reference them with$ref, so the schema doesn't bloat with duplication.
oneOf for discriminated types
Integration often hits event payloads whose shape differs by a field value—like a notification where type tells email from sms:
"oneOf": [
{ "properties": { "type": { "const": "email" }, "to": { "type": "string" } }, "required": ["to"] },
{ "properties": { "type": { "const": "sms" }, "phone": { "type": "string" } }, "required": ["phone"] }
]
oneOf matches exactly one; the validator can use the const discriminator to match precisely.
Generating strong types from a schema
Once the schema is the single source of truth, the rest is "translate it into each language's types":
JSON sample ──json-to-jsonschema──▶ Schema ──schema-to-typescript──▶ interface definitions
──schema-to-go──▶ struct definitions
- JSON → type: fast, but detached from structural constraints; a sample missing a field means a missing type;
- Schema → type: preserves enum/required/nullable semantics, produces more reliable output, and validation and types stay in sync.
Wire the whole chain into your JSON tooling: format → generate schema → tighten → generate DTOs in one run.
Landing checklist
- [ ] Define a schema for every API endpoint, saved as a versioned file;
- [ ] Start with
json-to-jsonschema, then add required/constraints by hand; - [ ] Drive runtime validation (backend or API gateway) and type generation from the same source;
- [ ] Use
oneOf + constfor discriminated unions and$refto reuse common objects; - [ ] Once the contract is set, generate TypeScript/Go DTOs instead of hand-writing them.
Self-check
Take a real API response from a service you own and run the full flow: format → generate schema → fill in required → generate TS types. Confirm the resulting types let your compiler catch a "missing field" risk directly. When that step works, you have truly grasped the value of JSON Schema.