Building a Payroll Calculation Engine in TypeScript
A comprehensive guide to designing and implementing a robust payroll calculation engine in TypeScript, covering gross-to-net computation, deduction pipelines, and extensible rule evaluation.
Payroll calculation is one of the most mission-critical operations in any organization. Getting it wrong means employees are paid incorrectly, tax filings are inaccurate, and regulatory penalties follow. Building a payroll calculation engine demands precision, auditability, and the ability to accommodate an ever-changing landscape of tax rules, benefit deductions, and statutory requirements.
In this article, we walk through the architecture of a payroll calculation engine built in TypeScript. We cover the core gross-to-net pipeline, the deduction and contribution framework, how to model calculation rules as composable functions, and the strategies that keep the engine maintainable as complexity grows.
The Gross-to-Net Pipeline
At its core, every payroll calculation follows the same pattern: start with gross compensation, apply a series of deductions and contributions, and arrive at net pay. The challenge lies in the ordering, interdependency, and conditional application of those deductions.
interface PayrollInput {
employeeId: string;
payPeriod: PayPeriod;
baseSalary: number;
overtime: OvertimeEntry[];
bonuses: BonusEntry[];
allowances: AllowanceEntry[];
deductionOverrides?: DeductionOverride[];
}
interface PayPeriod {
startDate: Date;
endDate: Date;
frequency: "weekly" | "biweekly" | "semi_monthly" | "monthly";
year: number;
periodNumber: number;
}
interface PayrollResult {
employeeId: string;
payPeriod: PayPeriod;
grossPay: number;
deductions: DeductionLineItem[];
contributions: ContributionLineItem[];
netPay: number;
calculatedAt: Date;
calculationVersion: string;
}
interface DeductionLineItem {
code: string;
name: string;
amount: number;
category: "tax" | "benefit" | "statutory" | "voluntary";
preTax: boolean;
}
interface ContributionLineItem {
code: string;
name: string;
employeeAmount: number;
employerAmount: number;
category: "retirement" | "insurance" | "statutory";
}The PayrollResult is the central output artifact. Every line item is recorded individually so that pay stubs, tax reports, and audit trails can reference the exact breakdown of each calculation.
A practical tip: always include a calculationVersion field in your results. When calculation rules change mid-year, you need to know which version of the engine produced each historical result.
class GrossPayCalculator {
calculate(input: PayrollInput): number {
const periodicBase = this.annualizeAndDivide(
input.baseSalary,
input.payPeriod.frequency
);
const overtimePay = input.overtime.reduce((sum, entry) => {
const hourlyRate = input.baseSalary / this.annualHours(input.payPeriod.frequency);
return sum + entry.hours * hourlyRate * entry.multiplier;
}, 0);
const bonusPay = input.bonuses.reduce((sum, b) => sum + b.amount, 0);
const allowancePay = input.allowances.reduce((sum, a) => sum + a.amount, 0);
return periodicBase + overtimePay + bonusPay + allowancePay;
}
private annualizeAndDivide(
annualSalary: number,
frequency: PayPeriod["frequency"]
): number {
const periods: Record<PayPeriod["frequency"], number> = {
weekly: 52,
biweekly: 26,
semi_monthly: 24,
monthly: 12,
};
return annualSalary / periods[frequency];
}
private annualHours(frequency: PayPeriod["frequency"]): number {
const standardAnnualHours = 2080;
return standardAnnualHours;
}
}Deduction Pipeline Architecture
Deductions are not a flat list. They have ordering constraints, dependencies, and conditional logic. A pre-tax retirement contribution reduces the taxable income used for federal tax calculation. A garnishment might be capped at a percentage of disposable income, which itself depends on all prior deductions.
The pipeline pattern handles this elegantly:
interface DeductionRule {
code: string;
name: string;
category: DeductionLineItem["category"];
preTax: boolean;
priority: number;
calculate: (context: DeductionContext) => DeductionOutput;
applicableTo: (context: DeductionContext) => boolean;
}
interface DeductionContext {
grossPay: number;
taxableIncome: number;
disposableIncome: number;
yearToDateEarnings: number;
yearToDateDeductions: Map<string, number>;
employee: EmployeeProfile;
payPeriod: PayPeriod;
priorDeductions: DeductionLineItem[];
}
interface DeductionOutput {
employeeAmount: number;
employerAmount?: number;
taxableReduction?: number;
}
class DeductionPipeline {
private rules: DeductionRule[] = [];
register(rule: DeductionRule): void {
this.rules.push(rule);
this.rules.sort((a, b) => a.priority - b.priority);
}
execute(initialContext: DeductionContext): DeductionLineItem[] {
const results: DeductionLineItem[] = [];
let context = { ...initialContext };
for (const rule of this.rules) {
if (!rule.applicableTo(context)) continue;
const output = rule.calculate(context);
results.push({
code: rule.code,
name: rule.name,
amount: output.employeeAmount,
category: rule.category,
preTax: rule.preTax,
});
if (rule.preTax && output.taxableReduction) {
context = {
...context,
taxableIncome: context.taxableIncome - output.taxableReduction,
priorDeductions: [...results],
};
}
context = {
...context,
disposableIncome: context.disposableIncome - output.employeeAmount,
priorDeductions: [...results],
};
}
return results;
}
}Priority ordering is essential. Pre-tax deductions must execute before tax calculations, and tax calculations must execute before post-tax deductions like garnishments. The context threading ensures each rule sees the correct state of the world after all higher-priority rules have executed.
Modeling Tax Brackets and Statutory Rules
Tax calculation is where the real complexity lives. Bracket-based tax systems, phase-outs, caps, and annual limits all need to be modeled precisely.
interface TaxBracket {
min: number;
max: number | null;
rate: number;
fixedAmount: number;
}
class BracketTaxCalculator {
constructor(private readonly brackets: TaxBracket[]) {}
calculate(taxableIncome: number): number {
let totalTax = 0;
for (const bracket of this.brackets) {
if (taxableIncome <= bracket.min) break;
const upper = bracket.max ?? Infinity;
const taxableInBracket = Math.min(taxableIncome, upper) - bracket.min;
totalTax += bracket.fixedAmount + taxableInBracket * bracket.rate;
}
return Math.round(totalTax * 100) / 100;
}
}
const federalIncomeTaxRule: DeductionRule = {
code: "FIT",
name: "Federal Income Tax",
category: "tax",
preTax: false,
priority: 100,
applicableTo: () => true,
calculate: (context) => {
const annualTaxable = context.taxableIncome * periodsPerYear(context.payPeriod.frequency);
const brackets: TaxBracket[] = [
{ min: 0, max: 11600, rate: 0.10, fixedAmount: 0 },
{ min: 11600, max: 47150, rate: 0.12, fixedAmount: 1160 },
{ min: 47150, max: 100525, rate: 0.22, fixedAmount: 5426 },
{ min: 100525, max: 191950, rate: 0.24, fixedAmount: 17168.5 },
{ min: 191950, max: 243725, rate: 0.32, fixedAmount: 39110.5 },
{ min: 243725, max: 609350, rate: 0.35, fixedAmount: 55678.5 },
{ min: 609350, max: null, rate: 0.37, fixedAmount: 183647.25 },
];
const calculator = new BracketTaxCalculator(brackets);
const annualTax = calculator.calculate(annualTaxable);
const periodicTax = annualTax / periodsPerYear(context.payPeriod.frequency);
return { employeeAmount: Math.round(periodicTax * 100) / 100 };
},
};
function periodsPerYear(frequency: PayPeriod["frequency"]): number {
const map: Record<PayPeriod["frequency"], number> = {
weekly: 52,
biweekly: 26,
semi_monthly: 24,
monthly: 12,
};
return map[frequency];
}A practical tip: externalize tax bracket data rather than hardcoding it. Tax brackets change every year, and many jurisdictions adjust them mid-year. A configuration-driven approach lets you update brackets without redeploying the engine.
Year-to-Date Tracking and Annual Limits
Many payroll deductions have annual caps. Social security tax, for instance, stops being withheld once an employee's year-to-date earnings exceed a defined wage base. Handling this correctly requires tracking cumulative earnings and deductions across pay periods.
interface YearToDateAccumulator {
employeeId: string;
year: number;
grossEarnings: number;
taxableEarnings: number;
deductions: Map<string, number>;
contributions: Map<string, number>;
}
class AnnualLimitEnforcer {
enforce(
deductionCode: string,
calculatedAmount: number,
annualLimit: number,
ytd: YearToDateAccumulator
): number {
const alreadyDeducted = ytd.deductions.get(deductionCode) ?? 0;
const remainingCapacity = Math.max(0, annualLimit - alreadyDeducted);
return Math.min(calculatedAmount, remainingCapacity);
}
}
const socialSecurityRule: DeductionRule = {
code: "FICA_SS",
name: "Social Security Tax",
category: "statutory",
preTax: false,
priority: 200,
applicableTo: () => true,
calculate: (context) => {
const rate = 0.062;
const wageBase = 168600;
const enforcer = new AnnualLimitEnforcer();
const rawAmount = context.grossPay * rate;
const cappedAmount = enforcer.enforce(
"FICA_SS",
rawAmount,
wageBase * rate,
buildYtdAccumulator(context)
);
return {
employeeAmount: Math.round(cappedAmount * 100) / 100,
employerAmount: Math.round(cappedAmount * 100) / 100,
};
},
};
function buildYtdAccumulator(context: DeductionContext): YearToDateAccumulator {
return {
employeeId: context.employee.id,
year: context.payPeriod.year,
grossEarnings: context.yearToDateEarnings,
taxableEarnings: context.yearToDateEarnings,
deductions: context.yearToDateDeductions,
contributions: new Map(),
};
}Orchestrating the Full Calculation
With all the pieces in place, the payroll engine orchestrates gross pay calculation, deduction pipeline execution, and result assembly into a single cohesive flow.
class PayrollCalculationEngine {
private readonly version = "2025.11.1";
constructor(
private readonly grossCalculator: GrossPayCalculator,
private readonly deductionPipeline: DeductionPipeline,
private readonly ytdService: YearToDateService,
private readonly employeeService: EmployeeService
) {}
async calculate(input: PayrollInput): Promise<PayrollResult> {
const employee = await this.employeeService.getProfile(input.employeeId);
const ytd = await this.ytdService.getAccumulator(
input.employeeId,
input.payPeriod.year
);
const grossPay = this.grossCalculator.calculate(input);
const deductionContext: DeductionContext = {
grossPay,
taxableIncome: grossPay,
disposableIncome: grossPay,
yearToDateEarnings: ytd.grossEarnings,
yearToDateDeductions: ytd.deductions,
employee,
payPeriod: input.payPeriod,
priorDeductions: [],
};
const deductions = this.deductionPipeline.execute(deductionContext);
const totalDeductions = deductions.reduce((sum, d) => sum + d.amount, 0);
const netPay = Math.round((grossPay - totalDeductions) * 100) / 100;
return {
employeeId: input.employeeId,
payPeriod: input.payPeriod,
grossPay: Math.round(grossPay * 100) / 100,
deductions,
contributions: this.extractContributions(deductions),
netPay,
calculatedAt: new Date(),
calculationVersion: this.version,
};
}
private extractContributions(deductions: DeductionLineItem[]): ContributionLineItem[] {
return deductions
.filter((d) => d.category === "statutory" || d.category === "benefit")
.map((d) => ({
code: d.code,
name: d.name,
employeeAmount: d.amount,
employerAmount: d.amount,
category: d.category === "benefit" ? "insurance" as const : "statutory" as const,
}));
}
}A practical tip: round to two decimal places at the final output stage, not during intermediate calculations. Rounding at each step introduces cumulative rounding errors that can cause pay stubs to be off by a cent---a common source of employee complaints and audit findings.
Conclusion
Building a payroll calculation engine in TypeScript requires a disciplined approach to decomposition. The gross-to-net pipeline provides the overarching structure, while the deduction pipeline pattern enables rules to be added, removed, and reordered without modifying the core engine. Bracket-based tax calculators, year-to-date accumulators, and annual limit enforcers handle the domain-specific complexities that make payroll uniquely challenging.
TypeScript's type system is a natural ally here. Discriminated unions for pay period frequencies, strongly typed deduction contexts, and interface-driven rule definitions catch entire categories of errors at compile time. When financial accuracy is non-negotiable, the compiler is your first line of defense.
The patterns demonstrated here---composable deduction rules, context-threaded pipelines, and versioned calculation outputs---form a foundation that can be extended to handle multi-jurisdiction tax systems, retroactive adjustments, and complex benefit election scenarios as the platform matures.
Related Articles
Scaling Payroll Processing for Growing Organizations
A strategic and technical guide to scaling payroll systems as organizations grow, covering batch processing optimization, infrastructure scaling patterns, and the operational strategies that keep payroll reliable at scale.
Security Best Practices for Payroll Systems
A comprehensive guide to securing payroll systems, covering data encryption, access controls, PII protection, threat modeling, and the security architecture that protects sensitive employee financial data.
Digital Payroll Transformation: Strategy Guide
A strategic guide to modernizing payroll operations through digital transformation, covering technology selection, change management, compliance continuity, and the business case for building custom payroll infrastructure.