The difference between validation you can configure and validation you can only read.
# Hard-wired: not reusable, not reorderable, stops at the first problem
def validate(record):
if not record.get('name'):
return 'name is required'
if not isinstance(record.get('age'), int):
return 'age must be a number'
# Rules as data: the same checks, composed by the caller
rules = {
'name': [required],
'age': [required, is_number, positive],
}
# Every rule has the same shape: value in, message or None out
def required(value):
return 'is required' if value in (None, '') else None
- One signature is what makes rules interchangeable
Every check takes a value and returns a message or None — so the validator never needs to know what any particular rule does, and a rule written today works in a validator written last year. The uniformity is the feature; a rule that took two arguments, or returned a bool, would need its own handling and the whole idea would unravel.
- None means pass, because a message means fail
Returning the message itself rather than a bool means the rule carries its own explanation, so the validator has nothing to say about checks it has never seen. The alternative — return False and look the message up somewhere — splits each rule across two places that can disagree.
- Run every rule, then report
Stopping at the first failure per field would hide 'must be a number' behind 'is required', so the user fixes one thing and is told about the next. Since each rule is independent and cheap, running all of them and collecting the messages costs nothing and turns four round trips into one.
Walk the rules, not the record — the rules decide what gets checked. For each field, run all of its checks and keep the messages that come back.
record.get(field) gives None for a field that is not there, which is exactly what the contract wants passed to the checks. Collect the non-None results into a list and only add the field to the result when that list has something in it.
A comprehension over the checks does the running and the filtering in one line: [message for check in checks if (message := check(value)) is not None] — or the plain loop, which is two lines longer and needs no walrus. Either way, no break and no early return.
def validate(record, rules): problems = {} for field, checks in ____.items(): value = record.____(field) messages = [] for check in checks: message = check(____) if message is not ____: messages.append(message) if messages: problems[field] = messages return problems
def validate(record, rules):
problems = {}
# The rules decide what is checked, so they are what we walk.
for field, checks in rules.items():
value = record.get(field)
# Every check runs: no break, no early return.
messages = [
message
for message in (check(value) for check in checks)
if message is not None
]
if messages:
problems[field] = messages
return problems
It hands None to the checks for a field that is not there, which means `required` handles absence and blankness with one condition and the validator needs no special case. That works because the contract chose None as the representation of 'nothing here' — the same decision that makes the rules composable is the one that keeps this function short.
A rule could legitimately return an empty string, and a truthiness test would silently drop it as a pass. More importantly, `is not None` says what the protocol is — None means satisfied — rather than relying on messages happening to be non-empty. Protocols should be checked literally.
`if problems:` becomes a valid-or-not test, `problems.items()` iterates only what went wrong, and the JSON sent to a form has exactly the fields to highlight. A result carrying every field with an empty list would make all three of those need filtering first.
Once the rules need arguments — a maximum length, a regular expression, a comparison against another field — the plain one-argument signature stops fitting, and the answer is either functools.partial or a real schema library. Pydantic, marshmallow and Cerberus do all of this, plus type coercion, nested structures, and error paths into arrays. Write this when the validation is small, the rules are yours, and you want them to stay ordinary functions you can unit-test without a framework. And note it checks fields independently: 'end date must be after start date' has no field to belong to, and needs a record-level rule this design does not have.
- Add record-level rules that see the whole record, and decide what key their messages should be filed under.
- Build rules with arguments using functools.partial — max_length(10) — and check that nothing in validate() has to change.
- Stop after the first failure per field behind a flag, and work out from the cases here which behaviour you would actually ship.