@verifyhash/iso-duration
v0.1.0
Published
Zero-dependency ISO 8601 duration parse, format, calendar-correct date arithmetic, and English humanize for Node.js.
Maintainers
Readme
iso-duration
TODO (owner): pick the final npm name/scope before publishing. The npm package name is not finalized by this project. Convention used here:
package.jsonkeeps the working slugiso-durationas a placeholder — replace it (and thenpm installline below) with the real published name/scope before runningnpm publish.
A tiny, zero-dependency, zero-network Node.js library for ISO 8601
durations — the P3Y6M4DT12H30M5S strings that show up in JSON Schema
(format: duration), OpenAPI, iCalendar (RRULE/DURATION), GitHub Actions
timeouts, Kubernetes manifests, and countless "retry after / cache for / repeat
every" API fields.
It does four things and nothing else:
parse(str)— string → duration objectformat(obj)— duration object → canonical string (round-tripsparse)addToDate/subtractFromDate— calendar-correctDatearithmetichumanize(obj)—"3 hours 30 minutes"
No dependencies, no install step, no I/O. Just index.js (CommonJS) and pure
functions. Drop it into any project.
Who it's for
JavaScript/Node developers who receive or emit ISO 8601 durations from APIs, schemas, or schedules and want to parse, normalize, do date math on, or display them — without pulling in Luxon/moment/date-fns just for the duration bits.
The duration object
Every function speaks the same plain object. All keys are optional; missing
numeric keys are 0, and negative defaults to false:
{ years, months, weeks, days, hours, minutes, seconds, negative }Usage
const iso = require('iso-duration'); // or require('./index.js')
iso.parse('P3Y6M4DT12H30M5S');
// { years: 3, months: 6, weeks: 0, days: 4,
// hours: 12, minutes: 30, seconds: 5, negative: false }
iso.parse('-PT1H30M'); // { ..., hours: 1, minutes: 30, negative: true }
iso.parse('P2W'); // { ..., weeks: 2 }
iso.parse('PT0.5H'); // { ..., hours: 0.5 } (fractional final component)
iso.format({ hours: 3, minutes: 30 }); // 'PT3H30M'
iso.format({}); // 'PT0S'
iso.format({ weeks: 2 }); // 'P2W'
iso.format({ hours: 1, negative: true }); // '-PT1H'
iso.humanize({ hours: 3, minutes: 30 }); // '3 hours 30 minutes'
iso.humanize({ hours: 1 }); // '1 hour'
iso.humanize({}); // '0 seconds'
const start = new Date('2020-01-31T00:00:00Z');
iso.addToDate(start, { months: 1 }); // 2020-02-29T00:00:00.000Z
start.toISOString(); // unchanged — input is never mutated
iso.subtractFromDate(start, { days: 10 }); // 2020-01-21T00:00:00.000ZTwo design choices to know about
Libraries genuinely disagree on these two points, so they are spelled out here rather than left to surprise you.
1. Weeks normalization
ISO 8601 treats W (weeks) as mutually exclusive with the other date fields —
P2W is legal, P1W3D technically is not. This library is lenient on input
(it will parse P1W3D and keep weeks and days separate) but opinionated on
output:
format()emitsP2Wonly when weeks is the sole non-zero component.- In every other case weeks are folded into days at 1 week = 7 days
(e.g.
format({ weeks: 1, days: 1 })→'P8D').
This keeps the canonical string valid ISO and guarantees format round-trips
through parse: format(parse(s)) === s for any canonical s, and repeated
format(parse(...)) is stable. In date arithmetic, weeks are always treated as
7 elapsed days.
2. Month (and year) overflow clamps
Adding months and years is calendar arithmetic, and calendars have ragged month lengths. When the target month is shorter than the source day-of-month, this library clamps to the last day of the target month — the same rule Luxon, Temporal, and date-fns use:
Jan 31 + P1M→ Feb 29 in a leap year, Feb 28 otherwise.Mar 31 − P1M→ Feb 29 / Feb 28 (subtraction clamps the same way).
The arithmetic order is: years/months are applied first as whole calendar
steps (with clamping), then weeks/days/hours/minutes/seconds are added as a flat
elapsed-milliseconds offset. Because the calendar step happens first, results
are deterministic and independent of your machine's timezone (all math is done
in UTC) and the input Date is never mutated — a new Date is returned.
Honest limits. Because day/time components are applied as elapsed
milliseconds, they do not track daylight-saving wall-clock shifts — that is
correct for UTC/absolute instants but means "+1 day" is always exactly 24 hours,
not "same local time tomorrow." Fractional years or months (rare, e.g.
P0.5M) can't be expressed as an exact number of calendar days, so their
fractional part is approximated using the mean Gregorian month (30.436875 days);
integer years/months — the normal case — are always exact.
Errors
parse() throws a descriptive Error on malformed input, including: an empty
string, a missing P, a T separator with no time components ("PT"), a bare
"P", out-of-order fields ("P3M6Y"), and time letters outside the T block
("P5S").
Running the tests
One command, no install, no network:
node test/index.test.jsIt prints one line per test and exits non-zero if any fail. Coverage includes
parse/format round-trips, P2W weeks, PT0S and negatives, fractional seconds,
Jan-31 + 1-month month-overflow arithmetic, the no-mutation guarantee, humanize
pluralization, and malformed-input throw cases.
License
MIT.
Install
Placeholder name: the
iso-durationbelow is the working slug, not a finalized npm name — see the owner TODO near the top of this README.
npm install iso-durationconst iso = require('iso-duration');
iso.parse('P3Y6M4DT12H30M5S'); // { years:3, months:6, days:4, hours:12, minutes:30, seconds:5, ... }
iso.format({ hours: 3, minutes: 30 }); // 'PT3H30M'
iso.humanize({ hours: 1, minutes: 30 }); // '1 hour 30 minutes'