Functional Weave
Code in Python

form.date-parts

Check a date typed into day, month and year boxes, with GOV.UK's error messages and the boxes to mark in error.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 49 tests, run in TypeScript, Python and Rust.

What it does

Checks a date someone typed into three boxes - day, month, year - the way the GOV.UK Design System's date input asks, and answers with GOV.UK's own error message and which boxes to mark in error. The same function runs in the browser (TypeScript), in a Python API and in a Rust service, so the page and the server never disagree about a date or word the error differently.

import { validateDateParts } from "#fune/form.date-parts@^1";

const check = validateDateParts(
  { day: form.dobDay, month: form.dobMonth, year: form.dobYear },
  "Date of birth",
  { today: "2026-09-28", timing: "past", notBefore: null, notAfter: null },
);
// { valid: false, date: null, message: "Date of birth must be in the past", fields: ["day", "month", "year"] }

For example

  • validate_date_parts(day 27, month 3, year 2007, Date of birth, today —, timing —, not before —, not after —) → valid true, date 2007-03-27, message —, fields a whole date, GOV.UK's example 27 3 2007
  • validate_date_parts(day 27 , month 3 , year 2007 , Date of birth, today —, timing —, not before —, not after —) → valid true, date 2007-03-27, message —, fields spaces and a trailing newline are trimmed from every box
  • validate_date_parts(day 02, month 2, year 2024, Date of birth, today —, timing —, not before —, not after —) → valid true, date 2024-02-02, message —, fields a leading zero or none: 02 and 2 are both February

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def validate_date_parts(parts: DateParts, label: str, rules: DateRules) -> DatePartsCheck
partsDatePartsthe three boxes as typed; each is trimmed, and the month may be a name (jan, January)
labelstringwhat the date is, as it starts a sentence: "Date of birth", "The date your course ends"
rulesDateRulesoptional past/future and earliest/latest checks; today is passed in, never read from the clock
returnsDatePartsCheckvalid with the ISO date, or GOV.UK's message and which of day, month and year to mark

The types it declares, generated into your project

@dataclass(frozen=True)
class DateParts:
    """What a person typed into a date input's three boxes, untrimmed."""

    day: str
    month: str
    year: str

DateTiming = Literal["any", "past", "past-or-today", "future", "future-or-today"]

@dataclass(frozen=True)
class DateRules:
    """Checks on a real date beyond its being real; null everywhere means none."""

    #: needed when timing is not any or null
    today: Optional[str]
    #: past, past-or-today, future or future-or-today, relative to today
    timing: Optional[DateTiming]
    #: the earliest date allowed, inclusive
    not_before: Optional[str]
    #: the latest date allowed, inclusive
    not_after: Optional[str]

@dataclass(frozen=True)
class DatePartsCheck:
    """The verdict on a typed date, shaped for a date input's error message and error classes."""

    valid: bool
    #: the ISO date, when valid
    date: Optional[str]
    #: the error message to show, when not valid
    message: Optional[str]
    #: which of "day", "month" and "year" to mark in error, in that order; empty when valid
    fields: List[str]

Your code names it in one line, in the file that uses it

from fune.form.date_parts import validate_date_parts  # form.date-parts@^1
impl/python.py · 141 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from typing import List, Optional

from .dates_add_days import days_in_month, parse_iso_date  ← from dates.add-days ^1.0.0 · built alongside by fune
from .form_date_parts_types import DateParts, DatePartsCheck, DateRules

MONTH_NAMES = (
    "January", "February", "March", "April", "May", "June",
    "July", "August", "September", "October", "November", "December",
)
PARTS = ("day", "month", "year")

# ASCII whitespace only, so all three languages trim the same characters
# (str.strip() with no argument would also take Unicode spaces).
_SPACE = " \t\n\r\f\v"


def _is_digits(value: str) -> bool:
    # ASCII digits only: str.isdigit() accepts "٢", which is not a day in the
    # other two languages.
    return value != "" and all("0" <= ch <= "9" for ch in value)


def _small_number(value: str, largest: int) -> Optional[int]:
    # A day or month number: one or two digits.
    if not _is_digits(value) or len(value) > 2:
        return None
    n = int(value)
    return n if 1 <= n <= largest else None


def _ascii_lower(value: str) -> str:
    return "".join(chr(ord(c) + 32) if "A" <= c <= "Z" else c for c in value)


def _month_number(value: str) -> Optional[int]:
    # GOV.UK asks services to accept month names in full or short ("jan"), in
    # any case; "sept" is common enough to accept too.
    numeric = _small_number(value, 12)
    if numeric is not None:
        return numeric
    lower = _ascii_lower(value)
    if lower == "sept":
        return 9
    for i, full in enumerate(MONTH_NAMES):
        name = _ascii_lower(full)
        if lower == name or lower == name[:3]:
            return i + 1
    return None


def _lower_first(label: str) -> str:
    # "Date of birth" starts a sentence; after "Enter" it is "date of birth".
    # A label that starts with an acronym ("UK arrival date") is left alone.
    def upper(i: int) -> bool:
        return i < len(label) and "A" <= label[i] <= "Z"

    if upper(0) and not upper(1):
        return _ascii_lower(label[0]) + label[1:]
    return label


def _long_date(iso: str) -> str:
    d = parse_iso_date(iso)
    return "%d %s %d" % (d.day, MONTH_NAMES[d.month - 1], d.year)


def _invalid(message: str, fields: List[str]) -> DatePartsCheck:
    return DatePartsCheck(valid=False, date=None, message=message, fields=fields)


def validate_date_parts(parts: DateParts, label: str, rules: DateRules) -> DatePartsCheck:
    """A date typed into three boxes, checked the way the GOV.UK Design
    System's date input asks: missing or incomplete information first, then a
    date that cannot be real, then the service's own rules, each with GOV.UK's
    wording."""
    name = label.strip(_SPACE)
    if name == "":
        raise ValueError('a date needs a label, such as "Date of birth", to write its error messages')
    # Rules are the caller's, so a bad one fails loudly whatever was typed.
    timing = rules.timing if rules.timing is not None else "any"
    if timing != "any" and rules.today is None:
        raise ValueError("rules.today is needed to check that a date is %s" % timing.replace("-", " "))
    for bound in (rules.today, rules.not_before, rules.not_after):
        if bound is not None:
            parse_iso_date(bound)
    not_before, not_after = rules.not_before, rules.not_after
    if not_before is not None and not_after is not None and not_before > not_after:
        raise ValueError(
            "rules.notBefore (%s) must not be after rules.notAfter (%s)" % (not_before, not_after)
        )

    typed = {
        "day": parts.day.strip(_SPACE),
        "month": parts.month.strip(_SPACE),
        "year": parts.year.strip(_SPACE),
    }
    year = typed["year"]

    missing = [p for p in PARTS if typed[p] == ""]
    if len(missing) == 3:
        return _invalid("Enter %s" % _lower_first(name), list(PARTS))
    if missing:
        what = missing[0] if len(missing) == 1 else "%s and %s" % (missing[0], missing[1])
        return _invalid("%s must include a %s" % (name, what), missing)
    if _is_digits(year) and len(year) != 4:
        return _invalid("Year must include 4 numbers", ["year"])

    d = _small_number(typed["day"], 31)
    m = _month_number(typed["month"])
    y = int(year) if _is_digits(year) and int(year) >= 1 else None
    found = {"day": d, "month": m, "year": y}
    wrong = [p for p in PARTS if found[p] is None]
    if wrong:
        return _invalid("%s must be a real date" % name, wrong if len(wrong) == 1 else list(PARTS))
    assert d is not None and m is not None and y is not None
    # 31 April or 29 February 2023: the day is what does not exist.
    if d > days_in_month(y, m):
        return _invalid("%s must be a real date" % name, ["day"])

    iso = "%s-%02d-%02d" % (year, m, d)
    today = rules.today if rules.today is not None else ""
    if timing == "past" and iso >= today:
        return _invalid("%s must be in the past" % name, list(PARTS))
    if timing == "past-or-today" and iso > today:
        return _invalid("%s must be today or in the past" % name, list(PARTS))
    if timing == "future" and iso <= today:
        return _invalid("%s must be in the future" % name, list(PARTS))
    if timing == "future-or-today" and iso < today:
        return _invalid("%s must be today or in the future" % name, list(PARTS))

    if not_before is not None and not_after is not None:
        if iso < not_before or iso > not_after:
            return _invalid(
                "%s must be between %s and %s" % (name, _long_date(not_before), _long_date(not_after)),
                list(PARTS),
            )
    elif not_before is not None and iso < not_before:
        return _invalid("%s must be the same as or after %s" % (name, _long_date(not_before)), list(PARTS))
    elif not_after is not None and iso > not_after:
        return _invalid("%s must be the same as or before %s" % (name, _long_date(not_after)), list(PARTS))
    return DatePartsCheck(valid=True, date=iso, message=None, fields=[])

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add form.date-parts
Download for Python form.date-parts-1.0.1-python.fune · 33,387 bytes sha256 916cba44a8a25f47294991e3ebccd3eba795c432b8475ec4fdec7fb243393d21

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./form.date-parts-1.0.1-python.fune, or fetch it from a terminal with fune pull form.date-parts@1.0.1:python.

The whole function, every language, is one file too: form.date-parts-1.0.1.fune, 47,741 bytes, sha256 96374a928398e4115d021774526a6218dacc6bf9e774d30f79043c268f877024. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before form.date-parts

after — your function gets the result and the arguments, and returns the final result.

# fune: after form.date-parts

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace dates.add-days in form.date-parts

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show form.date-parts --steps.

# fune: step form.date-parts after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
a whole date, GOV.UK's example 27 3 2007 day 27, month 3, year 2007, Date of birth, today —, timing —, not before —, not after — → valid true, date 2007-03-27, message —, fields
spaces and a trailing newline are trimmed from every box day 27 , month 3 , year 2007 , Date of birth, today —, timing —, not before —, not after — → valid true, date 2007-03-27, message —, fields
a leading zero or none: 02 and 2 are both February day 02, month 2, year 2024, Date of birth, today —, timing —, not before —, not after — → valid true, date 2024-02-02, message —, fields
29 February in a leap year day 29, month 2, year 2024, Date of birth, today —, timing —, not before —, not after — → valid true, date 2024-02-29, message —, fields
29 February 2023 does not exist: the day is marked day 29, month 2, year 2023, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must be a real date, fields day
1900 was not a leap year, though 1900 % 4 is 0 day 29, month 02, year 1900, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must be a real date, fields day
2000 was a leap year day 29, month 2, year 2000, Date of birth, today —, timing —, not before —, not after — → valid true, date 2000-02-29, message —, fields
31 April does not exist day 31, month 4, year 2026, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must be a real date, fields day
31 December is the last day day 31, month 12, year 1999, Date of birth, today —, timing —, not before —, not after — → valid true, date 1999-12-31, message —, fields
nothing entered: Enter, with the label in lower case, and the whole date marked day , month , year , Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Enter date of birth, fields day, month, year
Show the other 39 tests
CaseArgumentsExpected
a missing month day 27, month , year 2007, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must include a month, fields month
a missing day and year day , month 3, year , Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must include a day and year, fields day, year
a missing day and month, GOV.UK's example day , month , year 2007, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must include a day and month, fields day, month
a two-digit year is incomplete day 27, month 3, year 07, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Year must include 4 numbers, fields year
a five-digit year is not 4 numbers either day 27, month 3, year 20077, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Year must include 4 numbers, fields year
missing information comes before an incomplete year day , month 3, year 07, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must include a day, fields day
an incomplete year comes before an impossible day day 32, month 3, year 07, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Year must include 4 numbers, fields year
month 13 cannot be correct day 27, month 13, year 2007, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must be a real date, fields month
day 0 cannot be correct day 0, month 3, year 2007, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must be a real date, fields day
two wrong boxes: the whole date is marked day 32, month 13, year 2007, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must be a real date, fields day, month, year
letters in the year day 27, month 3, year 20o7, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must be a real date, fields year
year 0000 does not exist day 1, month 1, year 0000, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must be a real date, fields year
three digits in the day day 027, month 3, year 2007, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must be a real date, fields day
an Arabic-Indic digit is not a day in any of the languages day ٢, month 3, year 2007, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must be a real date, fields day
a month name in full, any case day 5, month January, year 2026, Date of birth, today —, timing —, not before —, not after — → valid true, date 2026-01-05, message —, fields
a short month name day 5, month jan, year 2026, Date of birth, today —, timing —, not before —, not after — → valid true, date 2026-01-05, message —, fields
SEPT is September day 9, month SEPT, year 2026, Date of birth, today —, timing —, not before —, not after — → valid true, date 2026-09-09, message —, fields
a month name cut at four letters is not one day 5, month Janu, year 2026, Date of birth, today —, timing —, not before —, not after — → valid false, date —, message Date of birth must be a real date, fields month
in the past: today is not in the past day 28, month 9, year 2026, Date of birth, today 2026-09-28, timing past, not before —, not after — → valid false, date —, message Date of birth must be in the past, fields day, month, year
in the past: yesterday is day 27, month 9, year 2026, Date of birth, today 2026-09-28, timing past, not before —, not after — → valid true, date 2026-09-27, message —, fields
today or in the past: today is allowed day 28, month 9, year 2026, Date of birth, today 2026-09-28, timing past-or-today, not before —, not after — → valid true, date 2026-09-28, message —, fields
today or in the past: tomorrow is not day 29, month 9, year 2026, Date of birth, today 2026-09-28, timing past-or-today, not before —, not after — → valid false, date —, message Date of birth must be today or in the past, fields day, month, year
in the future, GOV.UK's example label day 28, month 9, year 2026, The date your course ends, today 2026-09-28, timing future, not before —, not after — → valid false, date —, message The date your course ends must be in the future, fields day, month, year
today or in the future: yesterday is not day 27, month 9, year 2026, The date your course ends, today 2026-09-28, timing future-or-today, not before —, not after — → valid false, date —, message The date your course ends must be today or in the future, fields day, month, year
today or in the future: today is day 28, month 9, year 2026, The date your course ends, today 2026-09-28, timing future-or-today, not before —, not after — → valid true, date 2026-09-28, message —, fields
timing any ignores today day 1, month 1, year 2099, Date of birth, today 2026-09-28, timing any, not before —, not after — → valid true, date 2099-01-01, message —, fields
between two dates, GOV.UK's example day 1, month 10, year 2017, The date your contract started, today —, timing —, not before 2017-09-01, not after 2017-09-30 → valid false, date —, message The date your contract started must be between 1 September 2017 and 30 September 2017, fields day, month, year
between is inclusive at both ends day 30, month 9, year 2017, The date your contract started, today —, timing —, not before 2017-09-01, not after 2017-09-30 → valid true, date 2017-09-30, message —, fields
not before a date day 31, month 8, year 2017, The date your course ends, today —, timing —, not before 2017-09-01, not after — → valid false, date —, message The date your course ends must be the same as or after 1 September 2017, fields day, month, year
not after a date day 1, month 9, year 2017, The date of the last exam, today —, timing —, not before —, not after 2017-08-31 → valid false, date —, message The date of the last exam must be the same as or before 31 August 2017, fields day, month, year
a bound on the day itself is allowed day 31, month 8, year 2017, The date of the last exam, today —, timing —, not before —, not after 2017-08-31 → valid true, date 2017-08-31, message —, fields
a real-date problem comes before a rule day 31, month 4, year 2017, The date of the last exam, today —, timing —, not before —, not after 2017-08-31 → valid false, date —, message The date of the last exam must be a real date, fields day
a label that starts with an acronym keeps its capitals after Enter day , month , year , UK arrival date, today —, timing —, not before —, not after — → valid false, date —, message Enter UK arrival date, fields day, month, year
the label is trimmed day , month 3, year 2007, Date of birth , today —, timing —, not before —, not after — → valid false, date —, message Date of birth must include a day, fields day
a timing rule needs today day 1, month 1, year 2000, Date of birth, today —, timing past, not before —, not after — → error: rules.today is needed to check that a date is past
a malformed bound is refused day 1, month 1, year 2000, Date of birth, today —, timing —, not before 2017-9-1, not after — → error: "2017-9-1" is not an ISO date
a bound that cannot exist is refused day 1, month 1, year 2000, Date of birth, today —, timing —, not before —, not after 2017-02-30 → error: "2017-02-30" is not a real calendar date
notBefore after notAfter is refused day 1, month 1, year 2000, Date of birth, today —, timing —, not before 2018-01-01, not after 2017-01-01 → error: rules.notBefore (2018-01-01) must not be after rules.notAfter (2017-01-01)
an empty label is refused day 1, month 1, year 2000, , today —, timing —, not before —, not after — → error: a date needs a label

More from the author

check = validate_date_parts(DateParts(day="31", month="4", year="2026"), "Date of birth", DateRules(today=None, timing=None, not_before=None, not_after=None))
# DatePartsCheck(valid=False, date=None, message="Date of birth must be a real date", fields=["day"])

`message` goes to `react.form.date-input`'s `error` and `fields` to its `errorFields`, which marks exactly those boxes.

## The checks, in GOV.UK's order of priority

GOV.UK shows the highest-priority error only: missing or incomplete information, then information that cannot be correct, then anything else.

| What was typed | Message | Marked | | --- | --- | --- | | nothing in any box | `Enter date of birth` | all three | | some boxes empty | `Date of birth must include a month`, `... a day and year` | the empty ones | | a year of digits that are not 4 | `Year must include 4 numbers` | year | | a box that cannot be right (day 32, month 13, letters, year 0000) | `Date of birth must be a real date` | that box, or all three when more than one is wrong | | a day the month does not have (31 April, 29 February 2023) | `Date of birth must be a real date` | day | | `timing` past / past-or-today / future / future-or-today | `... must be in the past`, `... must be today or in the past`, `... must be in the future`, `... must be today or in the future` | all three | | `notBefore` and `notAfter` | `... must be between 1 September 2017 and 30 September 2017` | all three | | `notBefore` alone | `... must be the same as or after 1 September 2017` | all three | | `notAfter` alone | `... must be the same as or before 31 August 2017` | all three |

Otherwise it answers `{ valid: true, date: "2007-03-27", message: null, fields: [] }`.

- **The label** is written as it starts a sentence ("Date of birth", "The date your course ends"). After "Enter" its first letter is lower-cased ("Enter date of birth"), unless it starts with an acronym ("Enter UK arrival date"): two capitals in a row are left alone. GOV.UK's own example reads "Enter your date of birth"; pass "Your date of birth" as the label for that, and every other message then starts "Your date of birth must...". - **"Year must include 4 numbers"** is GOV.UK's wording verbatim and does not use the label. - **Trimming**: each box loses leading and trailing ASCII whitespace (space, tab, line breaks), the same in all three languages. - **Numbers** are ASCII digits only: `"٢"` is not a day. A day or month is one or two digits, so `"2"` and `"02"` are both accepted and `"002"` is not. - **Month names** are accepted, as GOV.UK asks, in full or as their first three letters, in any case (`January`, `jan`, `JAN`), plus `sept`. English only. - **Dates are proleptic Gregorian**, years 1 to 9999, the range of the `dates.*` family; the leap-year rule and month lengths come from `dates.add-days`, not a copy. - **Which box for 31 April**: GOV.UK says to mark the box that is wrong, or the whole date when it is not clear. This marks the day, which is where the fix almost always is. - **Today is an argument.** Nothing reads the clock, so the check is the same in a test, on a server in another timezone, and in the browser. Pass the date that "today" means for your service (usually the UK date). - Rules are checked before the date: a timing without `today`, a bound that is not an ISO date, or `notBefore` after `notAfter` throws whatever was typed, since that is a bug in the caller, not a mistake by the user. So does an empty label.

## Sources

GOV.UK Design System, "Date input", error messages and month names: https://design-system.service.gov.uk/components/date-input/ and the "Dates" pattern https://design-system.service.gov.uk/patterns/dates/. The messages above were checked word for word against the "Error messages" section of the component page.

## Notices

Portions derived from GOV.UK Frontend (https://github.com/alphagov/govuk-frontend), Copyright (c) 2017 Crown Copyright (Government Digital Service), under the MIT License; the full notice is in NOTICE.

1.0.1 adds its attribution notices (NOTICE). The code and the tests are unchanged.

Files

PathBytes
NOTICE1,213
README.md4,920
impl/python.py5,906
impl/rust.rs7,848
impl/typescript.ts5,891
vectors.json14,750