M3
April 29, 2026 · View on GitHub
Every draft. Every keyword. Same code, same answers — backend and frontend.
M3 passes every test1 in the official JSON Schema Test Suite across every draft from draft-03 through draft-v1 (formerly draft-next) — 10,780 assertions with zero failures.
Written in Clojure/ClojureScript, M3 compiles to both JVM bytecode and JavaScript from a single codebase — the only JSON Schema validator that delivers identical results on the server and in the browser across all drafts. Use it from Clojure, Java, Kotlin, Scala, JavaScript, or Node.js.
Full support for every keyword: $ref, $dynamicRef, $recursiveRef, unevaluatedProperties, unevaluatedItems, $vocabulary, $anchor, $dynamicAnchor, if/then/else, dependentSchemas, prefixItems, contentMediaType, contentEncoding, and all format validators.
Requires: Java 21+ (JVM) | Node.js 18+ (JavaScript)
Installation
| Leiningen | deps.edn | Maven | Gradle | npm |
|
|
|
|
|
Note for Maven/Gradle users: M3 is hosted on Clojars. Add the Clojars repository to your build configuration:
Maven — add to
<repositories>inpom.xml:<repository> <id>clojars</id> <url>https://repo.clojars.org</url> </repository>Gradle — add to
repositoriesblock:maven { url 'https://repo.clojars.org' }
Test Suite Compliance
| Draft | JVM | JavaScript |
|---|---|---|
| draft-03 | All tests passing | All tests passing |
| draft-04 | All tests passing | All tests passing |
| draft-06 | All tests passing | All tests passing |
| draft-07 | All tests passing | All tests passing |
| draft 2019-09 | All tests passing | All tests passing |
| draft 2020-12 | All tests passing | All tests passing |
| draft-v1 (formerly draft-next) | All tests passing | All tests passing1 |
A handful of validators cover drafts 3 through 2020-12 (notably Python's jsonschema and .NET's Newtonsoft.Json.Schema), and several JavaScript validators (Ajv, Hyperjump) run in both Node.js and the browser — but none of them do both. M3 is the only validator that covers all drafts and runs portably on backend and frontend from a single codebase.
Default draft: latest (currently draft2020-12)
Language Examples
Clojure
(require '[m3.json-schema :as m3])
(m3/validate {"type" "string"} "hello")
;; => {:valid? true, :errors nil}
(m3/validate {"type" "number"} "oops")
;; => {:valid? false, :errors [{:schema-path ["type"], :message "type: not a[n] number - \"oops\"", ...}]}
;; Compile once, validate many
(let [v (m3/validator {"type" "object" "required" ["id"]})]
(v {"id" 1}) ;; => {:valid? true, :errors nil}
(v {})) ;; => {:valid? false, :errors [...]}
;; Choose a draft
(m3/validate {"type" "string"} "hello" {:draft :draft7})
Java
import m3.JsonSchema;
import java.util.Map;
import java.util.List;
// From JSON strings
Map result = JsonSchema.validate("{\"type\":\"string\"}", "\"hello\"");
boolean valid = (boolean) result.get("valid"); // true
List errors = (List) result.get("errors"); // null
// Zero-copy from Jackson — no conversion needed
ObjectMapper mapper = new ObjectMapper();
Map schema = mapper.readValue(schemaJson, LinkedHashMap.class);
Object document = mapper.readValue(docJson, Object.class);
Map result = JsonSchema.validate(schema, document);
// With options
Map result = JsonSchema.validate(schema, document,
Map.of("draft", "draft2020-12"));
Kotlin
import m3.JsonSchema
val result = JsonSchema.validate("""{"type":"string"}""", "\"hello\"")
val valid = result["valid"] as Boolean // true
// From parsed maps
val schema = mapOf("type" to "object", "required" to listOf("name", "age"))
val doc = mapOf("name" to "Alice", "age" to 30)
val result = JsonSchema.validate(schema, doc)
// With options
val result = JsonSchema.validate(schema, doc,
mapOf("draft" to "draft2020-12"))
Scala
import m3.JsonSchema
import java.util.{Map => JMap, LinkedHashMap}
// From JSON strings
val result = JsonSchema.validate("""{"type":"string"}""", "\"hello\"")
val valid = result.get("valid").asInstanceOf[Boolean] // true
// From parsed maps (e.g. via Jackson or Gson)
val schema = new LinkedHashMap[String, Any]()
schema.put("type", "integer")
schema.put("minimum", 0)
val result = JsonSchema.validate(schema, 42)
JavaScript / Node.js
const { validate, validator } = require('m3-json-schema');
validate({ type: 'string' }, 'hello');
// { valid: true, errors: null }
validate({ type: 'number' }, 'not a number');
// { valid: false, errors: [{ schemaPath: ['type'], message: '...', ... }] }
// Compile once, validate many
const v = validator({ type: 'object', required: ['id'] });
v({ id: 1 }); // { valid: true, errors: null }
v({}); // { valid: false, errors: [...] }
// Choose a draft
validate({ type: 'string' }, 'hello', { draft: 'draft7' });
All JVM languages accept java.util.Map and java.util.List directly — documents from Jackson, Gson, or any JSON library work with zero conversion.
Options
| Option | Clojure | Java/JS | Description |
|---|---|---|---|
| Draft | :draft :draft7 | "draft": "draft7" | JSON Schema draft version |
| Registry | :registry {uri schema} | "registry": {uri: schema} | Map of URI to schema for $ref resolution |
Supported draft values: draft3, draft4, draft6, draft7, draft2019-09, draft2020-12, draft-v1, latest.
Use latest (:latest in Clojure) as an alias for the most recent stable draft (currently draft2020-12).
Validation Results
M3 returns three levels of feedback: errors, warnings, and infos.
(m3/validate {"type" "string"} 42)
;; => {:valid? false, :errors [{:schema-path ["type"], :message "...", ...}]}
(m3/validate {"type" "string" "format" "email"} "not-an-email")
;; => {:valid? true, :errors nil, :warnings [{:schema-path ["format"], :message "...", ...}]}
(m3/validate {"type" "string" "$comment" "a note"} "hello")
;; => {:valid? true, :errors nil, :infos [{:schema-path ["$comment"], :message "...", ...}]}
| Level | When | Effect on :valid? |
|---|---|---|
| Errors | Validation failures — type, required, pattern, etc. | false |
| Warnings | Schema is valid but something noteworthy — format annotation failures, contentEncoding decode failures, deprecated properties, unrecognised format values | true (does not affect validity) |
| Infos | Informational annotations — $comment values | true (does not affect validity) |
:warnings and :infos are only present in the result when non-empty. All three levels share the same shape:
{:schema-path ["properties" "age" "type"] ;; path into the schema
:document-path ["age"] ;; path into the document
:message "type: not a[n] integer - \"old\""
:document "old" ;; the failing value
:schema {"type" "integer"} ;; the relevant schema
Errors may additionally contain an :errors key with nested sub-errors for compound keywords like allOf, oneOf, etc.
Java/JS output uses camelCase string keys: schemaPath, documentPath, message, document, schema, errors, warnings, infos.
This makes M3 well-suited for building UIs that display not just pass/fail, but rich diagnostic feedback — warnings for deprecated fields, info annotations from $comment, and detailed error trees for complex schemas.
Architecture
M3 uses a two-level curried design:
- Level 2 (compile time): Each schema keyword compiles into a validation function
- Level 1 (runtime): The compiled function validates documents
This means schema compilation is done once and the compiled validator can be reused across many documents — use validator / JsonSchema.validate(Map, Object) for best performance.
Dialects are composable: each draft is defined as an ordered set of vocabularies, and each vocabulary maps keywords to checker functions. This makes M3 extensible — custom dialects can be assembled from existing or new vocabularies.
Internally, two context maps thread through validation:
- c2 (compile-time): draft, dialect, URI resolution, schema stash
- c1 (runtime): evaluation tracking, dynamic anchor scope, conditional state
Building from Source
git clone --recursive git@github.com:JulesGosnell/m3.git
cd m3
# Run Clojure tests (10,780 test-suite assertions)
lein test
# Run ClojureScript tests
npm install
lein test-cljs
# Build npm module
lein shadow compile npm
# Clean everything
lein clean-all
License
Copyright 2025 Julian Gosnell. Apache License, Version 2.0.
Footnotes
-
One test is excluded as a language-level limitation:
zeroTerminatedFloats.json— "a float is not an integer even without fractional part". Neither JavaScript (JSON.parse("1.0") === JSON.parse("1")) nor Clojure's reader distinguishes1.0from1at the value level, making this test impossible to pass without runtime parser-level numeric introspection. ↩ ↩2