Format a Duration as Hours and Minutes
To turn a raw amount like 95 minutes into "1 hr 35 min" or "1:35", normalize it with Temporal.Duration.round({ largestUnit: 'hours' }), then render it with Intl.DurationFormat. Part of Intl.DurationFormat for Human-Readable Durations.
Why this scenario is tricky
The trap is a two-step problem masquerading as one. People reach straight for a formatter and pass { minutes: 95 } — and get "95 min" back, because Intl.DurationFormat renders exactly the units you give it. It performs no carry. The minutes-into-hours arithmetic is a separate normalization step you must do first.
Doing that carry by hand (Math.floor(95 / 60) and 95 % 60) works for hours and minutes but is fragile: it silently breaks the moment a value is negative, fractional, or you decide to include seconds, and it duplicates logic that Temporal.Duration.round() already does correctly. The robust pattern is: build a Temporal.Duration, round({ largestUnit: 'hours' }) to perform the carry, then hand the normalized duration to a cached formatter.
The normalization-then-format pipeline
Minimal working solution
import { Temporal } from '@js-temporal/polyfill';
// Carry minutes into hours, then format. 'hours' is the largest unit we want shown.
function hoursMinutes(totalMinutes: number, style: 'short' | 'digital' = 'short'): string {
const normalized = Temporal.Duration
.from({ minutes: totalMinutes })
.round({ largestUnit: 'hours' }); // 95 -> { hours: 1, minutes: 35 }
// 'short' -> '1 hr, 35 min'; 'digital' -> '1:35'.
return new Intl.DurationFormat('en-US', { style }).format(normalized);
}
hoursMinutes(95); // '1 hr, 35 min'
hoursMinutes(95, 'digital'); // '1:35'
Intl.DurationFormat is TC39 Stage 3, so on older runtimes guard for it and lazy-load the @formatjs/intl-durationformat polyfill before the first call — see the parent guide for the ensureDurationFormat() helper.
Full production version
import { Temporal } from '@js-temporal/polyfill';
type HmStyle = 'short' | 'long' | 'narrow' | 'digital';
const hmCache = new Map<string, Intl.DurationFormat>();
function hmFormatter(locale: string, style: HmStyle): Intl.DurationFormat {
const key = `${locale}|${style}`;
let f = hmCache.get(key);
if (!f) {
f = new Intl.DurationFormat(locale, {
style,
// For non-digital styles, drop a zero minutes so 120 min reads '2 hr', not '2 hr, 0 min'.
minutesDisplay: style === 'digital' ? 'always' : 'auto',
});
hmCache.set(key, f);
}
return f;
}
export function formatHoursMinutes(
totalMinutes: number,
opts: { locale?: string; style?: HmStyle } = {},
): string {
if (!Number.isFinite(totalMinutes)) {
throw new TypeError('totalMinutes must be a finite number'); // guard NaN/Infinity early
}
const { locale = 'en-US', style = 'short' } = opts;
// Round to whole minutes first so fractional inputs (e.g. 95.4) do not leak a fractional unit,
// then carry into hours. A negative input keeps its sign and formats with a locale minus sign.
const normalized = Temporal.Duration
.from({ minutes: Math.trunc(totalMinutes) })
.round({ largestUnit: 'hours', smallestUnit: 'minutes' });
return hmFormatter(locale, style).format(normalized);
}
formatHoursMinutes(95); // '1 hr, 35 min'
formatHoursMinutes(120); // '2 hr' (zero minutes dropped)
formatHoursMinutes(95, { style: 'digital' }); // '1:35'
formatHoursMinutes(-95); // '-1 hr, 35 min'
Verification snippet
import { Temporal } from '@js-temporal/polyfill';
// Confirms the carry happens BEFORE formatting and that edge values behave.
const round = (m: number) =>
Temporal.Duration.from({ minutes: m }).round({ largestUnit: 'hours' });
// 95 minutes carries to exactly 1h 35m.
console.assert(round(95).hours === 1 && round(95).minutes === 35, 'carry 95 -> 1h35m');
// Exact hour leaves zero minutes (formatter then drops it with minutesDisplay:'auto').
console.assert(round(120).hours === 2 && round(120).minutes === 0, 'exact 120 -> 2h0m');
// Under an hour stays in minutes.
console.assert(round(35).hours === 0 && round(35).minutes === 35, 'under-hour 35 -> 0h35m');
// Negative durations preserve magnitude and carry the same way.
console.assert(round(-95).hours === -1 && round(-95).minutes === -35, 'negative -95');
// The string itself: digital style yields a clock.
console.assert(
new Intl.DurationFormat('en-US', { style: 'digital' }).format(round(95)) === '1:35:00',
'digital style renders clock',
);
Common pitfalls
- Skipping normalization.
new Intl.DurationFormat('en-US').format({ minutes: 95 })returns'95 min', not'1 hr 35 min'.
// Wrong: no carry
new Intl.DurationFormat('en-US', { style: 'short' }).format({ minutes: 95 }); // '95 min'
// Right: round first
new Intl.DurationFormat('en-US', { style: 'short' })
.format(Temporal.Duration.from({ minutes: 95 }).round({ largestUnit: 'hours' })); // '1 hr, 35 min'
- Hand-rolling the carry.
Math.floor(95/60)works until the value is fractional or negative. Letround()own it. - Hard-coding
"hr"/"min"labels. That is English-only; letstyle+ locale produce labels and the correct list conjunction. - Re-creating the formatter per call. Construction is expensive; cache per
locale+style.
FAQ
Why does { minutes: 95 } print "95 min" instead of "1 hr 35 min"?
Intl.DurationFormat never carries units — it renders what you pass. Normalize with Temporal.Duration.from({ minutes: 95 }).round({ largestUnit: 'hours' }) to get { hours: 1, minutes: 35 } before formatting.
How do I get the colon form "1:35" instead of "1 hr 35 min"?
Use style: 'digital'. Note the digital style always shows seconds, so a normalized 1h 35m renders as '1:35:00'; pass seconds: 0 is implicit, and the clock units stay visible by design.