tur/schemaStatus: SC0--SC4 shipped (scalar/object/array/optional/union/literal/ transform/recursive schemas, accumulating path-tagged errors). SC5 shipped: the
HasSchematypeclass with return-type-directeddecode!and the#json-str<T>(...)reader macro. SC7 is complete: the combinator layer (always/never/ap/field-of/fmap/alt, Validation semantics) and theFunctor/Applicative/Alternativetypeclass instances over the phantom(Schema a)wrapper -- so object decoders can be assembled applicatively withfmap/ap/alt-or, including building a struct field-by-field. See "Applicative combinators" and "HKT instances" below.
Turmeric's type system enforces invariants within a program. The gap is at
dynamic boundaries -- an HTTP response body, a config file, a channel
message -- where data arrives untyped at runtime. tur/contract panics on a
violation; manual field extraction gives no error propagation. tur/schema
fills the gap with schemas as first-class values that decode data into
a Result, accumulating every violation with a dot-separated field path.
tur/contract tur/schema
When compile + runtime runtime only
Data source internal (known types) external (untyped)
On failure panic Result (error value)
Composable no (macros) yes (schemas are values)
schema.tur is not auto-loaded (to keep it out of every program's codegen).
Load it explicitly, alongside json.tur -- schema decoding is anchored on the
tagged JSON node representation, because those nodes carry the runtime type tag
(0=null 1=bool 2=int 3=float 4=string 5=array 6=object) that makes genuine
"expected :cstr, got :int" validation possible.
(load "stdlib/json.tur")
(load "stdlib/schema.tur")
The input to schema-decode is a JSON node, from either json/decode (runtime
text) or the #json(...) reader macro (compile-time literal).
(schema/str) ; matches JSON strings, decodes to the :cstr
(schema/int) ; matches JSON integers
(schema/float) ; matches JSON floats
(schema/bool) ; matches JSON booleans
(schema/nil) ; matches JSON null
(schema/literal-str "active") ; matches only the exact string "active"
(schema/literal-int 42) ; matches only the exact integer 42
schema-decode returns a Result<ptr<void>, Vec<SchemaError>>. Inspect it
with the helper accessors:
(let [r (schema-decode (schema/int) (json/decode "42"))]
(if (schema-decode-ok? r)
(schema-decode-value r) ; => 42
(schema-error-message (schema-decode-errors r))))
| Helper | Purpose |
|---|---|
schema-decode-ok? |
did decoding succeed? |
schema-decode-value |
the decoded value (on success) |
schema-decode-errors |
the Vec of SchemaError (on failure) |
schema-decode! |
decode or abort with a formatted message (trusted data / tests) |
The decoded value mirrors the schema shape, so you keep working with ordinary Turmeric values:
| Schema | Decoded value |
|---|---|
| scalar | the underlying value (:cstr ptr / :int / float bits / bool) |
| literal | the matched value |
| object | the validated JSON object node (read fields with json/get) |
| array | a freshly allocated Vec of decoded element values |
| optional / union / transform / rec | the inner decoded value |
A field list mixes :cstr keys with schema pointers, which a homogeneous
Vec cannot hold, so object schemas are built incrementally:
(defn user-schema [] : int
(schema/field
(schema/field (schema/object-new) "name" (schema/str))
"age" (schema/int)))
(schema-decode (user-schema) (json/decode "{\"name\": \"alice\", \"age\": 30}"))
;; => ok
A field whose schema is (schema/optional ...) may be absent entirely or
present-but-null; any other missing field is a "missing required field"
error.
(schema/array (schema/int)) ; [1, 2, 3] -> Vec of ints
(schema/optional (schema/str)) ; null / absent ok
(schema/union (vec-of (schema/int) (schema/str))) ; first matching arm wins
(schema/transform (schema/int) (fn [x] (* x 2))) ; decode, then map
Union arms are all schema pointers (:int), so a plain (vec-of ...) works.
schema/transform applies its function only when the inner schema succeeds, so
the function never sees invalid data.
Decoding does not fail fast. Every violation is collected, each tagged with
a dot-separated path (address.zip) or an array index (children[0].value):
(defn address-schema [] : int
(schema/field
(schema/field (schema/object-new) "city" (schema/str))
"zip" (schema/str)))
(defn user-schema [] : int
(schema/field
(schema/field (schema/object-new) "name" (schema/str))
"address" (address-schema)))
(let [r (schema-decode (user-schema)
(json/decode
"{\"name\": 42, \"address\": {\"city\": \"NYC\", \"zip\": 10001}}"))]
(println (schema-error-message (schema-decode-errors r))))
;; name: expected :cstr, got :int
;; address.zip: expected :cstr, got :int
Walk the errors programmatically with schema-error-count, schema-error-at,
schema-error-path, and schema-error-text. This is the same accumulation
policy as Zod's .safeParse -- a complete picture of what is wrong, not just
the first failure.
Self-referential data (trees, nested comments) uses schema/rec, which passes
the schema itself in as self. The body is forced lazily on first decode and
memoized:
(defn tree-schema [] : int
(schema/rec
(fn [self]
(schema/field
(schema/field (schema/object-new) "value" (schema/int))
"children" (schema/array self)))))
(schema-decode (tree-schema)
(json/decode
"{\"value\": 1, \"children\": [{\"value\": 2, \"children\": []}]}"))
;; => ok, validated to arbitrary depth
A violation deep in the tree still reports a full path, e.g.
children[0].value: expected :int, got :cstr.
tur/contractThe two are complementary: use tur/schema to validate data at the
boundary, turning untyped JSON into values you trust, then use tur/contract
to enforce invariants on those values inside the program.
HasSchema (SC5)schema-decode hands back the validated node (read fields with json/get!);
to land directly in a typed value, implement the HasSchema typeclass. Its one
method, decode!, is return-type-directed: the concrete instance is chosen
from the expected type at the use site (an ascription (:: e T) or a typed
binding), not from any argument.
(load "stdlib/json.tur")
(load "stdlib/schema.tur")
(defstruct User :copy [name : cstr age : int])
(defn user-schema [] : int
(schema/field
(schema/field (schema/object-new) "name" (schema/str))
"age" (schema/int)))
(definstance HasSchema [User]
(decode! [node]
(let [v (schema-decode! (user-schema) node)]
(make-struct User
(json/get-string (json/get! v "name"))
(json/get-int (json/get! v "age"))))))
;; The ascription drives instance selection; decode! returns a real User.
(let [u (:: (decode! (json/decode body)) User)]
(.name u))
decode! returns the typed value directly (a genuine by-value struct -- the
compiler bridges the typeclass dictionary's int64 carrier ABI back to the
concrete struct at the resolved call site). It panics on any schema
violation, like schema-decode!; for graceful handling, validate with
schema-decode first and branch on schema-decode-ok?.
Ascribing decode! to a type with no HasSchema instance is a compile-time
error (no instance 'HasSchema T').
#json-str<T>(...) reader macro#json-str<T>(expr) desugars to
(:: (decode! (json/decode expr)) T) -- a one-liner from a runtime JSON
:cstr to a typed value:
(defn parse-user [body :cstr] :User
#json-str<User>(body))
Unlike #json(...), the inner is an ordinary Turmeric expression (read with the
normal reader), not a verbatim JSON blob. The bare #json<T>(...) literal form
now also wraps its node tree in (:: node T).
The panic-on-violation #json-str<T> is implemented; the Result-returning
#json-str?<T> and file-reading #json-file<T> remain future work
(#json-str? emits a "not yet implemented" diagnostic).
Object decoders compose from smaller pieces with the Validation applicative:
schema/ap decodes both arms against the same input and accumulates their
errors (it does not fail fast), so a malformed object reports every bad field at
once.
| Combinator | Role | Notes |
|---|---|---|
schema/always v |
pure |
always succeeds with v (ignores the node) |
schema/never msg |
empty |
always fails with msg; identity of schema/union |
schema/field-of k s |
extract | decode an object, pull key k via schema s |
schema/ap sf sa |
<*> |
apply the function decoded by sf to the value decoded by sa, accumulating errors (function arm is a top-level fn) |
schema/ap-fat sf sa |
<*> |
like schema/ap, but the function arm may be a capturing closure (fat-closure apply); backs the Applicative [Schema] ap method |
schema/fmap s f |
<$> |
map f over the decoded value (alias for schema/transform) |
schema/alt a b |
<\|> |
two-arm first-match union |
(defn double-it [x : int] : int (* x 2))
;; pure(double-it) <*> field-of("n", int) -- on {"n":21} => 42
(schema-decode! (schema/ap (schema/always double-it)
(schema/field-of "n" (schema/int)))
(json/decode "{\"n\": 21}"))
Decoding {"a": "x", "b": "y"} against an ap of two int fields yields two
path-tagged errors (a: expected :int, got :cstr and b: ...), one per arm.
MonadThe lawful >>= for Validation is fail-fast, which contradicts the accumulating
<*>. Per the design, Monad Schema is deliberately omitted; if you genuinely
need to choose a later schema based on a decoded value, decode in two explicit
steps (decode the discriminant, then dispatch on it) rather than reaching for a
monad. Note also the O(arms) cost: ap/union decode every arm.
The combinators above are also surfaced as Functor/Applicative/Alternative
typeclass instances over a phantom wrapper, so object decoders can be assembled
with the standard HKT method names and chain across operators:
(defstruct Schema [A] (raw :int)) ; provided by stdlib/schema.tur
(definstance Functor [Schema] (fmap [c f] ...))
(definstance Applicative [Schema] (pure [x] ...) (ap [ff fa] ...))
(definstance Alternative [Schema] (empty [a] ...) (alt-or [a b] ...))
(Schema a) is a transparent int newtype: a parametric struct whose single
field is a concrete :int, so the type parameter is phantom and the value has
one C representation (int64) at every site. That single representation is what
lets an HKT result flow with its (Schema b) type, so the next operator can
dispatch on it -- (ap (fmap c ctor) g) type-checks because the fmap result is
still a (Schema _).
Wrap with (make-struct Schema raw) and unwrap with (.raw s); both compile to
the identity (the value is the schema pointer). Build a struct field by field:
;; ->User is a curried constructor: name -> (age -> boxed User)
(defn user-schema [] : int
(let [name-s (:: (make-struct Schema (schema/field-of "name" (schema/str))) (Schema int))
age-s (:: (make-struct Schema (schema/field-of "age" (schema/int))) (Schema int))]
(.raw (ap (fmap name-s ->User) age-s)))) ; Validation: both fields decoded, errors accumulated
ap is built on schema/ap-fat, which applies the (possibly capturing) function
arm through the fat-closure protocol, so a multi-argument curried constructor
composes. Decoding {"name": 42, "age": "x"} still yields two path-tagged
errors -- the applicative is Validation, not fail-fast, even when assembling a
struct. See the schema-hkt-functor, schema-hkt-alternative,
schema-applicative-user, and schema-applicative-user-errors fixtures.
stdlib/schema.turpulls in theApplicative/Alternativeclass stubs for you (andFunctoris loaded globally), so(load "stdlib/schema.tur")is enough -- no(load "stdlib/typeclass.tur")is required to use the instances.
See docs/archive/history/schema-plan.md for the full design and the rationale behind the Validation (accumulating) semantics.