Age
How old is someone, in completed years, months and days? It sounds like a subtraction and is not, because months have different lengths. What is one month after 31 January? When does someone born on 29 February have a birthday in a year without one?
This program answers with the rule people actually use, and runs it on eleven pairs of dates chosen to hit every awkward case. It is a checkpoint for Part 20: Utilities: the Time package does the calendar arithmetic, and the program's job is to ask it the right questions.
How it is put together
| Piece | Its job | Lessons it uses |
|---|---|---|
Age | Years, months and days, as a struct | Struct |
AgeError | BornLater or OutOfRange — no details needed, so a plain enum | Enum, Fail |
AgeOn | The calculation | Date, Absence to error |
Show | Parses two texts, calls AgeOn, prints one row | Date, Catch, Format |
Main | Eleven rows of test cases | Function |
The dates are all written out in the source, so the output is the same on every run. A real program would ask the clock for today — one of the exercises below.
The rule
Count whole years, then whole months, then the days left over. A month is complete when its day comes round again — and when that day does not exist, the month is complete on its last day instead:
| Born | One month later | Why |
|---|---|---|
| 15 March | 15 April | The day exists |
| 31 January | 28 February | February has no 31st, so its last day |
| 29 February | 28 February | In a year that is not a leap year |
PlusMonths from the Time package follows exactly that rule — it clamps to the end of the month — so the program never counts the days in a month by hand.
Counting months, then correcting by one
AgeOn first refuses a birth after the date asked about. Then it counts the calendar months between the two dates:
var months = ((on.year - birth.year) as int64) * 12 + (on.month as int64)
- (birth.month as int64);
var reached = birth.PlusMonths(months) ?? fail AgeError::OutOfRange;
if on.DaysSince(reached) < 0 {
months -= 1;
reached = birth.PlusMonths(months) ?? fail AgeError::OutOfRange;
}
That count is either right or one too many — too many when the day of the month has not come round yet. Born on 15 March 1990 and asked about 4 October 2026, the count says 439 months, but 15 October 2026 is still in the future, so it drops to 438 — 36 years and 6 months. reached is then the last date, on or before on, that is a whole number of months after the birth.
PlusMonths returns Date?: a date far enough out leaves the range the calendar supports. ?? fail turns that absence into the OutOfRange error on the spot.
The answer is then a division and a remainder, and the days left over are a plain DaysSince:
return Age { years: months / 12, months: months % 12, days: on.DaysSince(reached) };
Step from the birth, not from last month
Notice that both calls are birth.PlusMonths(months) — always counted from the birth date. Stepping one month at a time would let the clamping drift:
| From 31 January 2026 | Result |
|---|---|
PlusMonths(1) | 28 February |
…then PlusMonths(1) again | 28 March |
PlusMonths(2) directly | 31 March |
Once a step has clamped to the 28th, every later step keeps the 28th. Counting from the birth each time keeps the original day of the month.
Parsing, and dates that do not exist
Show parses both texts with ParseDate, which returns Date ! TimeParseError. A text that is not a real date — such as 29 February 2023 — fails, and the catch prints a row saying so and leaves Show early:
let birth = ParseDate(birthText) catch {
else => {
PrintLine("{} {} {} is not a date", birthText, onText, birthText);
return;
}
};
The outcome of AgeOn is matched with nested patterns, one arm per case of the enum:
match AgeOn(birth, on) {
.Success(age) => PrintLine("{} {} {:5} {:7} {:5}", birth, on,
age.years, age.months, age.days),
.Failure(AgeError::BornLater) => PrintLine("{} {} refused, born after that date",
birth, on),
.Failure(AgeError::OutOfRange) => PrintLine("{} {} outside the calendar", birth, on)
}
{:5} and {:7} pad the numbers to the widths of the header's columns.
The program
The whole lesson is one package in the Examples repository. Its comments explain every step.
// Working out how old someone is, in completed years, months and days. It sounds like
// subtraction and is not, because months have different lengths.
//
// The rule is the one people use: count whole years, then whole months, then the days left over.
// A month is complete when its day comes round again. When that day does not exist, the month
// is complete on its last day instead: one month after 31 January is 28 February, and someone
// born on 29 February has their birthday on 28 February in a year that is not a leap year.
//
// `PlusMonths` from the Time package follows exactly that rule, clamping to the end of the
// month, so the program never counts days in a month by hand. It finds the last date, on or
// before the second one, that is a whole number of months after the birth, and the days left
// over are a plain `DaysSince`.
//
// Every date is written out, so the output is the same on every run. A real program would ask
// the clock for today.
import Io::PrintLine;
import Time::{ Date, ParseDate };
struct Age {
years: int64;
months: int64;
days: int64;
}
enum AgeError {
BornLater,
OutOfRange
}
// The age on `on` of someone born on `birth`, or why there is none.
func AgeOn(birth: Date, on: Date) -> Age ! AgeError {
if on.DaysSince(birth) < 0 {
fail AgeError::BornLater;
}
// Counting the calendar months between the two gives the right answer, or one too many
// when the day of the month has not come round yet. Stepping from `birth` rather than
// from the previous month keeps the clamping from drifting: 31 January plus two months
// is 31 March, not 28 March.
var months = ((on.year - birth.year) as int64) * 12 + (on.month as int64)
- (birth.month as int64);
var reached = birth.PlusMonths(months) ?? fail AgeError::OutOfRange;
if on.DaysSince(reached) < 0 {
months -= 1;
reached = birth.PlusMonths(months) ?? fail AgeError::OutOfRange;
}
return Age { years: months / 12, months: months % 12, days: on.DaysSince(reached) };
}
func Show(birthText: char8[..], onText: char8[..]) {
let birth = ParseDate(birthText) catch {
else => {
PrintLine("{} {} {} is not a date", birthText, onText, birthText);
return;
}
};
let on = ParseDate(onText) catch {
else => {
PrintLine("{} {} {} is not a date", birthText, onText, onText);
return;
}
};
match AgeOn(birth, on) {
.Success(age) => PrintLine("{} {} {:5} {:7} {:5}", birth, on,
age.years, age.months, age.days),
.Failure(AgeError::BornLater) => PrintLine("{} {} refused, born after that date",
birth, on),
.Failure(AgeError::OutOfRange) => PrintLine("{} {} outside the calendar", birth, on)
}
}
func Main() -> int {
PrintLine("born on years months days");
// A birthday already past this year, one still to come, and the day itself.
Show("1990-03-15", "2026-10-04");
Show("1990-12-25", "2026-10-04");
Show("2000-10-04", "2026-10-04");
// The end of a month. 31 January reaches a whole month on 28 February, and the next on
// 31 March, so 30 March is one month and 30 days.
Show("2026-01-31", "2026-02-28");
Show("2026-01-31", "2026-03-30");
Show("2026-01-31", "2026-03-31");
// A leap-day birthday falls on 28 February in an ordinary year, and on the 29th in a
// leap year, which makes 28 February 2028 the day before it.
Show("2004-02-29", "2025-02-28");
Show("2004-02-29", "2028-02-28");
Show("2004-02-29", "2028-02-29");
// Dates the other way round, and a date that does not exist.
Show("2026-10-04", "1990-03-15");
Show("2023-02-29", "2026-10-04");
return 0;
}
Besides Io, its Rux.toml lists Time under [Dependencies].
Run it
cd Examples/Projects/Age
rux run
born on years months days
1990-03-15 2026-10-04 36 6 19
1990-12-25 2026-10-04 35 9 9
2000-10-04 2026-10-04 26 0 0
2026-01-31 2026-02-28 0 1 0
2026-01-31 2026-03-30 0 1 30
2026-01-31 2026-03-31 0 2 0
2004-02-29 2025-02-28 21 0 0
2004-02-29 2028-02-28 23 11 30
2004-02-29 2028-02-29 24 0 0
2026-10-04 1990-03-15 refused, born after that date
2023-02-29 2026-10-04 2023-02-29 is not a date
Common mistakes
Date? as a Date.PlusMonths may have no answer, so var reached = birth.PlusMonths(months); makes reached a Date?, and the next line fails: error: argument 1 to 'DaysSince' has type 'Date?', but parameter 'earlier' requires 'Date'. Decide what absence means — here ?? fail AgeError::OutOfRange.Without the
if on.DaysSince(reached) < 0 block the program still compiles, but every birthday not yet reached this year comes out with negative days: 15 March 1990 on 4 October 2026 gives 36 years, 7 months and −11 days.Try it yourself
- Use today's date instead of a fixed one.
DateTime::FromTimestamp(Timestamp::Now(), UtcOffset::Utc()).datefrom the Time package is today in UTC — see Date and time. - Also print the age in total days, with one
DaysSince. - Print how many days remain until the next birthday. (Careful with 29 February.)
- Read the birth date from the input instead of from the source, using Input, and keep asking until
ParseDateaccepts it.
Learn more
- Date —
ParseDate,PlusDaysand friends - Absence to error —
?? fail - Enum — why
AgeErrorneeds no data - Next project: Password