glon
A Gleam library for JSON Schema generation and decoding. Define a schema once, then use it to both generate a JSON Schema string and decode JSON values into typed Gleam data.
gleam add glon@1
How it works
JsonSchema(t) is an opaque type that pairs a JSON Schema definition with a decoder. When you build a schema using the builder API, you get a value that can:
- Generate a JSON Schema string via
to_string - Decode a JSON value into a typed Gleam result via
decode
The schema and decoder are always in sync – if you say a field is a string, the decoder knows to decode a string.
Quick start
import glon
pub type User {
User(name: String, age: Int)
}
fn user_schema() {
use name <- glon.field("name", glon.string())
use age <- glon.field("age", glon.integer())
glon.success(User(name:, age:))
}
pub fn main() {
let schema = user_schema()
// Generate JSON Schema
glon.to_string(schema)
// -> {"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"]}
// Decode JSON values
glon.decode(schema, from: "{\"name\":\"Alice\",\"age\":30}")
// -> Ok(User(name: "Alice", age: 30))
}
Full example
A more realistic schema with nested objects, arrays, optional fields, nullable fields, and descriptions:
import gleam/option.{type Option}
import glon
pub type Address {
Address(street: String, city: String, zip: Option(String))
}
pub type Tag {
Tag(key: String, value: String)
}
pub type Company {
Company(
name: String,
founded_year: Int,
public: Bool,
rating: Option(Float),
address: Address,
tags: List(Tag),
website: Option(String),
phone: Option(String),
)
}
fn address_schema() {
use street <- glon.field("street", glon.string() |> glon.describe("Street address"))
use city <- glon.field("city", glon.string())
use zip <- glon.optional("zip", glon.string() |> glon.describe("ZIP or postal code"))
glon.success(Address(street:, city:, zip:))
}
fn tag_schema() {
use key <- glon.field("key", glon.string())
use value <- glon.field("value", glon.string())
glon.success(Tag(key:, value:))
}
fn company_schema() {
use name <- glon.field("name", glon.string() |> glon.describe("Legal company name"))
use founded_year <- glon.field("founded_year", glon.integer() |> glon.describe("Year the company was founded"))
use public <- glon.field("public", glon.boolean() |> glon.describe("Whether publicly traded"))
use rating <- glon.optional_or_null("rating", glon.number() |> glon.describe("Rating from 0.0 to 5.0"))
use address <- glon.field("address", address_schema())
use tags <- glon.field("tags", glon.array(of: tag_schema()) |> glon.describe("Categorization tags"))
use website <- glon.optional("website", glon.string())
use phone <- glon.optional_or_null("phone", glon.string())
glon.success(Company(name:, founded_year:, public:, rating:, address:, tags:, website:, phone:))
}
glon.to_string(company_schema()) produces:
{
"type": "object",
"properties": {
"name": { "type": "string", "description": "Legal company name" },
"founded_year": { "type": "integer", "description": "Year the company was founded" },
"public": { "type": "boolean", "description": "Whether publicly traded" },
"rating": { "type": ["number", "null"], "description": "Rating from 0.0 to 5.0" },
"address": {
"type": "object",
"properties": {
"street": { "type": "string", "description": "Street address" },
"city": { "type": "string" },
"zip": { "type": "string", "description": "ZIP or postal code" }
},
"required": ["street", "city"]
},
"tags": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": { "type": "string" },
"value": { "type": "string" }
},
"required": ["key", "value"]
},
"description": "Categorization tags"
},
"website": { "type": "string" },
"phone": { "type": ["string", "null"] }
},
"required": ["name", "founded_year", "public", "address", "tags"]
}
The same schema decodes JSON into typed Gleam values:
// All fields present
glon.decode(company_schema(), from: "{\"name\":\"Acme Corp\",\"founded_year\":1995,\"public\":true,\"rating\":4.5,\"address\":{\"street\":\"123 Main St\",\"city\":\"Springfield\",\"zip\":\"62704\"},\"tags\":[{\"key\":\"industry\",\"value\":\"tech\"}],\"website\":\"https://acme.example.com\",\"phone\":\"+1-555-0100\"}")
// -> Ok(Company(name: "Acme Corp", founded_year: 1995, public: True, rating: Some(4.5), ...))
// Only required fields
glon.decode(company_schema(), from: "{\"name\":\"Tiny LLC\",\"founded_year\":2020,\"public\":false,\"address\":{\"street\":\"1 Elm St\",\"city\":\"Shelbyville\"},\"tags\":[]}")
// -> Ok(Company(name: "Tiny LLC", ..., rating: None, website: None, phone: None))
// Explicit nulls
glon.decode(company_schema(), from: "{\"name\":\"Null Inc\",\"founded_year\":2010,\"public\":true,\"rating\":null,\"address\":{\"street\":\"0 Zero Rd\",\"city\":\"Nowhere\"},\"tags\":[],\"phone\":null}")
// -> Ok(Company(name: "Null Inc", ..., rating: None, phone: None))
API reference
Primitives
| Function | Type | JSON Schema |
|---|---|---|
glon.string() | JsonSchema(String) | {"type": "string"} |
glon.integer() | JsonSchema(Int) | {"type": "integer"} |
glon.number() | JsonSchema(Float) | {"type": "number"} |
glon.boolean() | JsonSchema(Bool) | {"type": "boolean"} |
Composites
| Function | Type | JSON Schema |
|---|---|---|
glon.array(of: schema) | JsonSchema(List(t)) | {"type": "array", "items": ...} |
glon.nullable(schema) | JsonSchema(Option(t)) | {"type": ["<t>", "null"]} |
Object fields
| Function | Required? | Nullable? | Gleam type |
|---|---|---|---|
glon.field | yes | no | t |
glon.optional | no | no | Option(t) |
glon.optional_or_null | no | yes | Option(t) |
glon.field_with_default | no | no | t (uses default when absent) |
All four are used with Gleam’s use syntax to chain fields:
use value <- glon.field("name", glon.string())
use value <- glon.optional("name", glon.string())
use value <- glon.optional_or_null("name", glon.string())
use value <- glon.field_with_default("port", glon.integer(), default: 8080, encode: json.int)
Enum / Const
| Function | Type | JSON Schema |
|---|---|---|
glon.enum(["a", "b"]) | JsonSchema(String) | {"type": "string", "enum": ["a", "b"]} |
glon.enum_map([#("a", A), #("b", B)]) | JsonSchema(t) | {"type": "string", "enum": ["a", "b"]} |
glon.constant("a") | JsonSchema(String) | {"type": "string", "const": "a"} |
glon.constant_map("a", A) | JsonSchema(t) | {"type": "string", "const": "a"} |
The _map variants decode to a custom Gleam type instead of String:
type Color { Red Green Blue }
// Decodes to String
glon.enum(["red", "green", "blue"])
// Decodes to Color
glon.enum_map([#("red", Red), #("green", Green), #("blue", Blue)])
Combinators
| Function | JSON Schema | Description |
|---|---|---|
glon.map(schema, transform) | (unchanged) | Transform decoded type without changing schema |
glon.one_of([a, b, ...]) | {"oneOf": [...]} | Value must match exactly one sub-schema |
glon.any_of([a, b, ...]) | {"anyOf": [...]} | Value must match at least one sub-schema |
glon.tagged_union("type", [...]) | {"oneOf": [...]} with discriminator | Discriminated union with tag field |
Use map to align types for one_of / any_of:
type Value { TextVal(String) NumVal(Int) }
let schema = glon.one_of([
glon.string() |> glon.map(TextVal),
glon.integer() |> glon.map(NumVal),
])
Use tagged_union for discriminated unions:
type Shape { Circle(Float) Square(Float) }
let schema = glon.tagged_union("type", [
#("circle", {
use radius <- glon.field("radius", glon.number())
glon.success(Circle(radius))
}),
#("square", {
use side <- glon.field("side", glon.number())
glon.success(Square(side))
}),
])
String validation
All string constraints are enforced during decode.
| Function | JSON Schema | Decode behavior |
|---|---|---|
glon.min_length(schema, n) | {"minLength": n} | Rejects strings shorter than n |
glon.max_length(schema, n) | {"maxLength": n} | Rejects strings longer than n |
glon.pattern(schema, regex) | {"pattern": "..."} | Rejects strings not matching the regex |
glon.string()
|> glon.min_length(1)
|> glon.max_length(100)
|> glon.pattern("^[a-zA-Z]+$")
Number validation
All number constraints are enforced during decode. Constraint values are Float, and work on both integer() and number() schemas.
| Function | JSON Schema | Decode behavior |
|---|---|---|
glon.minimum(schema, n) | {"minimum": n} | Rejects values < n |
glon.maximum(schema, n) | {"maximum": n} | Rejects values > n |
glon.exclusive_minimum(schema, n) | {"exclusiveMinimum": n} | Rejects values <= n |
glon.exclusive_maximum(schema, n) | {"exclusiveMaximum": n} | Rejects values >= n |
glon.multiple_of(schema, n) | {"multipleOf": n} | Rejects values not a multiple of n |
glon.integer()
|> glon.minimum(0.0)
|> glon.maximum(100.0)
glon.number()
|> glon.exclusive_minimum(0.0)
|> glon.multiple_of(0.5)
Annotations
glon.string() |> glon.describe("A human-readable description")
Operations
glon.to_string(schema) // -> String (JSON Schema)
glon.to_json(schema) // -> json.Json (for embedding in larger structures)
glon.decode(schema, from: json_string) // -> Result(t, json.DecodeError)
JSON Schema coverage
| Feature | Status | Notes |
|---|---|---|
| Types | ||
string | ✅ Supported | |
integer | ✅ Supported | |
number | ✅ Supported | |
boolean | ✅ Supported | |
array | ✅ Supported | |
object | ✅ Supported | Nested objects, required/optional fields |
null / nullable | ✅ Supported | Via nullable, optional_or_null |
enum | ✅ Supported | String values via enum, enum_map |
const | ✅ Supported | String values via constant, constant_map |
| Composition | ||
oneOf | ✅ Supported | Via one_of, tagged_union |
anyOf | ✅ Supported | Via any_of |
allOf | 🚫 Out of scope | Incompatible with Gleam’s type system |
not | 🔲 Not yet | Negation |
$ref / $defs | 🔲 Not yet | Reusable schema definitions |
| Object keywords | ||
properties | ✅ Supported | |
required | ✅ Supported | |
additionalProperties | 🔲 Not yet | |
patternProperties | 🔲 Not yet | |
propertyNames | 🔲 Not yet | |
minProperties / maxProperties | 🔲 Not yet | |
dependentRequired / dependentSchemas | 🔲 Not yet | |
| Array keywords | ||
items | ✅ Supported | |
prefixItems | 🔲 Not yet | Tuple validation |
minItems / maxItems | 🔲 Not yet | |
uniqueItems | 🔲 Not yet | |
contains | 🔲 Not yet | |
| String validation | ||
minLength / maxLength | ✅ Supported | Via min_length, max_length |
pattern | ✅ Supported | Via pattern |
format | 🚫 Out of scope | Validating formats is out of scope |
| Number validation | ||
minimum / maximum | ✅ Supported | Via minimum, maximum |
exclusiveMinimum / exclusiveMaximum | ✅ Supported | Via exclusive_minimum, exclusive_maximum |
multipleOf | ✅ Supported | Via multiple_of |
| Annotations | ||
description | ✅ Supported | Via describe |
title | 🔲 Not yet | |
default | ✅ Supported | Via field_with_default |
examples | 🔲 Not yet | |
deprecated | 🔲 Not yet | |
readOnly / writeOnly | 🔲 Not yet | |
| Conditional | ||
if / then / else | 🔲 Not yet | |
| Meta | ||
$schema | 🔲 Not yet | Draft identifier |
$id | 🔲 Not yet | |
$comment | 🔲 Not yet |
Compatibility
- Requires
gleam_json>= 3.0 - Works on both Erlang and JavaScript targets