Writing reusable field validators for agency schemas

The moment a codebase carries a separate schema for National Institutes of Health (NIH), National Science Foundation (NSF), and Department of Defense (DoD) submissions, the same low-level rules start getting copy-pasted three times: a dollar amount must be non-negative and under a ceiling, an eRA Commons or ORCID identifier must match its format, a deadline must fall inside the open funding cycle. Duplicated validators drift — a rounding fix lands in the NIH schema and never reaches NSF — and drift is how a proposal passes local checks and still fails at the portal. This page shows how to factor those shared constraints into one reusable validator library that composes into every agency model, as a focused technique inside schema validation with Pydantic. The tools are Pydantic v2 Annotated types, standalone field_validator functions, and model_validator for cross-field rules — written once, imported everywhere.

Phase 1 — Identify the constraints worth sharing

Not every rule belongs in a shared library. The ones that do are the format and range invariants that mean the same thing regardless of funder: a money value is a money value whether NIH or NSF is reading it. The ones that do not are genuinely agency-specific policy — a modular budget cap, an export-control flag — which stay on their own model. Drawing that line correctly is the whole design, and it complements the discriminated-model approach in validating parsed RFP JSON against agency schemas, which decides which schema to apply; this page decides what those schemas share.

Implementation steps:

  1. Catalogue the cross-agency invariants. Dollar amounts (non-negative, bounded), identifiers (ORCID, eRA Commons), dates within a submission cycle, page-range integers, and enum coercion recur on every agency model. These are library candidates.
  2. Separate policy from format. “A value is a valid dollar amount” is a format rule and is shared. “The direct-cost ceiling for this mechanism is $500,000” is policy and belongs to the agency schema registry, injected as a parameter — never hard-coded into the shared validator.
  3. Prefer Annotated types over repeated decorators. A reusable constraint expressed as an Annotated alias reads as a type and is impossible to forget, where a field_validator copied onto each field is easy to omit on the fourth model.
  4. Reserve model_validator for cross-field rules. Anything that needs two fields at once — a start date before an end date, a total equal to its line items — cannot be a field validator and belongs at the model level.
bash
python -m venv .venv
source .venv/bin/activate
pip install "pydantic>=2.6"

Phase 2 — Build the reusable validator library

The core technique is to express each shared constraint as a named validator function, then bind it to a type with Annotated[...] and AfterValidator. An AfterValidator runs after Pydantic’s own parsing, so the function receives an already-coerced value and only enforces the extra rule. The resulting alias — Money, OrcidId — is a type that any agency model uses like a built-in, and the rule lives in exactly one place. Some constraints need a parameter (a cycle window, a page ceiling); for those, a small factory returns a configured validator so policy stays injectable.

python
from __future__ import annotations
from datetime import date
from decimal import Decimal
from typing import Annotated, Callable
import re
from pydantic import AfterValidator, BaseModel, Field, field_validator, model_validator

# --- Reusable field validators -------------------------------------------------

_ORCID_RE = re.compile(r"^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$")   # 16 digits, dash-grouped
_ERA_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{5,19}$")     # eRA Commons username

def non_negative_money(v: Decimal) -> Decimal:
    """A dollar amount must be >= 0 and carry at most cents precision."""
    if v < 0:
        raise ValueError("dollar amount must not be negative")
    if v.as_tuple().exponent < -2:                 # more precise than cents
        raise ValueError("dollar amount limited to two decimal places")
    return v

def valid_orcid(v: str) -> str:
    if not _ORCID_RE.match(v):
        raise ValueError("ORCID must look like 0000-0002-1825-0097")
    return v

def valid_era_commons(v: str) -> str:
    if not _ERA_RE.match(v):
        raise ValueError("eRA Commons ID has an invalid format")
    return v

def within_cycle(opens: date, closes: date) -> Callable[[date], date]:
    """Factory: build a validator bound to one funding cycle's window."""
    def _check(v: date) -> date:
        if not (opens <= v <= closes):
            raise ValueError(f"date {v} falls outside cycle {opens}..{closes}")
        return v
    return _check

# --- Annotated type aliases: the shared vocabulary -----------------------------

Money = Annotated[Decimal, AfterValidator(non_negative_money)]
OrcidId = Annotated[str, AfterValidator(valid_orcid)]
EraCommonsId = Annotated[str, AfterValidator(valid_era_commons)]
PageCount = Annotated[int, Field(ge=1, le=99)]     # a page range is a bounded int

Enum coercion is the one constraint worth a reusable field validator rather than an Annotated alias, because agencies spell the same concept differently — "nih", "NIH", " NIH " all mean the same funder — and a shared field_validator with mode="before" normalizes the raw input before the enum is matched:

python
from enum import StrEnum

class Agency(StrEnum):
    NIH = "NIH"
    NSF = "NSF"
    DOD = "DoD"

class AgencyMixin(BaseModel):
    agency: Agency

    @field_validator("agency", mode="before")
    @classmethod
    def _coerce_agency(cls, v: object) -> object:
        """Normalize casing/whitespace so 'nih' and ' NIH ' both resolve."""
        if isinstance(v, str):
            cleaned = v.strip().upper()
            return {"NIH": "NIH", "NSF": "NSF", "DOD": "DoD"}.get(cleaned, v)
        return v

With the vocabulary in place, each agency schema is thin — it reuses the shared types and adds only its divergent rules. The diagram shows one validator library feeding three agency schemas, with the agency-specific rules the only thing that differs.

One reusable validator library composing into three agency schemas On the left, a single reusable validator library box lists the shared validators: money_amount, identifier for ORCID and eRA, date_in_cycle, page_range, coerce_enum, and a cross-field model_validator. Three arrows fan out from the library to three agency schema boxes on the right. The NIH schema adds a modular budget rule, the NSF schema adds a PAPPG cycle window, and the DoD schema adds export-control flags. Only those divergent rules live per agency; every shared constraint comes from the one library. Reusable validator library money_amount() identifier() # ORCID / eRA date_in_cycle() page_range() coerce_enum() model_validator # cross-field NIHSchema + modular budget rule NSFSchema + PAPPG cycle window DoDSchema + export-control flags
Shared format and range rules live once in the library; each agency schema adds only its divergent policy.

Phase 3 — Agency-conflicting rules and coercion policy

Sharing validators exposes the places where agencies genuinely disagree, and the library must express those differences without forking. Four patterns cover almost all of them.

Optional-versus-required by agency. A field can be mandatory for one funder and absent for another — an ORCID is required on an NSF program-director record but optional elsewhere. Keep the format validator shared (OrcidId), and let each model choose OrcidId versus OrcidId | None. Requiredness is the model’s decision; format is the library’s.

Conflicting bounds. The same field carries different limits per agency — a page range capped at 15 for one mechanism and 12 for another. Do not bake the number into the shared type; pass it as policy from the NIH FOA schema mapping record or the registry, using Field(le=...) on the model or a parameterized factory like within_cycle.

Coercion versus strict. Decide deliberately whether a validator coerces or rejects. Enum and casing normalization should coerce, because upstream extraction is messy; a dollar amount should be strict, because silently coercing "12,000" hides a parser defect. The library makes this explicit per validator rather than leaving it to a global config.

Error-message clarity. A shared validator’s message is read by a grant administrator, not only a developer, so it must name the expected shape (“ORCID must look like 0000-0002-1825-0097”) rather than emit a bare regex failure. One clear message, reused everywhere, beats three vague ones.

Shared validator NIH use NSF use DoD use
Money Modular budget totals Detailed line items Cost volume totals
OrcidId Optional on PI record Required on program director Optional
EraCommonsId Required (submission ID) Not used Not used
within_cycle(...) Standard due dates PAPPG cycle window Broad Agency Announcement (BAA) open period
PageCount Research Strategy ≤ 12 Project Description ≤ 15 Per-BAA volume limit

Phase 4 — Testing the shared validators once

The payoff of a shared library is that its rules are tested once, in isolation, instead of re-tested inside every agency model. Drive each validator directly through a minimal Annotated type or a throwaway model, asserting both the accept and reject paths, so a regression is caught at the library boundary before it can reach three schemas.

python
import pytest
from decimal import Decimal
from pydantic import TypeAdapter, ValidationError

def test_money_rejects_negative_and_sub_cent() -> None:
    adapter = TypeAdapter(Money)
    assert adapter.validate_python(Decimal("12000.00")) == Decimal("12000.00")
    with pytest.raises(ValidationError):
        adapter.validate_python(Decimal("-1"))          # non-negative rule
    with pytest.raises(ValidationError):
        adapter.validate_python(Decimal("1.005"))       # cents-precision rule

def test_orcid_format_is_enforced() -> None:
    adapter = TypeAdapter(OrcidId)
    assert adapter.validate_python("0000-0002-1825-0097")
    with pytest.raises(ValidationError):
        adapter.validate_python("0000-0002-1825")        # too short

def test_within_cycle_factory_bounds_dates() -> None:
    from datetime import date
    check = within_cycle(date(2026, 1, 1), date(2026, 6, 30))
    assert check(date(2026, 3, 1)) == date(2026, 3, 1)
    with pytest.raises(ValueError):
        check(date(2026, 9, 1))                          # outside the window

def test_agency_enum_coercion_normalizes_input() -> None:
    assert AgencyMixin(agency=" nih ").agency is Agency.NIH   # casing + whitespace
    with pytest.raises(ValidationError):
        AgencyMixin(agency="EPA")                        # unknown funder still rejected

Finish with a checklist that guards the library’s contract: confirm every shared validator has both an accept and a reject test, that policy numbers (ceilings, cycle windows) are injected rather than hard-coded, that coercing validators are documented as coercing, that error messages name the expected format, and that adding a fourth agency schema requires importing the vocabulary and writing only its divergent rules — never re-implementing a shared one. When that last property holds, the library is doing its job.

Frequently asked questions

When should a rule be an `Annotated` type versus a `field_validator`?

Reach for an Annotated alias with AfterValidator when the rule is a reusable property of a type — a money value, an ORCID — because the alias reads as a type and cannot be forgotten when a new model adds the field. Use a standalone field_validator when the rule needs mode="before" to normalize raw input before parsing, as enum casing coercion does, or when it depends on model context. Both live in the library; the choice is about how the rule is applied, not where it is defined.

How do I share a validator whose limit differs by agency?

Keep the format logic shared and inject the number as policy. A page-range validator lives in the library, but the ceiling — 12 for one mechanism, 15 for another — comes from the agency schema registry or the NIH FOA schema mapping record, applied with Field(le=...) on the model or through a factory that returns a validator bound to that limit. Hard-coding the number into the shared type is exactly the drift the library exists to prevent.

Where do cross-field rules belong?

Any rule that reads two or more fields at once — a start date before an end date, a total that must equal the sum of its line items — cannot be a field validator, which sees one value in isolation. It belongs in a model_validator(mode="after") on the model, or in a shared mixin when several agency models enforce the same cross-field relationship. Field validators handle single-value format and range; model validators handle relationships.

Should shared validators coerce or reject bad input?

Decide per validator and make it explicit. Coerce where upstream extraction is legitimately messy — normalize agency casing and whitespace so "nih" resolves — because rejecting there just pushes noise back at the pipeline. Reject where coercion would hide a defect: a dollar amount arriving as "12,000" signals a parser problem, and silently accepting it buries the bug. The library documents each validator’s stance rather than relying on a single global strict flag.

How do I test validators shared across three schemas without triplicating tests?

Test each validator in isolation through a TypeAdapter over its Annotated type or a one-field throwaway model, asserting both the accept and reject paths. Because the agency schemas import the same validated types, proving the rule once at the library boundary covers all three consumers — you then only test the divergent rules inside each agency model, not the shared ones again.

Up one level: Schema Validation with Pydantic