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
validateDateParts(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 2007validateDateParts(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 boxvalidateDateParts(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.
export function validateDateParts(parts: DateParts, label: string, rules: DateRules): DatePartsCheck
| parts | DateParts | the three boxes as typed; each is trimmed, and the month may be a name (jan, January) |
| label | string | what the date is, as it starts a sentence: "Date of birth", "The date your course ends" |
| rules | DateRules | optional past/future and earliest/latest checks; today is passed in, never read from the clock |
| returns | DatePartsCheck | valid 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
/** What a person typed into a date input's three boxes, untrimmed. */
export interface DateParts {
readonly day: string;
readonly month: string;
readonly year: string;
}
export type DateTiming = "any" | "past" | "past-or-today" | "future" | "future-or-today";
/** Checks on a real date beyond its being real; null everywhere means none. */
export interface DateRules {
/** needed when timing is not any or null */
readonly today: string | null;
/** past, past-or-today, future or future-or-today, relative to today */
readonly timing: DateTiming | null;
/** the earliest date allowed, inclusive */
readonly notBefore: string | null;
/** the latest date allowed, inclusive */
readonly notAfter: string | null;
}
/** The verdict on a typed date, shaped for a date input's error message and error classes. */
export interface DatePartsCheck {
readonly valid: boolean;
/** the ISO date, when valid */
readonly date: string | null;
/** the error message to show, when not valid */
readonly message: string | null;
/** which of "day", "month" and "year" to mark in error, in that order; empty when valid */
readonly fields: readonly string[];
}
Your code names it in one line, in the file that uses it
import { validateDateParts } from "#fune/form.date-parts@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { daysInMonth, parseIsoDate } from "./dates_add_days.ts"; ← from dates.add-days ^1.0.0 · built alongside by fune
import type { DateParts, DatePartsCheck, DateRules } from "./form_date_parts_types.ts";
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
const PARTS = ["day", "month", "year"] as const;
const ALL = ["day", "month", "year"];
// ASCII whitespace only, so all three languages trim the same characters.
function isSpace(ch: string): boolean {
return ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f" || ch === "\v";
}
function trim(value: string): string {
let start = 0;
let end = value.length;
while (start < end && isSpace(value[start])) start++;
while (end > start && isSpace(value[end - 1])) end--;
return value.slice(start, end);
}
// ASCII digits only: "٢" is not a day in any of the three languages.
function isDigits(value: string): boolean {
if (value === "") return false;
for (const ch of value) if (ch < "0" || ch > "9") return false;
return true;
}
// A day or month number: one or two digits.
function smallNumber(value: string, max: number): number | null {
if (!isDigits(value) || value.length > 2) return null;
const n = Number(value);
return n >= 1 && n <= max ? n : null;
}
// GOV.UK asks services to accept month names in full or short ("jan"), in
// any case; "sept" is common enough to accept too.
function monthNumber(value: string): number | null {
const numeric = smallNumber(value, 12);
if (numeric !== null) return numeric;
const lower = value.replace(/[A-Z]/g, (c) => c.toLowerCase());
if (lower === "sept") return 9;
for (let i = 0; i < 12; i++) {
const name = MONTH_NAMES[i].toLowerCase();
if (lower === name || lower === name.slice(0, 3)) return i + 1;
}
return null;
}
// "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 as it is.
function lowerFirst(label: string): string {
const upper = (c: string | undefined) => c !== undefined && c >= "A" && c <= "Z";
if (upper(label[0]) && !upper(label[1])) return label[0].toLowerCase() + label.slice(1);
return label;
}
function longDate(iso: string): string {
const d = parseIsoDate(iso);
return `${d.day} ${MONTH_NAMES[d.month - 1]} ${d.year}`;
}
function invalid(message: string, fields: string[]): DatePartsCheck {
return { valid: false, date: null, message, fields };
}
function joinMissing(missing: string[]): string {
return missing.length === 1 ? missing[0] : `${missing[0]} and ${missing[1]}`;
}
/**
* 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.
*/
export function validateDateParts(parts: DateParts, label: string, rules: DateRules): DatePartsCheck {
const name = trim(label);
if (name === "") throw new Error("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.
const timing = rules.timing ?? "any";
if (timing !== "any" && rules.today == null) {
throw new Error(`rules.today is needed to check that a date is ${timing.replace(/-/g, " ")}`);
}
if (rules.today != null) parseIsoDate(rules.today);
if (rules.notBefore != null) parseIsoDate(rules.notBefore);
if (rules.notAfter != null) parseIsoDate(rules.notAfter);
if (rules.notBefore != null && rules.notAfter != null && rules.notBefore > rules.notAfter) {
throw new Error(`rules.notBefore (${rules.notBefore}) must not be after rules.notAfter (${rules.notAfter})`);
}
const day = trim(parts.day);
const month = trim(parts.month);
const year = trim(parts.year);
const typed = { day, month, year };
const missing = PARTS.filter((p) => typed[p] === "");
if (missing.length === 3) return invalid(`Enter ${lowerFirst(name)}`, ALL);
if (missing.length > 0) return invalid(`${name} must include a ${joinMissing(missing)}`, [...missing]);
if (isDigits(year) && year.length !== 4) return invalid("Year must include 4 numbers", ["year"]);
const d = smallNumber(day, 31);
const m = monthNumber(month);
const y = isDigits(year) && Number(year) >= 1 ? Number(year) : null;
const wrong = PARTS.filter((p) => (p === "day" ? d : p === "month" ? m : y) === null);
if (wrong.length > 0) return invalid(`${name} must be a real date`, wrong.length === 1 ? [...wrong] : ALL);
// 31 April or 29 February 2023: the day is what does not exist.
if ((d as number) > daysInMonth(y as number, m as number)) return invalid(`${name} must be a real date`, ["day"]);
const iso = `${year}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const today = rules.today ?? "";
if (timing === "past" && iso >= today) return invalid(`${name} must be in the past`, ALL);
if (timing === "past-or-today" && iso > today) return invalid(`${name} must be today or in the past`, ALL);
if (timing === "future" && iso <= today) return invalid(`${name} must be in the future`, ALL);
if (timing === "future-or-today" && iso < today) return invalid(`${name} must be today or in the future`, ALL);
const { notBefore, notAfter } = rules;
if (notBefore != null && notAfter != null) {
if (iso < notBefore || iso > notAfter) {
return invalid(`${name} must be between ${longDate(notBefore)} and ${longDate(notAfter)}`, ALL);
}
} else if (notBefore != null && iso < notBefore) {
return invalid(`${name} must be the same as or after ${longDate(notBefore)}`, ALL);
} else if (notAfter != null && iso > notAfter) {
return invalid(`${name} must be the same as or before ${longDate(notAfter)}`, ALL);
}
return { valid: true, date: iso, message: null, fields: [] };
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 1 dependency, pins them in fune.lock, downloads only the TypeScript 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
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./form.date-parts-1.0.1-typescript.fune, or fetch it from a terminal with fune pull form.date-parts@1.0.1:typescript.
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| NOTICE | 1,213 |
| README.md | 4,920 |
| impl/python.py | 5,906 |
| impl/rust.rs | 7,848 |
| impl/typescript.ts | 5,891 |
| vectors.json | 14,750 |