Most Saudi payroll systems model leave as one number. An employee has a balance, they take days, the balance goes down. That model is correct for annual leave and it is wrong for everything else.
Articles 113, 114, 115 and 160 of the Saudi Labour Law create a second category of leave that behaves nothing like a balance. It is not accrued. It is not carried over. It cannot be refused on the grounds that the worker has annual days available. It arrives attached to an event — a marriage, a birth, a death, a Hajj season, an exam — and in several cases it expires if the worker does not claim it inside a window measured in days.
Get this wrong and the failure is silent. Nobody notices a leave type that was never granted. The employee notices roughly a year later, in front of a labour committee, with a claim.
This tutorial builds the engine that gets it right. Every rule below is traced to an article number, every branch is covered by a test, and the test suite runs green at the end.
Prerequisites
- Node.js 22+ (the examples use
node --experimental-strip-types, so no build step) - TypeScript familiarity — discriminated unions and exhaustive
switchhandling - A working understanding of Saudi payroll concepts (wage base, payslip lines)
- No database required; the engine is pure functions over dates
What You'll Build
A single awardSpecialLeave function that takes an employment record and a leave event, and
returns an award: how many days are paid, how many are unpaid, when the leave starts and ends,
whether it touches the annual balance, and — critically — a refusal reason when the entitlement
does not arise.
The six events it handles:
| Event | Article | Entitlement |
|---|---|---|
| Marriage | 113 | 5 days, full pay |
| Birth of a child | 113 | 3 days, full pay, claimed within 7 days of the birth |
| Bereavement | 113 | 5 days for spouse, ascendant or descendant; 3 days for a sibling |
| Hajj | 114 | 10 to 15 days, once in the whole service, after 2 continuous years |
| Exam | 115 | Actual exam days, paid for a first attempt, unpaid for a repeated year |
| Iddah (widow) | 160 | 4 months and 10 days for a Muslim worker; 15 days otherwise |
Step 1: Model the event, not the balance
The first design decision is the one that saves you later. Do not add these to the annual leave
table with a type column. They are a different shape: they have no opening balance, no accrual
rate, and no carry-over. Model them as events that produce awards.
// src/special-leave/types.ts
export type Religion = 'muslim' | 'non-muslim';
export type Relation = 'spouse' | 'ascendant' | 'descendant' | 'sibling';
export type Employment = {
hiredOn: string;
religion: Religion;
performedHajjBefore: boolean;
hajjLeaveUsedInService: boolean;
enrolmentApproved: boolean;
};
export type Event =
| { kind: 'marriage'; on: string; requestedStart?: string }
| { kind: 'newborn'; on: string; requestedStart?: string }
| { kind: 'bereavement'; on: string; relation: Relation }
| { kind: 'iddah'; on: string; pregnant?: boolean; deliveryOn?: string }
| { kind: 'hajj'; on: string; requestedDays?: number }
| { kind: 'exam'; on: string; examDays: number; repeatYear: boolean };
export type Award = {
article: string;
paidDays: number;
unpaidDays: number;
startsOn: string | null;
endsOn: string | null;
deductsFromAnnual: boolean;
notes: string[];
refused?: string;
};Note deductsFromAnnual on the award rather than as a constant. It is always false under these
articles, and stating it explicitly on every award is what stops a later refactor from quietly
netting these days off the annual balance — the single most common defect in this area.
The rule people get wrong: an employer cannot refuse Article 113 leave because the worker still has annual days in hand. The entitlement is independent of the annual balance. This comes up constantly in Saudi HR discussions, and it is settled — the article grants the leave outright.
Step 2: Date arithmetic that survives month ends
Every rule in this engine is a date rule, so the date helpers are the engine. Use UTC throughout;
a local-timezone Date will shift a leave boundary by a day for anyone running the payroll from a
machine set to a different offset.
// src/special-leave/dates.ts
const MS_DAY = 86_400_000;
export const parseDate = (iso: string) => {
const [y, m, d] = iso.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, d));
};
export const toISO = (d: Date) => d.toISOString().slice(0, 10);
export const addDays = (iso: string, n: number) =>
toISO(new Date(parseDate(iso).getTime() + n * MS_DAY));
export const daysBetween = (a: string, b: string) =>
Math.round((parseDate(b).getTime() - parseDate(a).getTime()) / MS_DAY);
export function addMonths(iso: string, n: number): string {
const d = parseDate(iso);
const day = d.getUTCDate();
const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + n, 1));
const last = new Date(Date.UTC(t.getUTCFullYear(), t.getUTCMonth() + 1, 0)).getUTCDate();
t.setUTCDate(Math.min(day, last));
return toISO(t);
}
export const overlapDays = (aS: string, aE: string, bS: string, bE: string) => {
const s = Math.max(parseDate(aS).getTime(), parseDate(bS).getTime());
const e = Math.min(parseDate(aE).getTime(), parseDate(bE).getTime());
return e < s ? 0 : Math.round((e - s) / MS_DAY) + 1;
};addMonths clamps: adding one month to 31 January yields 28 February, not 3 March. That clamp is
not decoration. It is load-bearing in Step 5.
Step 3: The constants, each tied to its article
// src/special-leave/constants.ts
import type { Relation } from './types';
export const BEREAVEMENT_DAYS: Record<Relation, number> = {
spouse: 5, // Art. 113
ascendant: 5, // parents, grandparents
descendant: 5, // children, grandchildren
sibling: 3, // brother or sister
};
export const MARRIAGE_DAYS = 5; // Art. 113
export const NEWBORN_DAYS = 3; // Art. 113
export const NEWBORN_CLAIM_WINDOW_DAYS = 7; // Art. 113 — from the date of birth
export const IDDAH_MONTHS = 4; // Art. 160
export const IDDAH_EXTRA_DAYS = 10; // Art. 160
export const IDDAH_NON_MUSLIM_DAYS = 15; // Art. 160
export const HAJJ_MIN_DAYS = 10; // Art. 114
export const HAJJ_MAX_DAYS = 15; // Art. 114
export const HAJJ_MIN_TENURE_YEARS = 2; // Art. 114The five-versus-three split is worth pausing on, because the SERP disagrees with itself about it. Article 113 grants five days for the death of a spouse, an ascendant or a descendant. A sibling carries three. Several widely-read Saudi HR pages quote only the five-day figure and let readers assume it covers every relative — which overpays for a sibling and, worse, teaches HR a rule that breaks the moment someone checks it.
Step 4: Article 113 — the three event leaves and their windows
// src/special-leave/engine.ts
const refuse = (article: string, why: string): Award => ({
article, paidDays: 0, unpaidDays: 0, startsOn: null, endsOn: null,
deductsFromAnnual: false, notes: [], refused: why,
});
const span = (start: string, days: number) => ({ start, end: addDays(start, days - 1) });
// --- marriage ---
case 'marriage': {
const { start, end } = span(ev.requestedStart ?? ev.on, MARRIAGE_DAYS);
return {
article: '113', paidDays: MARRIAGE_DAYS, unpaidDays: 0,
startsOn: start, endsOn: end, deductsFromAnnual: false,
notes: ['Runs from the marriage date, not the date of the contract (aqd qiran).'],
};
}
// --- newborn ---
case 'newborn': {
const start = ev.requestedStart ?? ev.on;
const elapsed = daysBetween(ev.on, start);
if (elapsed < 0) return refuse('113', 'Leave cannot start before the birth.');
if (elapsed >= NEWBORN_CLAIM_WINDOW_DAYS)
return refuse('113', `Claim window of ${NEWBORN_CLAIM_WINDOW_DAYS} days from birth has expired.`);
const { end } = span(start, NEWBORN_DAYS);
return {
article: '113', paidDays: NEWBORN_DAYS, unpaidDays: 0,
startsOn: start, endsOn: end, deductsFromAnnual: false,
notes: [`Started on day ${elapsed} of the 7-day window.`],
};
}
// --- bereavement ---
case 'bereavement': {
const days = BEREAVEMENT_DAYS[ev.relation];
const { start, end } = span(ev.on, days);
return {
article: '113', paidDays: days, unpaidDays: 0,
startsOn: start, endsOn: end, deductsFromAnnual: false,
notes: [`Relation '${ev.relation}' carries ${days} days.`],
};
}Two traps live in this block.
The newborn window closes. The three days attach to the birth and must be taken inside seven
days of it. A father who requests the leave two weeks later has no entitlement left to claim. A
system that grants it anyway is not being generous — it is producing a payslip that does not
reconcile to the statute, and the reverse case (a system that silently drops the request without
telling anyone why) is how the entitlement gets lost in the first place. That is why refuse
carries a reason string instead of returning zero days.
Marriage leave runs from the marriage, not the engagement. The distinction between the contract date and the marriage date is a live argument in Saudi HR practice, and the engine should record which date it used rather than leave it implicit.
Step 5: Article 160 — the iddah period is not 130 days
This is the rule most implementations hardcode, and hardcoding it is wrong.
Article 160 gives a Muslim worker whose husband dies leave at full pay of not less than four months and ten days. A non-Muslim worker in the same circumstance receives fifteen days. If she is pregnant, she may extend the leave without pay until she delivers.
"Four months and ten days" is calendar arithmetic. It is not a fixed count, because calendar months are not the same length.
case 'iddah': {
if (emp.religion === 'non-muslim') {
const { start, end } = span(ev.on, IDDAH_NON_MUSLIM_DAYS);
return {
article: '160', paidDays: IDDAH_NON_MUSLIM_DAYS, unpaidDays: 0,
startsOn: start, endsOn: end, deductsFromAnnual: false,
notes: ['Non-Muslim widow: 15 days at full pay.'],
};
}
const end = addDays(addMonths(ev.on, IDDAH_MONTHS), IDDAH_EXTRA_DAYS);
const paid = daysBetween(ev.on, end);
const notes = [`Four calendar months plus ten days resolved to ${paid} days.`];
let unpaid = 0;
if (ev.pregnant && ev.deliveryOn && daysBetween(end, ev.deliveryOn) > 0) {
unpaid = daysBetween(end, ev.deliveryOn);
notes.push(`Pregnant: unpaid extension of ${unpaid} days to delivery.`);
}
return {
article: '160', paidDays: paid, unpaidDays: unpaid,
startsOn: ev.on, endsOn: unpaid ? ev.deliveryOn! : end,
deductsFromAnnual: false, notes,
};
}Run it from two different dates in the same year and the engine returns two different answers:
iddah from 2026-02-01 = 130 days
iddah from 2026-10-01 = 133 days
A bereavement in February resolves to 130 days. The same bereavement in October resolves to 133,
because the four months it spans are longer ones. Every system that stores 130 as a constant
underpays an October widow by three days at full pay — and does it quietly, forever, to the
employee least likely to be auditing her own payslip.
Note the floor. The article says not less than four months and ten days. The computed value is a minimum, not a cap. An employer policy that grants more is lawful; one that grants 130 flat is not.
Step 6: Article 114 — Hajj leave must not pay Eid twice
Hajj leave carries the most preconditions of any leave in the statute, and one arithmetic trap that costs real money.
The entitlement is ten to fifteen days at full pay, including the Eid al-Adha holiday, once during the entire period of service, and only if the worker has not performed Hajj before. It requires at least two continuous years with the same employer, and the employer may cap how many workers take it in a given year according to operational need.
case 'hajj': {
const tenureDays = daysBetween(emp.hiredOn, ev.on);
if (tenureDays < HAJJ_MIN_TENURE_YEARS * 365)
return refuse('114', 'Requires two continuous years with the same employer.');
if (emp.performedHajjBefore)
return refuse('114', 'Worker has already performed Hajj.');
if (emp.hajjLeaveUsedInService)
return refuse('114', 'Hajj leave is once for the whole service.');
const granted = Math.min(
HAJJ_MAX_DAYS,
Math.max(HAJJ_MIN_DAYS, ev.requestedDays ?? HAJJ_MIN_DAYS),
);
const { start, end } = span(ev.on, granted);
const eid = holidays
.filter((h) => h.name === 'eid-al-adha')
.reduce((n, h) => n + overlapDays(start, end, h.start, addDays(h.start, h.days - 1)), 0);
const notes = [`Granted ${granted} days, of which ${eid} fall inside the Eid al-Adha holiday.`];
if (eid) notes.push('Eid days are already paid as a public holiday and are not paid twice.');
return {
article: '114', paidDays: granted - eid, unpaidDays: 0,
startsOn: start, endsOn: end, deductsFromAnnual: false, notes,
};
}The phrase "including the Eid al-Adha holiday" means the Eid days sit inside the grant. They are already paid to every employee as a public holiday. A grant of fifteen days that overlaps a four-day Eid produces eleven days of incremental cost, not fifteen. Systems that treat the two as separate lines overstate the leave provision by up to four days per pilgrim.
Eid dates are data, not a formula. Eid al-Adha is fixed by moon sighting and announced each year. Do not compute it. Inject it:
export type Holiday = { name: string; start: string; days: number };
// Replace with the officially announced dates each year — these are astronomical
// estimates and the announced dates routinely differ by a day.
export const HOLIDAYS_2026: Holiday[] = [
{ name: 'eid-al-adha', start: '2026-05-27', days: 4 },
];This is the same lesson the probation-period engine learned: any date the state announces rather than derives belongs in a table that a human updates once a year, with the estimate labelled as an estimate. A formula that is right four years out of five is worse than a table, because nobody checks a formula.
Step 7: Article 115 — the exam leave that flips to unpaid
Article 115 is conditional in a way the others are not. If the employer approved the worker's enrolment in an educational institution, the worker gets full pay for the actual exam days of a first attempt. For a repeated year, the leave is still owed — but without pay.
case 'exam': {
if (!emp.enrolmentApproved)
return refuse('115', 'Employer never approved the enrolment, so no entitlement arises.');
const { start, end } = span(ev.on, ev.examDays);
return ev.repeatYear
? {
article: '115', paidDays: 0, unpaidDays: ev.examDays,
startsOn: start, endsOn: end, deductsFromAnnual: false,
notes: ['Repeated year: leave is owed, but unpaid.'],
}
: {
article: '115', paidDays: ev.examDays, unpaidDays: 0,
startsOn: start, endsOn: end, deductsFromAnnual: false,
notes: ['Non-repeated year: actual exam days at full pay.'],
};
}"Actual exam days" is literal. It is not the length of the exam period; it is the days on which the worker sits an exam. A three-week exam session with four papers is four days, not twenty-one.
Step 8: Assemble and expose the refusal
Wire the branches into one exhaustive function:
export function awardSpecialLeave(
emp: Employment,
ev: Event,
holidays: Holiday[] = [],
): Award {
switch (ev.kind) {
// ... the six cases from Steps 4 to 7
}
}Because Event is a discriminated union and every branch returns, TypeScript will fail the build
if a seventh leave type is added and left unhandled. That is the property you want: the compiler,
not a code reviewer, catches the statute changing.
The refused field matters as much as the paid days. An HR screen that shows "0 days" teaches
nobody anything. One that shows "Claim window of 7 days from birth has expired" tells the manager
what happened, tells the employee why, and gives you an audit trail when the claim arrives.
Testing Your Implementation
Thirteen tests cover every branch and every trap. This suite runs green:
import assert from 'node:assert/strict';
import { awardSpecialLeave, addMonths, type Employment } from './engine.ts';
const emp: Employment = {
hiredOn: '2022-01-10',
religion: 'muslim',
performedHajjBefore: false,
hajjLeaveUsedInService: false,
enrolmentApproved: true,
};
// Art. 113 — marriage never touches the annual balance
const marriage = awardSpecialLeave(emp, { kind: 'marriage', on: '2026-03-01' });
assert.equal(marriage.paidDays, 5);
assert.equal(marriage.endsOn, '2026-03-05');
assert.equal(marriage.deductsFromAnnual, false);
// Art. 113 — the newborn window closes on day 7
const late = awardSpecialLeave(emp, {
kind: 'newborn', on: '2026-03-01', requestedStart: '2026-03-08',
});
assert.equal(late.paidDays, 0);
assert.match(late.refused!, /window/);
// Art. 113 — five days for an ascendant, three for a sibling
assert.equal(awardSpecialLeave(emp,
{ kind: 'bereavement', on: '2026-03-01', relation: 'ascendant' }).paidDays, 5);
assert.equal(awardSpecialLeave(emp,
{ kind: 'bereavement', on: '2026-03-01', relation: 'sibling' }).paidDays, 3);
// Art. 160 — the iddah period is calendar arithmetic, not a constant
const feb = awardSpecialLeave(emp, { kind: 'iddah', on: '2026-02-01' });
const oct = awardSpecialLeave(emp, { kind: 'iddah', on: '2026-10-01' });
assert.equal(feb.paidDays, 130);
assert.equal(oct.paidDays, 133);
// Art. 114 — fifteen granted days over a four-day Eid cost eleven
const hajj = awardSpecialLeave(emp,
{ kind: 'hajj', on: '2026-05-25', requestedDays: 15 },
[{ name: 'eid-al-adha', start: '2026-05-27', days: 4 }]);
assert.equal(hajj.paidDays, 11);
// Art. 115 — a repeated year is owed, but unpaid
const repeat = awardSpecialLeave(emp,
{ kind: 'exam', on: '2026-06-01', examDays: 4, repeatYear: true });
assert.equal(repeat.paidDays, 0);
assert.equal(repeat.unpaidDays, 4);
// month-end clamp
assert.equal(addMonths('2026-01-31', 1), '2026-02-28');Run with node --experimental-strip-types test.ts. The two assertions worth keeping forever are
the iddah pair and the Hajj overlap — they are the two that a well-meaning refactor breaks first.
Troubleshooting
The iddah period comes back one day short. You built the end date with local time instead of
UTC, and a machine west of Riyadh rolled the boundary back. Every date in this engine must go
through parseDate.
Hajj leave is refused for a long-serving employee. The tenure check uses 2 * 365 days, which
ignores leap years. That is deliberately conservative — it refuses a borderline case rather than
granting one that fails audit. If your policy is to count calendar years, swap the check for
daysBetween(emp.hiredOn, addMonths(ev.on, -24)) >= 0.
Special leave is showing up as a deduction from annual balance. Something downstream is reading
paidDays and netting it off. The deductsFromAnnual flag exists precisely so the consumer can
assert on it — check it in your payroll posting step, not just in the engine.
A leave type returns undefined. You added a variant to Event and did not add a case.
Turn on noImplicitReturns and the compiler will catch it before the payroll run does.
Next Steps
- Pair this with the accrual side: the annual leave engine for Articles 109 to 111 handles the balance these leaves must never touch.
- Sick leave runs on its own ladder and its own rolling year — see the Article 117 engine.
- Maternity leave under Article 151 is twelve weeks, and since July 2025 GOSI pays it on the employer's behalf. The rules, and who reimburses whom, are in the Article 151 guide.
- Check any of these against the free leave calculator before you ship.
Conclusion
Special leave is a small part of a payroll system that generates a disproportionate share of its labour claims, because every rule in it is a rule about dates and every defect in it is invisible on the payslip. The engine above is roughly two hundred lines. It encodes six articles, four preconditions, two expiring claim windows, and one piece of calendar arithmetic that most implementations replace with a constant and get wrong three days at a time.
The pattern that makes it maintainable is not clever. It is a discriminated union that the compiler can check, constants annotated with the article they come from, a refusal reason on every rejection, and a table for the dates the state announces rather than derives.
If your HR system already computes annual leave correctly and still handles marriage, bereavement and Hajj as manual approvals in a spreadsheet, that gap is where the claims come from. We connect HR systems to payroll so entitlements are computed from the statute rather than remembered by a manager — tell us what you are running and we will tell you what it is missing.