Skip to content

Built-in Field Types & Validators

→ Documentation index

Source: src/saidex/validators.py
Tests: tests/test_validators.py

This page lists the reusable field types and validator functions that SAIDEX ships with. Drop them straight into your extraction schemas — no need to write your own validator for these common cases.

This is the counterpart to Pydantic Validators: that page teaches you how to write your own validators; this page documents the ones the library provides ready-made. Reach for a built-in type first, and fall back to a custom validator only when no built-in fits.

All built-ins are exported from the package root:

from saidex import (
    IsoDateStr, IbanStr, VatIdStr, CountryCodeStr,
    CurrencyCodeStr, IsinStr, PhoneStr, LanguageCodeStr,
    RRuleStr,  # requires the optional `saidex[rrule]` extra
)

Each type X also exports its underlying validate_x function (e.g. validate_iban) so you can reuse the check outside a schema or compose it into your own Annotated type.


Why use a built-in type?

A built-in like IsoDateStr gives you several things at once:

  • One annotation, every schema — attach validation by typing a field, with no @field_validator method to copy around.
  • Stays a str — the value round-trips as a string (never coerced into a datetime.date or other object), so model_dump() and JSON output stay predictable. Some types additionally normalise the string to a canonical form (e.g. IbanStr strips spaces and upper-cases) — noted per type below.
  • Clear, LLM-facing error messages — on invalid input the validator raises ValueError, which create_instance_safe turns into structured correction guidance and feeds back into the retry loop (see Retry & Fallback).
  • Tested — every built-in is covered in tests/test_validators.py.

Reference

Type Validator Normalises? Purpose
IsoDateStr validate_iso_date no (strict) A str in ISO yyyy-mm-dd calendar-date format
IbanStr validate_iban yes IBAN with structural and mod-97 checksum validation
VatIdStr validate_vat_id yes EU-style VAT identification number (format check)
CountryCodeStr validate_country_code yes ISO 3166-1 alpha-2 country code, validated against the official set
CurrencyCodeStr validate_currency_code yes ISO 4217 currency code, validated against the official set
IsinStr validate_isin yes ISIN with structural and Luhn check-digit validation
PhoneStr validate_phone yes Phone number normalised to E.164 (+ and country code)
LanguageCodeStr validate_language_code yes ISO 639-1 language code, validated against the official set
RRuleStr validate_rrule no (strict) RFC 5545 recurrence rule (RRULE); needs the saidex[rrule] extra

Every validate_* function returns the (optionally normalised) value on success and raises ValueError on failure — see validate_iso_date for the patterns in which you can reuse them directly.


IsoDateStr

IsoDateStr = Annotated[str, AfterValidator(validate_iso_date)]

A drop-in replacement for str on any field that must hold a date in yyyy-mm-dd form (four-digit year, zero-padded month and day). It validates both the shape and that the value is a real calendar date, while keeping the field a str.

Usage

from pydantic import BaseModel, Field
from saidex import IsoDateStr

class MeetingNotes(BaseModel):
    # Required date field
    meeting_date: IsoDateStr = Field(description="Meeting date as yyyy-mm-dd")

    # Optional date field — combine with `| None`
    next_meeting: IsoDateStr | None = Field(
        default=None,
        description="Date of the next meeting as yyyy-mm-dd, or null",
    )

IsoDateStr also works inside nested models and lists — for example an action_items: list[ActionItem] where each ActionItem.due_date is IsoDateStr | None. Validation errors report the full path (action_items -> 0 -> due_date) so the LLM knows exactly which item to fix.

What it accepts and rejects

Input Result
"2024-04-05" ✅ valid, returned unchanged
None (on an IsoDateStr \| None field) ✅ allowed
"2024-4-5" ❌ wrong format — month/day must be zero-padded
"April 5th", "05.04.2024", "31/12/2026" ❌ wrong format
"2024-04-05T00:00:00" ❌ wrong format — date only, no time
"2024-13-40", "2024-02-30" ❌ right shape, but not a real calendar date

The two rejection reasons produce distinct error messages so the model gets actionable feedback:

'2024-4-5' does not match the required date format yyyy-mm-dd
(four-digit year, zero-padded month and day, e.g. 2024-04-05).

'2024-13-40' has the right shape but is not a real calendar date.
Use a valid yyyy-mm-dd date, e.g. 2024-04-05.

Tip: Still describe the format in Field(description=...). The validator is the safety net that triggers a retry; a good description is what stops the model from producing a bad value in the first place. See Schema Design — dates.

Strict vs. lenient — which should you use?

IsoDateStr is strict: it rejects anything that is not already yyyy-mm-dd and lets the retry loop ask the model to correct it. This keeps your data clean and surfaces genuinely ambiguous input.

If you would rather auto-normalise other notations (e.g. rewrite 05.04.2024 → 2024-04-05) instead of rejecting them, use a BeforeValidator that reformats the string — see Validators — normalising date formats. You can even combine both: a BeforeValidator to normalise, then validate_iso_date as the final guard.


validate_iso_date

def validate_iso_date(value: str) -> str: ...

The plain function that powers IsoDateStr. It returns the value unchanged when valid and raises ValueError otherwise. Use it directly when you want to:

Compose your own Annotated type (e.g. normalise first, then validate):

from typing import Annotated
from pydantic import BeforeValidator, AfterValidator
from saidex import validate_iso_date

def to_iso(v: str) -> str:
    ...  # rewrite DD.MM.YYYY → YYYY-MM-DD, then let validate_iso_date check it
    return v

LenientISODate = Annotated[str, BeforeValidator(to_iso), AfterValidator(validate_iso_date)]

Validate inside an existing @field_validator or @model_validator:

from pydantic import BaseModel, field_validator
from saidex import validate_iso_date

class Booking(BaseModel):
    check_in: str
    check_out: str

    @field_validator("check_in", "check_out", mode="after")
    @classmethod
    def _check_dates(cls, v: str) -> str:
        return validate_iso_date(v)

Validate outside of Pydantic (plain input checking):

from saidex import validate_iso_date

try:
    validate_iso_date(user_input)
except ValueError as exc:
    print(f"Bad date: {exc}")

IbanStr

IbanStr = Annotated[str, AfterValidator(validate_iban)]

An IBAN field that validates both structure and the ISO 7064 mod-97 check digits, so a transposed or misread digit is caught — not just a wrong shape. The value is normalised: surrounding spaces are removed and letters upper-cased, so model_dump() yields the compact canonical form.

from pydantic import BaseModel, Field
from saidex import IbanStr

class PaymentDetails(BaseModel):
    iban: IbanStr = Field(description="Payee IBAN")

PaymentDetails(iban="de89 3704 0044 0532 0130 00").iban
# -> "DE89370400440532013000"
Input Result
"DE89 3704 0044 0532 0130 00" ✅ → "DE89370400440532013000"
"GB82 WEST 1234 5698 7654 32" ✅ normalised
"DE42 1004 0000 0287 8000 04" ❌ valid shape, checksum fails
"not-an-iban", "1234" ❌ not structurally an IBAN

VatIdStr

VatIdStr = Annotated[str, AfterValidator(validate_vat_id)]

An EU-style VAT identification number: a 2-letter country prefix followed by 2–12 alphanumeric characters. Spaces, dots, and hyphens are stripped and the value upper-cased. This is a format check (not a per-country length or checksum check), which is enough to reject obviously wrong values from an LLM.

from saidex import VatIdStr

class Company(BaseModel):
    vat_id: VatIdStr | None = Field(None, description="EU VAT ID, e.g. DE298471023")

# "DE 301 847 192" -> "DE301847192";  "ATU12345678" stays;  "12345" -> ValueError

CountryCodeStr

CountryCodeStr = Annotated[str, AfterValidator(validate_country_code)]

An ISO 3166-1 alpha-2 country code, validated against the official set of assigned codes and upper-cased. Because it checks the real set, common mistakes are rejected: the United Kingdom is "GB", not "UK"; "XX" is invalid.

from saidex import CountryCodeStr

class Address(BaseModel):
    country: CountryCodeStr = Field(description="ISO 3166-1 alpha-2 code, e.g. DE")

Address(country="de").country   # -> "DE"
Address(country="UK")           # -> ValueError (use "GB")

Tip: Tell the model the expected form in the description (“two-letter ISO code”). If your source text uses country names, pair this with a BeforeValidator that maps names → codes (see Build your own validated type).


CurrencyCodeStr

CurrencyCodeStr = Annotated[str, AfterValidator(validate_currency_code)]

An ISO 4217 three-letter currency code, validated against the set of active codes (plus the standard precious-metal/supranational codes) and upper-cased.

from saidex import CurrencyCodeStr

class Money(BaseModel):
    amount: float = Field(description="Numeric amount, no symbol")
    currency: CurrencyCodeStr = Field(description="ISO 4217 code, e.g. EUR")

Money(amount=49.99, currency="eur").currency   # -> "EUR"
Money(amount=1.0, currency="€")                # -> ValueError (use "EUR")

IsinStr

IsinStr = Annotated[str, AfterValidator(validate_isin)]

An ISIN (International Securities Identification Number) that validates both structure (2-letter prefix, 9 alphanumeric characters, 1 check digit) and the ISO 6166 Luhn check digit, catching transposed or misread characters. The value is normalised (spaces removed, upper-cased).

from saidex import IsinStr

class Security(BaseModel):
    isin: IsinStr = Field(description="12-character ISIN, e.g. US0378331005")

Security(isin="us 0378331005").isin   # -> "US0378331005"
Input Result
"US0378331005" (Apple) ✅ valid
"DE0005140008" (Deutsche Bank) ✅ valid
"US0378331006" ❌ valid shape, check digit fails
"12345", "USABC" ❌ not structurally an ISIN

The check digit validates the digits, not the country prefix — by design, since valid prefixes include non-country codes like XS (Eurobonds). Pair it with a description if you also want a specific issuer country.


PhoneStr

PhoneStr = Annotated[str, AfterValidator(validate_phone)]

A phone number normalised to E.164 (+, country code, then digits — max 15 total). Common separators (spaces, parentheses, dots, slashes, hyphens) are removed, and a leading international 00 is converted to +.

from saidex import PhoneStr

class Contact(BaseModel):
    phone: PhoneStr | None = Field(None, description="Phone in E.164 form, e.g. +496971402200")

Contact(phone="+49 69 7140 2200").phone   # -> "+496971402200"
Contact(phone="069 7140 2200")            # -> ValueError (no country code)

E.164 requires a country code, so a bare national number is rejected. If your source text omits it, prepend the expected country code in a BeforeValidator (see Build your own validated type).


LanguageCodeStr

LanguageCodeStr = Annotated[str, AfterValidator(validate_language_code)]

An ISO 639-1 two-letter language code, validated against the official set and lower-cased. Rejects names and wrong codes: English is "en", not "english" or "gb".

from saidex import LanguageCodeStr

class Page(BaseModel):
    language: LanguageCodeStr = Field(description="ISO 639-1 language code, e.g. en")

Page(language="EN").language   # -> "en"

RRuleStr

RRuleStr = Annotated[str, AfterValidator(validate_rrule)]

An RFC 5545 recurrence rule (RRULE) — the string that encodes "every year on May 1st" or "every Monday, Wednesday and Friday". The value is validated by parsing it and is kept verbatim (strict, like IsoDateStr) so it round-trips exactly as the model produced it.

Optional dependency. RRuleStr parses the rule with python-dateutil, which ships in the saidex[rrule] extra. Install it with pip install 'saidex[rrule]'. If the extra is missing, validate_rrule raises RuntimeError (not ValueError) — a missing dependency is an environment problem the retry loop must not try to "correct".

from saidex import RRuleStr

class CalendarEvent(BaseModel):
    title: str
    recurrence: RRuleStr = Field(
        description="Recurrence as an RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR",
    )

CalendarEvent(title="Standup", recurrence="FREQ=WEEKLY;BYDAY=MO,WE,FR")
Input Result
"FREQ=YEARLY;BYMONTH=5;BYMONTHDAY=1" ✅ valid, returned unchanged
"FREQ=WEEKLY;BYDAY=MO,WE,FR" ✅ valid
"FREQ=DAILY;COUNT=10" ✅ valid
"every monday", "FREQ=FORTNIGHTLY" ❌ not a parseable RRULE
"" ❌ empty

How built-ins fit the retry loop

Because every built-in raises ValueError on bad input, it plugs into the same self-correction flow as any hand-written validator:

LLM output → create_instance_safe(schema, **output)
                   │
                   ├─ IsoDateStr / validate_iso_date runs
                   │
                   ├─ valid   → validated instance ✅
                   └─ invalid → ValueError
                                   │
                                   ▼
                         structured field-level error
                         ("action_items -> 0 -> due_date: …")
                                   │
                                   ▼
                         sent back to the LLM as a retry prompt

See Retry & Fallback for the full end-to-end loop and Validators — testing for how to assert on the exact message the LLM receives.


Build your own validated type

The built-ins on this page are nothing special — they are just Annotated aliases wrapping a validator function. You can create your own in your code the exact same way, and they behave identically: reusable across schemas, and their ValueError messages flow into the retry loop via create_instance_safe.

The recipe

from typing import Annotated
from pydantic import AfterValidator

def validate_x(value: str) -> str:
    """Return the (optionally normalised) value, or raise ValueError."""
    if not is_acceptable(value):
        raise ValueError("…specific, actionable message telling the LLM the rule…")
    return normalise(value)          # or just `return value` to keep it as-is

XStr = Annotated[str, AfterValidator(validate_x)]

Three decisions shape the validator:

  1. AfterValidator vs BeforeValidator. AfterValidator runs after Pydantic has coerced the value to the field's declared type — use it for strings you want to check and lightly normalise (case, whitespace). BeforeValidator runs on the raw value first — use it to reshape messy input (e.g. map a country name to a code) before the strict check. You can stack both: Annotated[str, BeforeValidator(reshape), AfterValidator(check)].
  2. Strict vs normalising. Return the value unchanged to be strict (like IsoDateStr), or return a canonical form to normalise (like IbanStr). Document which you chose — callers rely on the output shape.
  3. The error message. It is sent verbatim to the LLM. State the rule and give an example: "…expected NNNNN or NNNNN-NNNN, e.g. 10115". See Validators — error messages.

Example 1 — a strict pattern type

A reference/order ID that must look like ORD-12345:

import re
from typing import Annotated
from pydantic import AfterValidator

_ORDER_ID_RE = re.compile(r"^ORD-\d{5}$")

def validate_order_id(value: str) -> str:
    if not isinstance(value, str) or not _ORDER_ID_RE.match(value):
        raise ValueError(
            f"'{value}' is not a valid order ID. Expected 'ORD-' followed by "
            f"exactly five digits, e.g. 'ORD-04812'."
        )
    return value

OrderId = Annotated[str, AfterValidator(validate_order_id)]

Example 2 — a normalising, set-validated type

Validate against an allowed set and normalise. The same shape as CountryCodeStr / CurrencyCodeStr:

from typing import Annotated
from pydantic import AfterValidator

_PRIORITIES = {"low", "medium", "high", "critical"}

def validate_priority(value: str) -> str:
    norm = str(value).strip().lower()
    if norm not in _PRIORITIES:
        allowed = ", ".join(sorted(_PRIORITIES))
        raise ValueError(f"'{value}' is not a valid priority. Use one of: {allowed}.")
    return norm

Priority = Annotated[str, AfterValidator(validate_priority)]

For a fixed, small set you usually want a typing.Literal or an Enum instead — Pydantic then advertises the allowed values in the JSON schema the LLM sees. Reach for a set-checking validator when the list is large (country codes), comes from data, or needs normalisation before the check.

Example 3 — reshape first, then reuse a built-in

Map a country name to a code with a BeforeValidator, then hand off to the library's validate_country_code for the authoritative check:

from typing import Annotated, Any
from pydantic import BeforeValidator, AfterValidator
from saidex import validate_country_code

_NAME_TO_CODE = {"germany": "DE", "deutschland": "DE", "united kingdom": "GB"}

def name_to_code(value: Any) -> Any:
    if isinstance(value, str):
        return _NAME_TO_CODE.get(value.strip().lower(), value)
    return value

# Accepts "Germany", "DE", or "de"; always ends up validated and normalised to "DE".
LenientCountryCode = Annotated[str, BeforeValidator(name_to_code), AfterValidator(validate_country_code)]

Where to put it

Define each type once in a small module (e.g. myapp/schema_types.py) and import it wherever you declare schemas. That keeps the validation rule in a single, testable place — exactly how SAIDEX keeps its own in src/saidex/validators.py.

Test it without an LLM

Validators are plain functions, so test them directly — and assert on the message the LLM would receive via create_instance_safe:

import pytest
from saidex import create_instance_safe

def test_order_id_rejects_bad_value():
    with pytest.raises(ValueError, match="valid order ID"):
        validate_order_id("12345")

def test_schema_reports_field_path():
    instance, error = create_instance_safe(MyOrder, order_id="12345")
    assert instance is None
    assert "order_id" in error      # this text is sent back to the model

See Validators — testing for more on testing validators and inspecting the retry message.