Part of the Lex project — Library · Manifesto · All packages
A runtime validation library for lex-lang,
inspired by pydantic's constraint
catalog + multi-error accumulation semantics but designed
ground-up for Lex's effect system, variant ADTs, and codegen
pipelines. Written in pure Lex — no Rust shims, no host hooks,
no native code. Runs on stock lex run / lex check and uses
only the existing stdlib (std.str, std.list, std.regex,
std.json, std.datetime, std.random, std.crypto,
std.cli, ...).
Pick the one that fits your call-site shape; both produce the same
Result[_, List[Error]] end-to-end.
Hand-write the validator as a combineN over per-field checks.
Best when the field set is fixed at compile time and you want
maximum control over the constructed value.
import "./src/error" as e
import "./src/constraints" as c
import "./src/field" as f
import "./src/combine" as cm
import "./src/parse" as p
type User = { email :: Str, username :: Str, age :: Int }
fn build(em :: Str, un :: Str, ag :: Int) -> User {
{ email: em, username: un, age: ag }
}
fn parse_user(input :: Str) -> Result[User, List[e.Error]] {
cm.and_then(
p.from_json(input, ["email", "username", "age"]),
fn (raw :: User) -> Result[User, List[e.Error]] {
cm.combine3(
f.check_str("email", raw.email, [StrEmail]),
f.check_str("username", raw.username, [StrMinLen(3), StrMaxLen(32)]),
f.check_int("age", raw.age, [IntInRange(13, 130)]),
build
)
}
)
}Describe the schema as a value; runtime validation, JSON Schema
export, TypeScript stubs, and Python pydantic classes all derive
from the same ModelSchema. Best when the schema doubles as API
documentation or feeds downstream codegen.
import "./src/error" as e
import "./src/constraints" as c
import "./src/schema" as s
import "./src/validator" as v
fn user_schema() -> s.ModelSchema {
{
title: "User", description: "",
fields: [
s.required_str("email", [StrEmail]),
s.required_str("username", [StrMinLen(3), StrMaxLen(32)]),
s.required_int("age", [IntInRange(13, 130)]),
],
}
}
# One Validator value carries everything downstream.
fn user_validator() -> v.Validator { v.make(user_schema()) }The validator bundle exposes:
v.validate_str(user_validator(), body) # runtime validation
v.export_typescript(user_validator()) # TS interfaces
v.export_python(user_validator()) # Pydantic v2 classes
v.export_json_schema_str(user_validator()) # Draft 2020-12
v.export_openapi_str(user_validator()) # OpenAPI 3.1 component$ lex run examples/01_user_signup.lex format_demo
email: not a valid email address [email]
username: must be at least 3 characters [min_len]
username: does not match pattern ^[a-zA-Z0-9_]+$ [pattern]
age: must be >= 13 [min]
password: must be at least 8 characters [min_len]
password: does not match pattern .*[0-9].* [pattern]
$ lex run examples/02_nested.lex format_demo
email: not a valid email address [email]
address.street: must be at least 1 characters [min_len]
address.city: must be at least 1 characters [min_len]
address.zip: does not match pattern ^[0-9]{5}$ [pattern]
address.country: does not match pattern ^[A-Z]{2}$ [pattern]
$ lex run examples/03_list_of_items.lex format_demo
customer: must be at least 1 characters [min_len]
items[0].sku: does not match pattern ^[A-Z]{3}-[0-9]{4}$ [pattern]
items[1].quantity: must be > 0 [min]
items[1].price_cents: must be >= 0 [min]
items[2].sku: does not match pattern ^[A-Z]{3}-[0-9]{4}$ [pattern]
items[2].quantity: must be > 0 [min]Lex's type system already catches mismatched shapes at compile time
— that's the whole pitch. So why need pydantic-style runtime checks?
Because data crosses trust boundaries: HTTP bodies, query strings,
config files, env vars. The type system can't tell you a string
carrying a JSON body has a 5-digit zip; that an Int parsed from
a request body is between 13 and 130; that a list of items isn't
empty. These are runtime invariants the type system deliberately
doesn't model. lex-schema covers the gap with:
- Rich constraint catalog.
StrMinLen,StrMaxLen,StrPattern,StrEmail,StrUrl,StrUuid,StrOneOf,IntInRange,FloatFinite,ListNonEmpty,DateBefore,DateInRange, plus an open-endedvalidatefor custom predicates. - Multi-error accumulation. A bad signup form reports every failing field in one shot, not the first one that trips — pydantic semantics, applicative implementation.
- Path-aware errors. A leaf failure deep in a nested record
surfaces as
address.ziporitems[3].quantity, ready to render into a form-validation UI. - JSON / TOML / YAML entry points. Wraps
std.{json,toml,yaml}.parse_strictso the outer parse failure uses the sameErrorsshape as the field-level checks. - Safe-mode JSON. A first-class
JsonADT + recursive-descent parser for untrusted input. JSON type mismatches surface as typedErrors, not VM panics. - Coercion.
str → int / float / boolfor query strings, form bodies, env vars — every value arrives as text but downstream code wants typed fields. - Optional fields + defaults. Both pydantic patterns
(
Optional[T] = None,T = "default") cleanly handled. - Discriminated unions. Route a JSON payload to one of N variant-specific validators by a tag field.
- Datetime bounds. ISO 8601 parsing +
DateBefore/DateAfter/DateInRangeconstraints with canonical-form normalization. - Cross-field rules.
cm.cross_checkfor "password and confirm_password must match" — pydantic's@model_validatorequivalent. - Schema-as-value.
ModelSchemadescribes the field set declaratively; one value drives runtime validation and generates JSON Schema 2020-12 / OpenAPI 3.1 / TypeScript interfaces / Pydantic v2 classes. - JSON Schema import. Read a JSON Schema document and
produce a
ModelSchema— the inverse of the export path. - CLI integration. Wire
std.cli's argv parser through the schema; one call validates flags, options, and positionals. - Property-based testing. Generate constraint-respecting samples from any schema; assert the round-trip property (every generated sample validates).
- Fuzz driver. 30+ hand-picked malformed inputs verify every
validator surfaces
Errors, never a VM panic. - Webhook idempotency. SHA-256 content keys + a threaded
Set[Str]for at-least-once delivery deduplication.
Pre-1.0. The surface is small (19 modules, ~3700 lines of pure Lex),
and stable in the sense that no breaking changes are planned for the
listed API. Verified against lex-lang v0.8.2:
- Every
src/module type-checks (lex check). - Every test suite returns
run_all = 0(~308 cases across 25 suites). - Every
examples/demo runs end-to-end (23 examples). multipart/form-datais first-class; SQL DDL codegen joins the TS/Python/Zod/Rust/Go quartet.
CHANGELOG carries the exact lex --version used. All seventeen
ergonomic / correctness issues this library filed against lex-lang
have landed — sixteen closed in lex 0.8.0-0.8.2; the
remaining three closed in 0.8.2 (#331 datetime comparison,
#334 list.cons, #345 alias unfold through closure params).
The library now compiles and runs against vanilla lex with
zero workarounds in source.
| Module | Purpose | LoC |
|---|---|---|
src/error.lex |
Error type + path helpers + formatting |
~120 |
src/constraints.lex |
StrCheck/IntCheck/FloatCheck/ListCheck ADTs + evaluators |
~220 |
src/field.lex |
check_str/check_int/...; optional variants + with_default |
~250 |
src/combine.lex |
combine2..combine6, and_then, or_else, traverse, with_path, cross_check |
~230 |
src/parse.lex |
from_json / from_toml / from_yaml |
~50 |
src/coerce.lex |
str → int / float / bool coercion + Map-based require_* / optional_* |
~190 |
src/json_value.lex |
Safe-mode Json ADT + parser + path-aware extractors + stringify |
~640 |
src/datetime.lex |
ISO 8601 datetime + ordered DateCheck bounds |
~170 |
src/union.lex |
Discriminated-union (tagged-union) dispatch | ~80 |
src/schema.lex |
ModelSchema value + schema-driven validate + JSON Schema / OpenAPI export |
~310 |
src/schema_import.lex |
JSON Schema → ModelSchema (inverse of to_json_schema) |
~220 |
src/cli.lex |
std.cli ↔ ModelSchema bridge (parse_and_validate_argv) |
~80 |
src/sdk.lex |
ModelSchema → TS / Python / Zod / Rust / Go / SQL DDL codegen |
~1050 |
src/property.lex |
Schema-driven sample generation + round-trip property check | ~310 |
src/validator.lex |
Validator bundle (schema + pre-computed exports + validate) |
~70 |
src/fuzz.lex |
Malformed-input fuzz driver — every category surfaces as Err |
~140 |
src/problem.lex |
RFC 7807 problem+json renderer (presets + serialization) |
~120 |
src/migrate.lex |
Schema migrations (Transform ADT) + backward-compat check |
~320 |
src/form.lex |
x-www-form-urlencoded + multipart/form-data decoder + dispatch by Content-Type |
~400 |
src/html.lex |
HTML entity escape for XSS-safe interpolation into HTML body + attribute contexts |
~55 |
The library has no dependencies beyond lex-lang's standard library.
Drop the src/ directory next to your code and import what you need:
import "./lex-schema/src/error" as e
import "./lex-schema/src/constraints" as c
import "./lex-schema/src/field" as f
import "./lex-schema/src/combine" as cm
import "./lex-schema/src/parse" as p
# … plus json_value / schema / validator / etc. as needed.Each module is a separate import because Lex resolves module names file-by-file; the import is a few lines, no bundler.
type Error = { path :: Str, code :: Str, message :: Str }
# Construction
e.error(path, code, message) -> Error
e.single(path, code, message) -> List[Error] # one-item list
# Composition
e.concat(a, b) -> List[Error]
e.flatten([a, b, ...]) -> List[Error]
# Path manipulation
e.prefix_path("address", inner) # leaf "zip" -> "address.zip"
e.prefix_index("items", 3, inner) # leaf "name" -> "items[3].name"
# Diagnostics
e.format(errs) # "path: message [code]\n..."Each check is a value (a variant), not a closure — so you can
inspect, store, and audit them, e.g. lex audit --calls StrPattern
lists every regex check in a tree.
type StrCheck =
StrNonEmpty | StrMinLen(Int) | StrMaxLen(Int) | StrExactLen(Int)
| StrPattern(Str) | StrOneOf(List[Str])
| StrStartsWith(Str) | StrEndsWith(Str)
| StrEmail | StrUrl | StrUuid
type IntCheck =
IntMin(Int) | IntMax(Int) | IntInRange(Int, Int)
| IntEq(Int) | IntOneOf(List[Int])
| IntPositive | IntNonNegative
type FloatCheck =
FloatMin(Float) | FloatMax(Float) | FloatInRange(Float, Float)
| FloatFinite | FloatPositive | FloatNonNegative
type ListCheck =
ListMinLen(Int) | ListMaxLen(Int) | ListExactLen(Int) | ListNonEmpty
# Plus `datetime.DateCheck` for ISO 8601 bounds:
type DateCheck =
DateBefore(Str) | DateAfter(Str)
| DateAtOrBefore(Str) | DateAtOrAfter(Str)
| DateInRange(Str, Str)Every validator returns Result[T, List[Error]] and accumulates
every failing constraint for that field — never short-circuits at
the first.
f.check_str(path, value, [StrEmail, StrMaxLen(254)])
f.check_int(path, value, [IntInRange(0, 130)])
f.check_float(path, value, [FloatFinite, FloatNonNegative])
f.check_bool(path, value)
f.check_list_shape(path, xs, [ListMinLen(1)])
f.check_list_of(name, xs, [ListMaxLen(50)], element_validator)
f.validate(path, value, code, predicate) # escape hatch
# Optional variants — None is always Ok(None).
f.check_optional_str(path, opt, [StrMaxLen(40)])
f.check_optional_int(path, opt, [IntPositive])
f.check_optional_float(path, opt, [FloatFinite])
f.check_optional_bool(path, opt)
# Defaults — lift Option[T] to T then run the regular checker.
f.with_default(opt, "anonymous")
f.with_default_lazy(opt, fn () -> Str { lookup_default() })For query strings, form posts, env vars — every value arrives as a
Str but downstream code wants typed fields with the regular
constraint catalog.
coerce.coerce_str_to_int(path, "42") -> Result[Int, List[Error]]
coerce.coerce_str_to_float(path, "3.14") -> Result[Float, List[Error]]
coerce.coerce_str_to_bool(path, "yes") -> Result[Bool, List[Error]]
# Coerce + validate in one step.
coerce.check_str_as_int(path, s, [IntPositive])
coerce.check_str_as_float(path, s, [FloatNonNegative])
# Pull a value out of a Map[Str, Str] (query strings, form bodies).
coerce.require_int_from_map(qs, "page", [IntPositive])
coerce.require_bool_from_map(qs, "debug")
coerce.optional_str_from_map(qs, "query", [StrMaxLen(120)])Truthy bool words: true, 1, yes, on, y, t. Falsy:
false, 0, no, off, n, f. Case-insensitive, trimmed.
# Applicative builders — every failing branch's errors merge.
cm.combine2(ra, rb, builder)
cm.combine3(ra, rb, rc, builder)
cm.combine4(ra, rb, rc, rd, builder)
cm.combine5(ra, rb, rc, rd, re, builder)
cm.combine6(ra, rb, rc, rd, re, rf, builder)
# Monadic chaining — second step only runs if first succeeded.
cm.and_then(r, k)
# Recovery — handler sees the errors, decides the next move.
cm.or_else(r, handler)
# Walk a homogeneous list with per-element validation; errors merge.
cm.traverse(xs, f)
# Prefix every error's path. Used at nested-record boundaries.
cm.with_path("address", inner_result)
# Cross-field rules — run after a successful combineN via and_then.
cm.cross_check(value, [rule1, rule2, ...]) # rule :: T -> Option[List[Error]]
cm.require(value, predicate, path, code, message) # one-shot
# Lift / fail helpers
cm.pure(v) # Ok(v)
cm.fail(es) # Err(es)# Parse JSON, then validate. Strict mode checks listed top-level fields.
p.from_json(source, ["email", "age"]) -> Result[T, List[Error]]
p.from_toml(source, ["license"]) -> Result[T, List[Error]]
p.from_yaml(source, ["name"]) -> Result[T, List[Error]]For untrusted inputs where the JSON↔Lex type guarantees of the
polymorphic from_json path aren't acceptable. The library parses
into a Json ADT (JNull | JBool(Bool) | JInt(Int) | JFloat(Float) | JStr(Str) | JList(...) | JObj(...)) and every extractor is
total: a JSON "thirty" against an expected Int field is a
type error, not a VM crash.
# Parse: O(n) for escape-free strings, O(n²) worst-case for nested
# arrays / objects (blocked on lex-lang#334).
jv.parse(body) -> Result[Json, ParseErr]
jv.parse_into_errors(body) -> Result[Json, List[Error]]
# Field extractors — same constraint catalog, total over input.
jv.j_str(prefix, json, "email", [StrEmail])
jv.j_int(prefix, json, "age", [IntInRange(13, 130)])
jv.j_optional_str(prefix, json, "nickname", [StrMaxLen(40)])
jv.j_obj(prefix, json, "address") # nested record
jv.j_list(prefix, json, "items") # list of Json
# Dotted-path navigation — no intermediate with_path needed.
jv.get_path(json, "user.address.zip") -> Option[Json]
jv.j_str_at(json, "user.email", [StrEmail])
# Round-trip-able serializer.
jv.stringify(json) # compact
jv.stringify_pretty(json) # 2-space indentRoute a JSON payload to one of N variant-specific validators based
on a tag field. Three failure modes each tag with their own code:
missing tag → missing, wrong type → type, unknown discriminator
→ one_of (message lists the known set).
type WebhookEvent =
Signup({ user_id :: Str, email :: Str })
| Purchase({ user_id :: Str, cents :: Int })
fn validate_event(j :: jv.Json) -> Result[WebhookEvent, List[Error]] {
u.discriminate("", j, "event", [
("signup", validate_signup),
("purchase", validate_purchase),
])
}ISO 8601 parsing + ordered bound checks. The validator returns the
canonical UTC string so downstream code stores a uniformly ordered
form. Comparisons go through a packed YYYYMMDDhhmmss Int key
(workaround for lex-lang#331 + #332).
dt.check_iso_datetime("ts", s, [
DateAfter("2026-01-01T00:00:00Z"),
DateAtOrBefore("2026-12-31T23:59:59Z"),
])
dt.check_iso_in_past("created_at", s) # [time]-flavored preset
dt.check_iso_in_future("expires_at", s)A ModelSchema is a List[Field], each Field carries a
FieldKind ADT (KStr / KInt / KFloat / KBool / KArray /
KObject) with constraint lists inline. Three operations off one
schema:
# Run-time validation, returns normalized Json.
s.validate(schema, json) -> Result[Json, List[Error]]
# Emit JSON Schema 2020-12 (full doc with $schema URI).
s.to_json_schema(schema) -> Json
# Emit OpenAPI 3.1 component schema (no $schema, with title).
s.to_openapi_schema(schema) -> Json
# Builders that pin the nominal type (workaround for lex-lang#328).
s.required_str(name, checks) | s.required_int / float / bool / array / object
s.optional(field) # flip required → false
s.with_desc(field, "human-readable") # add descriptionRound-trip import:
si.from_str(json_schema_text) -> Result[ModelSchema, List[Error]]
si.from_json_schema(jv_value) -> Result[ModelSchema, List[Error]]make(schema) runs every emitter once and stores results in a
single record. Pass the Validator around for both runtime
validation and codegen.
let val := v.make(user_schema())
v.validate(val, payload_json) -> Result[Json, List[Error]]
v.validate_str(val, payload_str) -> Result[Json, List[Error]]
v.export_json_schema_str(val) -> Str
v.export_openapi_str(val) -> Str
v.export_typescript(val) -> Str
v.export_python(val) -> Str
v.summary(val) -> Str # "Validator{title=..., fields=N}"
# Response / output validation (#1 spike) — FastAPI's response_model.
v.serialize(val, json) -> Result[Str, Errors] # default: lossy
v.serialize_lossy(val, json) -> Result[Str, Errors] # silently drop extras
v.serialize_strict(val, json) -> Result[Str, Errors] # error on extras
v.openapi_response(val) -> Json # responses[200] bodyThe lossy / strict pair maps directly to pydantic's
model_config = {"extra": "ignore" | "forbid"} and to FastAPI's
default-lossy response_model behaviour. openapi_response returns the
{ description, content: { application/json: { schema } } } envelope so
downstream consumers (e.g. lex-web's OpenAPI exporter) can stash it under
whatever status key they need.
sdk.to_typescript(schema) # export interface User { ... }
sdk.to_python(schema) # class User(BaseModel): ...
sdk.to_zod(schema) # export const User = z.object({ ... })
sdk.to_rust_struct(schema) # pub struct User { ... } (#[derive(Serialize, Deserialize)])
sdk.to_go_struct(schema) # type User struct { ... `json:"..."` }
sdk.to_sql_ddl(schema, DialectPostgres) # CREATE TABLE "user" (...)
sdk.to_sql_ddl(schema, DialectSqlite) # CREATE TABLE "user" (...)StrOneOf renders as "a" | "b" (TS) / Literal["a", "b"] (Py)
/ z.enum(...) (Zod) / IN (...) (SQL). Nested records emit their
own interface/class/table; SQL nesting becomes a child table plus a
<field>_id FK column. Optional fields use ?: / Optional[T] /
.optional() / Option<T> / *T + omitempty / NULL-able.
Constraints lift where the target supports them: JSDoc hints (TS),
pydantic field args (Py), Zod chains, doc comments (Rust/Go), or
CHECK clauses (SQL — Postgres includes regex matches; SQLite
omits them because REGEXP requires a loadable extension).
cl.parse_and_validate_argv(spec, argv, schema)
-> Result[Json, List[Error]]
cl.help(spec) # human-readable
cl.describe(spec) # machine-readable JSONThree error sources tag distinctly: cli.parse failure (code
parse), bridge decode (parse), constraint failure (per-rule).
# Pure + deterministic via std.random's SplitMix64.
p.generate(schema, rng) -> (Json, Rng)
# n round-trip cycles. Ok(n) on a clean sweep.
p.round_trip(schema, n, seed) -> Result[Int, List[Error]]Constraint-respecting generators for str (length, format, OneOf), int (range, OneOf), float (range), bool, list (length, element- kind), and nested objects.
fz.run_all(schema) -> List[Tally]
fz.count_escapes(schema) -> Int # 0 = every input errored
fz.format_tallies(tallies) -> StrFive hand-picked catalogues (parse failures, type mismatches,
missing required, constraint failures, deep nesting) run against
the schema. The pass condition is count_escapes == 0 and no VM
panic — the latter enforced by the run completing.
form.decode_urlencoded(body) -> Map[Str, Str]
form.decode_multipart(body, boundary) -> Result[List[MultipartPart], Errors]
form.decode_body(body, content_type) -> Result[Map[Str, Str], Errors]
form.extract_boundary(content_type) -> Option[Str]
form.parts_to_map(parts) -> Map[Str, Str]
type MultipartPart = {
name :: Str,
filename :: Option[Str],
content_type :: Str,
body :: Str,
}decode_urlencoded handles +-as-space, %XX byte escapes, bare
keys, multi-=-in-value (matches URLSearchParams /
urllib.parse_qs). decode_multipart parses RFC 7578 multipart
bodies — walks --BOUNDARY segments, parses each part's
Content-Disposition for name + optional filename, respects
Content-Type per part, tolerates embedded \r\n\r\n in bodies,
validates the closing --BOUNDARY-- trailer. decode_body
dispatches on Content-Type (accepts the ; charset=UTF-8 suffix
and quoted boundaries) and flattens both formats to a uniform
Map[Str, Str], so the same coerce.require_*_from_map pipeline
serves urlencoded and multipart callers. Callers that need file
metadata go through decode_multipart directly.
html.escape(s) -> StrReplaces the five OWASP-recommended HTML special characters with
their HTML5 entities — & -> &, < -> <, > ->
>, " -> ", ' -> ' (numeric, not ',
to match Go's html.EscapeString and stay safe on legacy
parsers). Use it any time untrusted text is interpolated into an
HTML body or attribute value. Scope is HTML text + attribute
contexts only — CSS, JavaScript, and URL contexts have different
rules; using HTML escapes inside <script> / style= / href=
payloads is a real XSS bug. Intentionally not idempotent: feeding
already-escaped HTML in re-escapes the & of every entity, which
is the correct behaviour for the call-site this exists to
protect.
All examples in examples/ are runnable end-to-end via
lex run <file> <fn> [args]. Some require effect grants
(--allow-effects net,io,time); each example's header carries the
exact invocation.
| File | Demonstrates |
|---|---|
01_user_signup.lex |
One-shot signup form validation, error accumulation |
02_nested.lex |
Nested record (User → Address) with with_path |
03_list_of_items.lex |
Indexed list-of-records validation (items[3].sku) |
04_api_request.lex |
HTTP endpoint pipeline: parse → validate → 200/400 |
05_optional_and_defaults.lex |
Option[T] fields + with_default over a Map[Str, Str] source |
06_coerce_query_string.lex |
Query-string parsing with coercion + constraints |
07_safe_mode_json.lex |
Safe-mode validation via the Json ADT — total over malformed input |
08_discriminated_union.lex |
Webhook payload routing into a Lex ADT via u.discriminate |
09_datetime_and_paths.lex |
ISO 8601 bounds + dotted-path extraction (organizer.email) |
10_schema_introspection.lex |
ModelSchema value + run-time validation + JSON Schema / OpenAPI export |
11_cli_args.lex |
std.cli argv → ModelSchema validation in one call |
12_cross_field.lex |
Cross-field rules: password match, date ordering |
13_property_test.lex |
200 generate→validate cycles against a User schema; all pass |
14_json_schema_round_trip.lex |
Emit JSON Schema, parse it back, validate the same payload through both |
15_sdk_export.lex |
Generate TypeScript interfaces + Pydantic v2 classes from one schema |
16_validation_service.lex |
HTTP /v1/validate service accepting {schema, payload} over the wire |
17_webhook_dedup.lex |
SHA-256 idempotency keying on top of the discriminator pattern |
18_cli_codegen.lex |
Load a JSON Schema file from disk, emit TS / Python / JSON-Schema text |
19_fuzz_driver.lex |
Run 30+ malformed inputs through a schema; assert every one surfaces as Err |
20_payments_api.lex |
Combined tour: format validators (IPv4, Luhn, phone E.164) + RFC 7807 + 4-target codegen + v1→v2 migration |
21_form_and_diagram.lex |
URL-encoded login → cm.combine3 → typed LoginFields; same schema → Mermaid ER + Go struct |
22_multipart_upload.lex |
Avatar upload: multipart/form-data → decode_multipart → text fields validated + file metadata extracted |
23_sql_ddl.lex |
Same ModelSchema → CREATE TABLE for Postgres and SQLite; nested object → child table + FK |
24_response_model.lex |
FastAPI response_model parity — serialize_lossy drops internal fields, serialize_strict surfaces them as unexpected errors, openapi_response emits the OpenAPI 3.1 responses[200] envelope |
Run the bad-input demos to see the full error trail:
$ lex run examples/01_user_signup.lex format_demo
$ lex run examples/02_nested.lex format_demo
$ lex run examples/03_list_of_items.lex format_demo
$ lex run examples/08_discriminated_union.lex format_bad_field
$ lex run examples/19_fuzz_driver.lex demo_tallies # "parse_failures: 12/12 ..."Generate an SDK off one schema:
$ lex run examples/15_sdk_export.lex demo_typescript
$ lex run examples/15_sdk_export.lex demo_python
$ lex run examples/15_sdk_export.lex demo_json_schema17 suites, ~185 cases. The full sweep:
for f in tests/test_*.lex; do
echo -n "$(basename $f): "
lex run --allow-effects time "$f" run_all
doneReference output: every line ends in 0 (zero failures).
Each module has its own suite:
lex run tests/test_error.lex run_all # ~11 cases
lex run tests/test_constraints.lex run_all # ~21 cases
lex run tests/test_field.lex run_all # ~8 cases
lex run tests/test_combine.lex run_all # ~11 cases
lex run tests/test_coerce.lex run_all # ~12 cases
lex run tests/test_json_value.lex run_all # ~23 cases
lex run tests/test_json_extra.lex run_all # ~12 cases
lex run tests/test_union.lex run_all # ~6 cases
lex run tests/test_datetime.lex run_all # ~13 cases
lex run tests/test_schema.lex run_all # ~13 cases
lex run tests/test_cli.lex run_all # ~6 cases
lex run tests/test_cross_field.lex run_all # ~7 cases
lex run tests/test_property.lex run_all # ~8 cases
lex run tests/test_schema_import.lex run_all # ~7 cases
lex run tests/test_sdk.lex run_all # ~12 cases
lex run tests/test_validator.lex run_all # ~9 cases
lex run tests/test_fuzz.lex run_all # ~4 casesCoverage spans every constraint's pass/fail branches, error
accumulation in combineN, path manipulation, coercion,
the Json ADT parser + extractors + round-trip, discriminated-union
dispatch, ISO 8601 bounds, schema-driven validation + JSON Schema /
OpenAPI emission, SDK codegen for TS + Python, the validator
bundle, property-based round-trip on randomly-generated samples,
and the fuzz driver's malformed-input catalogues.
In a closure-based design (pydantic-py: Field(min_length=1)),
each constraint is an opaque callable. We instead model them as
ADT variants:
type StrCheck = StrMinLen(Int) | StrMaxLen(Int) | ...Three concrete payoffs:
- Inspectable by
lex audit.lex audit --calls StrPatternlists every regex constraint in a tree. Closures vanish into call-site bodies. - Codegen-friendly.
sdk.to_typescript,to_python,to_zod,to_rust_struct,to_go_struct,to_sql_ddl, ands.to_json_schemaall walk the constraint list and emit appropriate annotations / Field-args / Zod chains /CHECKclauses / JSON Schema keywords. A closure-based design would lose this for free. - Cheaper. A variant is a tagged record; a closure carries captures + an indirect call.
The trade-off is the fixed catalog. To add a new constraint kind,
add a variant + a branch in eval_xxx + a branch in each codegen
emitter. The library leans into "the small total surface" rule
from the Lex design rules — better four well-named checks than
a thousand one-off lambdas.
For genuinely custom predicates the f.validate(path, value, code, predicate) escape hatch takes a closure directly, and
cm.cross_check takes a list of closures for model-level rules.
Sibling fields should report failures all at once. Monadic
short-circuit (Result.and_then) hides the second field's errors
behind the first's. We use applicative combine2..combine6 so a
form with three bad fields surfaces three errors, not
one-then-fix-then-rerun-three-times.
When one field's type depends on another's value (i.e., a real
dependency), use cm.and_then to chain. That's the rare case;
sibling validation is the common one.
p.from_json wraps std.json.parse_strict. The standard library
parser does field-presence checks (#168), but it doesn't yet do
deep type validation: a JSON {"age": "thirty"} parsed against a
Lex record {age :: Int} returns a record with a Value::Str in
the age slot, which crashes the first time downstream code does
arithmetic on it. The acceptance test for the full fix is tracked
at lex-lang#322;
when it lands, p.from_json becomes fully runtime-safe end-to-end
with no surface change.
For now, two paths cover both threat models:
- Trusted-producer mode —
p.from_json+ field validators. Lightweight; relies on the JSON producer (another Lex service, a typed client, your own code) emitting types that match the declared Lex record. - Safe mode —
jv.parse+jv.j_str/jv.j_intextractors. Fully total over input. The parser is O(n) for escape-free strings and O(n²) worst-case for nested arrays / objects (the latter blocked on lex-lang#334).
Both produce the same Errors shape; callers swap freely as the
trust model changes.
Every src/*.lex module type-checks under lex check with no
effects required. datetime.check_iso_in_past / _in_future
carry [time] because they read the wall clock; everything else
is pure. Property-based test generation is deterministic via
std.random's SplitMix64 — the same seed produces the same
sample on every platform.
Seventeen small ergonomic or correctness gaps came up while building the library. All seventeen landed in lex 0.8.0-0.8.2. The cleanup commits restored the library to a no-workaround state — every spelling in source is the direct, idiomatic Lex.
| Issue | Status | Topic |
|---|---|---|
| #319 | ✅ 0.8.0 | Inline type ascription (expr :: Type) |
| #320 | ✅ 0.8.0 | option.unwrap_or_else |
| #321 | ✅ 0.8.0 | list.enumerate |
| #322 | ✅ 0.8.0 | Deep JSON type validation in json.parse_strict |
| #323 | ✅ 0.8.0 (simple positions) → fully closed by #345 in 0.8.2 | Type-alias asymmetry |
| #324 | ✅ 0.8.0 | _ as a lambda parameter name |
| #325 | ✅ 0.8.0 | Scientific-notation float literals |
| #326 | ✅ 0.8.0 | regex.is_match_str to skip the compile round-trip |
| #328 | ✅ 0.8.0 | Record-alias coercion under nested constructors |
| #329 | ✅ 0.8.0 | Negative integer literals in match patterns |
| #331 | ✅ 0.8.2 | datetime.before / after / compare + std.duration |
| #332 | ✅ 0.8.0 | Str < Str works at runtime |
| #334 | ✅ 0.8.0 (list.reverse) + 0.8.2 (list.cons) |
O(n) list builders |
| #337 | ✅ 0.8.0 | Constructor-pattern fail path leaks scrutinee |
| #338 | ✅ 0.8.0 | list.sort_by[T, K] for canonicalization |
| #339 | ✅ 0.8.0 | Top-level fn shadowing a cross-module param |
| #345 | ✅ 0.8.2 | Type-alias unfold reaches polymorphic-stdlib closure params |
No workarounds remain in source. The library compiles and
runs against vanilla lex 0.8.2 with the direct, idiomatic
spellings:
type Errors = List[Error]is the canonical alias throughout.src/datetime.lexcallsdatetime.before/datetime.after/datetime.comparedirectly on parsedInstants.src/json_value.lexparser is O(n) vialist.cons+list.reversefor array / object builders.
EUPL-1.2 — to match the parent lex-lang project.
Built under the principles of Trust Without Comprehension.