Describing Cron Expressions: Why Templates Don't Work, and What Does
A zero-dependency TypeScript CLI that turns
0 */4 * * 1-5intoAt 00:00, every 4 hours, Monday through Friday. The interesting part is not the parser. It's how the describer composes phrases instead of pattern-matching templates.
📦 GitHub: https://github.com/sen-ltd/cron-describe
Every backend engineer has stared at a line like 0 */4 * * 1-5 and mentally decoded it. The five fields are simple on their own, but put them in the wrong order once and you have a 4 AM page on a Saturday. There is a classic tool for this problem — crontab.guru — and a near-dormant npm library, cron-descriptor, originally ported from a C# project by Brady Holt. If all you want is to paste a string and read a sentence, they both work. What I could not find was a small, scriptable, zero-dependency CLI that I could alias as cd "$CRON" in my shell and run in nightly deploy checks.
So I built cron-describe, a TypeScript + Node CLI. The whole thing is about 650 lines of source code, no runtime dependencies, and ships as a 136 MB node:20-alpine image. But the code is not the interesting part of the story. The interesting part is what I learned when I started writing the describer and realised that the obvious approach — a lookup table of sentence templates — does not scale past the 30 or 40 most common patterns.
The problem
A cron expression has five fields: minute, hour, day-of-month, month, day-of-week. Each field can be a wildcard (*), a single number, a range (9-17), a step (*/5, or 0-30/10), or a comma-separated list of any of the above. Day-of-month also accepts L for last day of the month. Month and day-of-week also accept names (Jan, Mon).
That's a large enough vocabulary that the number of distinct expressions is effectively unbounded. But there's a quirk: day-of-month and day-of-week are OR-joined when both are restricted. 0 0 15 * MON does not mean "at midnight on the 15th if the 15th is a Monday" — it means "at midnight on the 15th OR on any Monday". The POSIX spec is clear about this, and every real cron implementation I've checked follows it, but most description libraries get it subtly wrong or silently print something that reads as AND.
So what the describer needs to do is:
- Parse the expression into a typed AST.
- Walk the AST and produce a short English sentence.
- Do that in Japanese too, as a sanity check that the design is actually locale-agnostic.
- Expose the OR quirk visibly.
- Not blow up on any of the long-tail patterns.
The template trap
The first approach I tried was the obvious one: a list of sentence templates, each gated by a predicate on the AST.
const TEMPLATES = [
{ match: (a) => a.minute.isWildcard && a.hour.isWildcard, text: 'Every minute' },
{ match: (a) => a.minute.isStep(5) && a.hour.isWildcard, text: 'Every 5 minutes' },
{ match: (a) => a.minute.isFixed() && a.hour.isFixed(), text: (a) => `At ${a.hour}:${a.minute}` },
// ...
];
This works great for the first ten entries. Then you add:
-
0 */4 * * 1-5— every four hours on weekdays -
30 9-17 * * 1-5— at :30 past every hour, from 9 to 17, on weekdays -
0 0 1,15 * *— at midnight on the 1st and 15th
And you realise that every time a new combination shows up, you either need another template or you need to generalise one of the existing ones. The templates get more and more conditional until they read like case statements inside format strings. That's the template trap: pattern-matching something with a combinatorial surface area using a finite list of patterns.
The real problem is that the templates are trying to describe the whole expression at once, when what's actually going on is that each field has its own small story and the sentence is the concatenation of those stories.
The phrase-builder approach
Here is the contract I settled on. The describer produces four sub-phrases:
interface DescribeResult {
description: string;
parts: {
time: string; // "At 09:00" or "Every 5 minutes" or ""
dayOfMonth: string; // "on the 1st of the month" or "" if wildcard
month: string; // "in January" or "" if wildcard
dayOfWeek: string; // "Monday through Friday" or "" if wildcard
};
}
Each sub-phrase is allowed to be empty. The top-level description is a locale-aware join of the non-empty parts. That's the whole trick. The describer never looks at "which combined template does this expression match?" It looks at each field on its own and produces something to say about it.
Here is the top-level function:
export function describe(ast: CronAst, locale: Locale): DescribeResult {
const time = describeTime(ast, locale);
const dom = describeDayOfMonth(ast.fields.dayOfMonth, locale);
const month = describeMonth(ast.fields.month, locale);
const dow = describeDayOfWeek(ast.fields.dayOfWeek, locale);
// OR semantics: dom and dow are OR-joined in cron when both restricted.
let combined = { time, dayOfMonth: dom, month, dayOfWeek: dow };
if (dom.length > 0 && dow.length > 0) {
combined = {
time,
dayOfMonth: '',
month,
dayOfWeek: locale.id === 'ja' ? `${dom} または ${dow}` : `${dom} or ${dow}`,
};
}
return {
description: locale.join([combined.time, combined.dayOfMonth, combined.month, combined.dayOfWeek]),
parts: combined,
valid: true,
};
}
Notice that the OR handling is two lines. In the template approach this would have been a whole new family of templates — "on the Xth or Y" for every combination of (dom-shape, dow-shape).
The parser, for completeness
The parser is boring but I want to show one slice of it because the shape of the AST drives the describer:
export type FieldPart =
| { type: 'wildcard' }
| { type: 'number'; value: number }
| { type: 'range'; start: number; end: number }
| { type: 'step'; start: number | '*'; end: number | null; step: number }
| { type: 'lastDayOfMonth' };
function parsePart(kind: FieldKind, token: string): FieldPart {
if (token === '*') return { type: 'wildcard' };
if (kind === 'dayOfMonth' && token.toUpperCase() === 'L') {
return { type: 'lastDayOfMonth' };
}
const slashIdx = token.indexOf('/');
if (slashIdx !== -1) {
const base = token.slice(0, slashIdx);
const step = parseInt(token.slice(slashIdx + 1), 10);
if (base === '*' || base === '') return { type: 'step', start: '*', end: null, step };
const dashIdx = base.indexOf('-');
if (dashIdx !== -1) {
const { start, end } = parseRange(kind, base.slice(0, dashIdx), base.slice(dashIdx + 1));
return { type: 'step', start, end, step };
}
return { type: 'step', start: parseNumber(kind, base), end: null, step };
}
const dashIdx = token.indexOf('-');
if (dashIdx !== -1) {
const { start, end } = parseRange(kind, token.slice(0, dashIdx), token.slice(dashIdx + 1));
return { type: 'range', start, end };
}
return { type: 'number', value: parseNumber(kind, token) };
}
A few small decisions pay off later. dow=7 is collapsed to 0 inside parseNumber, so the describer never sees 7. Month and day-of-week names are resolved inside parseNumber via a lookup table, so Jan and 1 produce identical ASTs. By the time the describer runs, the AST is fully normalised — the describer never has to worry about whether the user typed Mon or 1.
The locale interface
The Locale interface is intentionally small:
export interface Locale {
readonly id: 'en' | 'ja';
timeOfDay(hour: number, minute: number, second?: number): string;
everyUnit(unit: 'second'|'minute'|'hour'|'day'|'month', n: number): string;
atMinute(minute: number): string;
monthName(n: number): string;
dowName(n: number): string;
monthRange(start: number, end: number): string;
dowRange(start: number, end: number): string;
dayOfMonthList(values: number[]): string;
lastDayOfMonth(): string;
inMonth(phrase: string): string;
onDow(phrase: string): string;
join(parts: string[]): string;
ordinal(n: number): string;
list(items: string[]): string;
}
The describer makes decisions about what to say. The locale decides how to say it. If you want to add French, you implement fifteen tiny methods and you're done; you don't touch the describer.
Japanese is the interesting stress test because word order is different. In English the natural order is <time>, <dom>, <month>, <dow>. In Japanese it's <month><dow><dom><time>. The describer hands its four parts to locale.join() in a canonical order (time, dom, month, dow), and the Japanese locale reorders them before joining with の. That's the full extent of what Japanese needs.
I was genuinely surprised this worked on the first try. I had expected to need some deeper restructuring — maybe a parallel "phrase plan" data structure that the locale could query — but it turns out that reordering at the join step is enough for English vs Japanese. I would not bet on it holding for every language (Arabic word order would probably push back), but it held for the two I wanted.
Step + range: the case that templates can't handle
My favourite concrete example of why templates fail is 0 */4 * * 1-5. Three fields are doing work: minute is fixed at 0, hour is a step of 4, day-of-week is a range from Monday to Friday. The phrase builder handles this as three independent decisions:
-
Time phrase: hour is a step with fixed minute, so emit
At 00:00, every 4 hours. - Day-of-month phrase: wildcard, emit nothing.
- Month phrase: wildcard, emit nothing.
-
Day-of-week phrase: range, emit
Monday through Friday.
Locale joins non-empty parts: At 00:00, every 4 hours, Monday through Friday. Done.
A template-based approach would need either (a) a template that specifically matches "fixed minute, hour step, dom wildcard, month wildcard, dow range" — which is one of something like 60 plausible shapes — or (b) a template composition system, which at that point is what I built, just less honestly.
Tradeoffs
A few honest limitations I decided not to fix:
-
No next-fire time. The question "when does this cron next run?" is a harder problem — you have to roll the calendar forward, handle month-ends, handle DST, and handle the dom+dow OR. I wrote a separate Python service for that (
cron-next-api) because the data structures it needs are quite different from the ones the describer needs. -
No Quartz year field. I accept the optional seconds field with
--seconds, because Jenkins and some CI systems expose that form, but I do not accept Quartz's optional trailing year field. That would need to propagate through the describer and add zero value for the use cases I care about. - Timezone-unaware. Cron is always local time on the machine it runs on. If you're describing a cron to someone in another timezone, the hour number is still the number they'd see in their crontab, not a converted one.
Try it in 30 seconds
git clone https://github.com/sen-ltd/cron-describe
cd cron-describe
docker build -t cron-describe .
docker run --rm cron-describe "0 */4 * * 1-5"
# At 00:00, every 4 hours, Monday through Friday
docker run --rm cron-describe "0 9 * * 1-5" --locale ja
# 毎週月〜金曜日の9時
docker run --rm cron-describe "0 9 * * 1-5" --format json | jq .description
# "At 09:00, Monday through Friday"
The whole project is about 650 lines of strict TypeScript (parser + describer + two locales + formatters + CLI) and 77 vitest tests covering parser field forms, describer fixtures, Japanese locale output, CLI arg parsing, and JSON shape. The single-commit build runs in under a minute.
The takeaway
When you are generating natural language from structured input, resist the template instinct. Templates feel cheap on day one and get exponentially more expensive as the input vocabulary grows. A phrase-builder that produces sub-phrases per field and joins them at the end scales linearly in the number of fields, and locale support falls out almost for free.
I'm going to alias cron-describe in my shell right now and stop copy-pasting cron expressions into crontab.guru.
— Built by SEN 合同会社. We write small, honest tools and long posts about how they work.

Top comments (0)