Problem/Motivation
A required value answers three separate questions: is it present, may it be null, and may it be empty. JSON Schema keeps all three apart (required for presence, "null" in type for nullability, minItems/minProperties for emptiness). Drupal's required flag currently answers all three at once, and this issue is about the third: an empty collection is treated as if it were null. The first two (presence versus nullability, see #5) are a separate conversation - data definitions have no nullability concept at all.
Two pieces of the typed data system combine so that "required" means "non-empty" for every list and complex data value, with no way for a consumer to say otherwise:
TypedDataManager::getDefaultConstraints()addsNotNullwhenever$definition->isRequired()- a presence property becomes a value constraint.NotNullConstraintValidator::validate()coerces an emptyListInterfaceorComplexDataInterfacevalue toNULLbefore delegating to Symfony, so Drupal'sNotNullbehaves as "not empty" for collections. Symfony itself keeps these apart (NotNullis null-only;NotBlankcatches empty), as does JSON Schema.
The coercion dates to #2343035: Upgrade validator integration for Symfony versions 2.5+, and it is the semantic Field API wants: a required field means "at least one item". But it leaks onto every other typed data consumer, and core has already acknowledged that once: #3364109: Configuration schema & required values: add test coverage for `nullable: true` validation support added a hardcoded exemption for config schema's ArrayElement, with the comment "TRICKY: ... configuration schema sees a substantial difference between an empty sequence/mapping and NULL." That fixed one subsystem by class check and left the conflation in place for everything else.
Where I'm hitting this issue. The Tool API contrib module validates tool inputs and outputs against a published JSON Schema, where a required map or list output legitimately holds an empty value (an empty result set). With output enforcement on, core's NotNull failed real tools with "This value should not be null." for values their schema allows (tool#3583040). Because the exemption is a hardcoded instanceof ArrayElement, a module using plain Map/ItemList cannot opt out; Tool API had to stop marking rebuilt list definitions required and re-implement the null-only half of the check itself - which in turn means its definitions under-report isRequired() to anything that introspects them.
Related: #3347033: NotNullConstraintValidator doesn't catch empty strings, only list/complex data types documents the mirror-image inconsistency (empty strings are NOT coerced). One interaction to account for: on maps, the coercion currently doubles as a stopgap for #3256536: Map data definition properties are not validated if root value is empty - an empty map's required properties are never visited by RecursiveContextualValidator, so the NotNull rejection of [] is the only thing catching a required property missing from an empty required map. Any consumer switching the option off for maps needs #3256536: Map data definition properties are not validated if root value is empty fixed or its own property walk; the Tool API workaround was scoped to lists for exactly this reason.
Steps to reproduce
$definition = \Drupal::typedDataManager()->createDataDefinition('map')->setRequired(TRUE); $violations = \Drupal::typedDataManager()->create($definition, [])->validate(); // One violation: "This value should not be null." - for a present, empty map.
Proposed resolution
Make the empty-to-null coercion an option of Drupal's NotNull constraint rather than a hardcoded behavior plus a hardcoded exemption:
- Add an option (for example
treatEmptyAsNull, defaultTRUE) to theNotNullconstraint plugin. The validator coerces only when the option is on. getDefaultConstraints()keeps emitting plainNotNull(default on), so Field API behavior is unchanged.- The
ArrayElementcarve-out becomes config schema passingtreatEmptyAsNull: FALSEwhere it builds constraints, replacing the instanceof check. - Consumers that mean JSON-Schema-style "required but may be empty" declare
NotNullwith the option off, and express minimums withCount- which is already how the schema side of such systems works.
Long-term direction. The option is step one of a correction, not a permanent flag. Once consumers (config schema, contrib) declare treatEmptyAsNull: FALSE where they mean it, a future major can flip the default and deprecate the coercion entirely, with Field API expressing its actual requirement explicitly (a dedicated constraint that ignores empty field items, since Count would count them; plain typed data can use Symfony's NotBlank or Count), at which point NotNull means exactly what it says and the option itself can be removed.
Alternatives considered: removing the coercion outright breaks required-field semantics everywhere; teaching getDefaultConstraints() about collections changes behavior for fields too. The constraint option is BC-clean by default.
Remaining tasks
- Agree on the option name and default.
- Patch validator and constraint plugin, replace the
ArrayElementinstanceof with the option, add kernel coverage for all four combinations (empty/null x option on/off).
User interface changes
None.
API changes
A new, optional constraint option; no behavior change without it.
Data model changes
None.
AI was used to assist in drafting this issue.
Comments
Comment #2
michaellander commentedComment #3
michaellander commentedComment #4
michaellander commentedComment #5
michaellander commentedA note on the part this issue deliberately leaves out. The summary scopes presence versus nullability out as a separate conversation, since data definitions have no nullability concept. If the option here is agreed, that becomes the natural next step, and it would need its own issue:
DataDefinition::isNullable()/setNullable(), mirroring config schema'snullable, which config already keeps apart from presence (requiredKey).TypedDataManager::getDefaultConstraints()addsNotNullonly when a definition is required and not nullable."null"intypefrom nullability instead of fromrequired, so presence (the parent'srequiredlist), nullability ("null"intype) and emptiness (minItems/minProperties) each map to their own keyword.With both in place,
requiredmeans present,nullablesays whether null is allowed, and "not empty" is an explicit constraint: the three questions from the summary, each answered separately.One refinement to the long-term direction: for Field API the explicit requirement would need to be a dedicated constraint rather than
Count, since a field item list can hold empty items thatCountwould still count. Plain typed data can use Symfony'sNotBlankorCount.Tool API is working through the same split on its side: tool#3583069 (a required map may be empty; Tool API walks declared map properties itself, which covers #3256536 for its own validation) and tool#3583043 (a
nullableflag). A second real case since this was filed: canvas_tools'canvas_get_component_schemafailed for every component without slots, because its required, emptyslotsmap hit this coercion (canvas_tools#26).AI-assisted comment.
Comment #6
michaellander commented