Repository navigation
Description
Each draft from Draft-06 on has its own copy of the constraint set in src/JsonSchema/Constraints/Drafts/<Draft>/. Most of these files differ only in their namespace:
| Compared sets | Identical except namespace | Different |
|---|---|---|
| Draft06 vs Draft07 | 30 | 2 (Factory, FormatConstraint) |
| Draft07 vs Draft2019 | 27 | 6 (AdditionalItems, Contains, Content, Factory, Format, MultipleOf) |
| Draft2019 vs Draft2020 (#971) | 30 | 5 (Content, Factory, Format, Items, UnevaluatedProperties) |
Together the four sets have about 8,700 lines in 142 files. Only a handful of files actually differ.
A fix to a shared keyword therefore has to be applied to every copy by hand, and copies are easy to miss:
- fix: Reject a trailing newline in idn-hostname #990 (trailing newline in
idn-hostname) fixed Draft-07 and Draft 2019-09. Draft 2020-12 in feat: Add Draft 2020-12 #971 still had the old regex, which surfaced only when the JSON Schema Test Suite added the case. - Throw on an unresolvable $ref instead of passing silently #944 (throw on an unresolvable
$ref) had to be ported to Draft 2020-12 separately. - Avoid unconditional constraint instantiation in Draft06/07/2019 dispatch #926 touches the same dispatch logic in three
Draft*Constraintclasses.
Tests are duplicated the same way. For example, RefConstraintTest exists once per draft and differs only in the dialect URI.
Proposal
- Move the constraints that are identical across drafts into a shared namespace, e.g.
JsonSchema\Constraints\Drafts\Common. Their constructors take the generic\JsonSchema\Constraints\Factory, so they work with each draft's factory. - Keep the per-draft classes as thin subclasses, e.g.
class MinLengthConstraint extends Common\MinLengthConstraint {}. They are public and can be referenced or replaced viaFactory::setConstraintClass(), so removing them would be a breaking change. They could be deprecated later. - Keep classes with real differences per draft (
FormatConstraint,ItemsConstraint/PrefixItemsConstraint,ContentConstraint, …). Where the difference is small, share a base class and override only what differs. - Merge per-draft tests that differ only in the dialect into one test with a data provider over the dialects.
The Draft*Constraint dispatchers could follow in a separate step, together with or on top of #926.
Draft-04 in strict mode
Once the shared classes exist, a Draft-04 constraint set becomes cheap to add, and strict mode could support Draft-04 as well. Today a Draft-04 schema in strict mode throws InvalidArgumentException: Unknown constraint draft04, so strict mode cannot be enabled globally when validating schemas of mixed drafts. Draft-04 is still widely used.
The legacy constraints used without strict mode are not a pure Draft-04 implementation either. For a schema with "$schema": "http://json-schema.org/draft-04/schema#":
| Schema | Instance | Result today | Draft-04 |
|---|---|---|---|
{"const": 1} |
2 |
invalid | valid (const does not exist in Draft-04) |
{"exclusiveMinimum": 5} |
5 |
invalid | valid (exclusiveMinimum is a boolean modifier of minimum in Draft-04) |
{"items": false} |
[1] |
TypeError |
not a valid Draft-04 schema |
A Draft-04 set would differ from Draft-06 only in a few keywords: boolean exclusiveMinimum/exclusiveMaximum, no const, contains, propertyNames or boolean schemas, and id instead of $id. Draft-03 is not worth the effort.
Suggested split
To keep the reviews manageable and the conflicts small:
- Constraints that are identical in all drafts.
- Constraints that are identical in a subset of drafts, or differ only slightly.
- The
Draft*Constraintdispatchers (coordinated with Avoid unconditional constraint instantiation in Draft06/07/2019 dispatch #926). - The deduplicated tests.
- A Draft-04 constraint set for strict mode, built on the shared classes.
Timing
Several open PRs change the files this would move (#971, #983, #984, #987, #973). I'd suggest starting after #971 is merged, so Draft 2020-12 is covered from the start, and preferably once the other open PRs are in.
Questions
- Is this direction acceptable, or would you prefer the drafts to extend each other (e.g. Draft2020 extends Draft2019) instead of a shared namespace?
- Should the per-draft subclasses stay permanently, or be deprecated for removal in the next major version?
- Would strict mode support for Draft-04 be welcome?
I'm happy to work on this.
Activity
DannyvdSluijs commentedon Oct 26, 2026
Hi @Roman3349 thanks for the extensive report. I'm aware there is duplication between the drafts, which is intentional or at least the result of the Draft 3 and Draft 4 being intertwined. I've chosen explicitly not sharing code between draft elements unless the draft refers to a complex format which was captured in an RFC. You're absolutely right on the downside that during fixes or improvements we need to consider multiple classes/paths.
For now I would still think keeping the duplication and not having to do a mayor version bump is the way to go. As I would rather give priority to getting the drafts complete (you can check the badges in the readme on the progress).
Beside the organisational issues there is no impact on timings/performance right?
Roman3349 commentedon Oct 28, 2026
Hi @DannyvdSluijs,
that makes sense, and I agree that completing the drafts comes first.
No, there's no runtime impact. Validation runs the same code either way, and the copies only cost a bit of extra opcache memory and autoloading when one process validates schemas of several drafts. It's purely a maintenance issue. For the record, the proposal wouldn't need a major version bump either, because the per-draft classes would stay as thin subclasses.
For illustration, the thin subclasses from the proposal would look like this:
The per-draft class names, the factories'
constraintMapandFactory::setConstraintClass()keep working as before, which is why no major version bump is needed. A fix tominLengththen goes intoCommononce.Strict mode support for Draft-04 doesn't depend on the deduplication either. It could be done as one more copied set later, if it's welcome. I'm fine with closing this issue or leaving it open for later, whichever you prefer.