writing/tutorial/2026/08
TutorialAug 31, 2026·24 min read

Tafqit in TypeScript: Arabic amounts in words, done right

Article 5 of the Saudi Commercial Papers Law makes the amount written in words the controlling text when it disagrees with the figure — which makes a tafqit bug an error in the amount, not in the formatting. This tutorial builds a complete tafqit engine in TypeScript: Arabic number-noun agreement, the currencies whose minor unit is a thousand, and the decimal trap that quietly eats a halala.

Article 5 of the Saudi Commercial Papers Law (Royal Decree M/37) states one rule that reclassifies this whole category of bug:

If the amount of the bill is written in both words and figures, the amount written in words prevails in case of a discrepancy; and if the amount is written several times in words or in figures, the lesser amount prevails in case of a discrepancy.

Read that again as an engineer. The field your tafqit function emits is not decoration on the document, and it is not a reading aid. It is the controlling text. If your system prints «ثلاث آلاف ريال» instead of «ثلاثة آلاف ريال», you have not produced ugly output — you have produced a document that can be returned. And if a decimal quietly loses a halala between the database and the page, the lesser amount is the one that applies.

This tutorial builds a complete tafqit engine in TypeScript — from Arabic number-noun agreement up to the currency layer — and walks through the five places where most published implementations fall over.

What You'll Build

One function, tafqit(value, options), that takes a number or a string and returns the amount in words as it is written on a cheque, an invoice or a contract:

tafqit(1520, { currency: 'SAR' });
// ألف وخمسمائة وعشرون ريال سعودي فقط لا غير
 
tafqit('1.15', { currency: 'SAR' });
// واحد ريال سعودي وخمس عشرة هللة فقط لا غير
 
tafqit('1.5', { currency: 'KWD' });
// واحد دينار كويتي وخمسمائة فلس فقط لا غير
 
tafqit(234000, { currency: 'SAR' });
// مائتان وأربعة وثلاثون ألفًا ريال سعودي فقط لا غير

Look at the third example. 1.5 Kuwaiti dinars is five hundred fils, not fifty. The Kuwaiti, Bahraini, Omani, Jordanian, Iraqi, Tunisian and Libyan dinars all divide into a thousand minor units rather than a hundred. Any implementation that hardcodes a multiplication by 100 is wrong in seven Arab currencies at once, and wrong by a factor of ten.

Prerequisites

  • Node.js 20 or newer
  • TypeScript at the level of functions and types
  • A basic grasp of Arabic number-noun agreement — we explain it, but prior familiarity helps
  • An editor that handles right-to-left text inside string literals

Step 1: The Five Rules an Implementation Has to Encode

Before any code, these are the rules that separate a correct implementation from an almost correct one. Almost correct on a cheque is worse than useless, because it passes review.

1. Reversal (المخالفة) for 3 to 10. The number takes the opposite gender marker from the noun it counts: «ثلاثة رجال» because رجال is masculine so the number carries the ة, and «ثلاث نساء» because نساء is feminine so the number is bare. This is the rule developers get wrong most often, because intuition says the two should agree.

2. Agreement for 1, 2, 11 and 12. Here the rule inverts: «ريال واحد», «امرأة واحدة», «أحد عشر», «إحدى عشرة».

3. The split at 13 to 19. The unit half reverses, while عشر agrees: «ثلاثة عشر» for a masculine noun, «ثلاث عشرة» for a feminine one. Note this is the opposite of standalone ten, which gives «عشرة رجال» and «عشر نساء». The two therefore cannot share a lookup table — and sharing one is exactly what most libraries do, which is why they are wrong for every number between 13 and 19.

4. The counted form follows the last component, not the magnitude. It is «مائة ألف» because the number ends in a round hundred, and «مائتان وأربعة وثلاثون ألفًا» because it ends in thirty-four. Reading only the size of the group produces «مائة ألفًا», which is wrong.

5. The currency carries its own gender. ريال is masculine and ليرة is feminine, giving «ثلاثة ريالات» against «ثلاث ليرات». And هللة is feminine even though ريال is masculine, so it is «خمس وسبعون هللة», never «خمسة وسبعون هللة». One currency, two genders, inside a single amount.

Step 2: Project Setup and the Digit-Normalisation Gate

mkdir tafqit && cd tafqit
npm init -y
npm install --save-dev typescript @types/node
npx tsc --init --target es2020 --module nodenext --strict
mkdir src

What arrives at the function is not necessarily a number. Amounts get pasted out of spreadsheets and accounting systems, and they turn up in Arabic-Indic digits ٠١٢٣٤٥٦٧٨٩, in Eastern Arabic digits, and with Arabic thousands separators. Normalising before parsing removes an entire class of failure:

// src/normalize.ts
 
/** Normalise Arabic-Indic and Eastern digits so pasted amounts just work. */
export function normalizeDigits(s: string): string {
  return s.replace(/[٠-٩۰-۹]/g, (d) => {
    const code = d.charCodeAt(0);
    const base = code >= 0x06f0 ? 0x06f0 : 0x0660;
    return String(code - base);
  });
}

The two ranges are distinct: Arabic-Indic digits start at U+0660 and Extended Arabic-Indic (Persian) digits at U+06F0. Handling only one leaves half of all paste cases failing silently.

Step 3: 1 to 99

This is where the reversal and the split live. Note that gender throughout means the gender of the counted noun, never of the number word:

// src/numbers.ts
 
/** Gender of the noun being counted — not of the number word. */
export type Gender = 'm' | 'f';
 
/** Both spellings are in live use; Gulf official documents tend to مائة. */
export type HundredsForm = 'مائة' | 'مئة';
 
/** 1–9 as they appear when counting a masculine noun (3–9 carry the ة). */
const ONES_M = ['', 'واحد', 'اثنان', 'ثلاثة', 'أربعة', 'خمسة', 'ستة', 'سبعة', 'ثمانية', 'تسعة'];
/** 1–9 as they appear when counting a feminine noun (3–9 are bare). */
const ONES_F = ['', 'واحدة', 'اثنتان', 'ثلاث', 'أربع', 'خمس', 'ست', 'سبع', 'ثماني', 'تسع'];
 
/** The unit half of 11–19. 11 and 12 are irregular and agree rather than reverse. */
const TEEN_UNIT_M = ['عشرة', 'أحد', 'اثنا', 'ثلاثة', 'أربعة', 'خمسة', 'ستة', 'سبعة', 'ثمانية', 'تسعة'];
const TEEN_UNIT_F = ['عشر', 'إحدى', 'اثنتا', 'ثلاث', 'أربع', 'خمس', 'ست', 'سبع', 'ثماني', 'تسع'];
 
const TENS = ['', '', 'عشرون', 'ثلاثون', 'أربعون', 'خمسون', 'ستون', 'سبعون', 'ثمانون', 'تسعون'];
 
export const ZERO = 'صفر';
 
/** 1–99, given the gender of the noun being counted. */
function underHundred(n: number, gender: Gender): string {
  const ones = gender === 'm' ? ONES_M : ONES_F;
  if (n < 10) return ones[n];
 
  if (n < 20) {
    const unit = (gender === 'm' ? TEEN_UNIT_M : TEEN_UNIT_F)[n - 10];
    if (n === 10) return unit;
    // The عشر half agrees with the noun — the inverse of standalone ten.
    return `${unit} ${gender === 'm' ? 'عشر' : 'عشرة'}`;
  }
 
  const ten = TENS[Math.floor(n / 10)];
  const unit = n % 10;
  // Arabic puts the unit before the ten: خمسة وعشرون, not عشرون وخمسة.
  return unit ? `${ones[unit]} و${ten}` : ten;
}

Index zero of TEEN_UNIT_M holds «عشرة» while index zero of TEEN_UNIT_F holds «عشر». That is the split: standalone ten reverses, while the عشر inside a compound agrees. Collapsing the two tables into one is the commonest bug in npm packages, and it is a bug that only shows up in a narrow band of numbers.

Step 4: The Hundreds, and Why مائة Is Feminine

/**
 * Hundreds 100–900.
 *
 * 300–900 are conventionally written as one word (ثلاثمائة), and the unit uses
 * the feminine-noun column because مائة is feminine. 800 contracts to ثمان +
 * مائة rather than taking the standalone ثماني form with a yaa.
 */
function hundredsToWords(h: number, form: HundredsForm, construct = false): string {
  if (h === 0) return '';
  if (h === 1) return form;
  if (h === 2) {
    // The dual loses its nūn in the construct state: مائتا ألف, not مائتان ألف.
    const base = form === 'مائة' ? 'مائت' : 'مئت';
    return base + (construct ? 'ا' : 'ان');
  }
  // 800 contracts — ثمانمائة, not ثمانيمائة.
  return (h === 8 ? 'ثمان' : ONES_F[h]) + form;
}
 
/** 0–999. Returns '' for 0 so callers can drop empty groups. */
export function tripletToWords(
  n: number,
  gender: Gender,
  form: HundredsForm = 'مائة',
  construct = false,
): string {
  if (n === 0) return '';
  const parts: string[] = [];
  // The construct form only applies when the hundreds are the final component,
  // i.e. nothing follows them inside the group.
  const h = hundredsToWords(Math.floor(n / 100), form, construct && n % 100 === 0);
  if (h) parts.push(h);
  const rest = underHundred(n % 100, gender);
  if (rest) parts.push(rest);
  return parts.join(' و');
}

Using ONES_F for the hundreds is not an oversight. The word مائة is itself feminine, so the number counting it reverses to the masculine form — that is, it drops the ة. Hence «ثلاثمائة», not «ثلاثةمائة». The construct parameter handles the dual in the construct state: «مائتا ألف» with the nūn dropped, against «مائتان» when it stands alone.

Step 5: Scale Words, and the Last-Component Rule

Here is rule four. The form a scale word takes (ألف, مليون) is not governed by the size of the group but by its last component:

/** Scale words, in singular / dual / plural / accusative-singular forms. */
const SCALES: { one: string; two: string; plural: string; accusative: string }[] = [
  { one: '', two: '', plural: '', accusative: '' }, // units — no scale word
  { one: 'ألف', two: 'ألفان', plural: 'آلاف', accusative: 'ألفًا' },
  { one: 'مليون', two: 'مليونان', plural: 'ملايين', accusative: 'مليونًا' },
  { one: 'مليار', two: 'ملياران', plural: 'مليارات', accusative: 'مليارًا' },
  { one: 'تريليون', two: 'تريليونان', plural: 'تريليونات', accusative: 'تريليونًا' },
];
 
/** Largest value this can express, one short of the next unnamed scale. */
export const MAX_VALUE = 1_000 ** SCALES.length - 1;
 
function scaleGroup(group: number, level: number, form: HundredsForm): string {
  const s = SCALES[level];
  if (group === 1) return s.one;
  if (group === 2) return s.two;
 
  const tail = group % 100;
  // The scale word is a masculine noun, so its multiplier follows the masculine
  // column whatever the final counted noun happens to be.
  const count = tripletToWords(group, 'm', form, tail === 0);
  if (tail === 0) return `${count} ${s.one}`;              // مائة ألف
  if (tail <= 2 && group < 100) return `${count} ${s.one}`;
  if (tail <= 10) return `${count} ${s.plural}`;           // ثلاثة آلاف
  return `${count} ${s.accusative}`;                        // أحد عشر ألفًا
}

Try the three values yourself: 100000 gives «مائة ألف», 3000 gives «ثلاثة آلاف», and 11000 gives «أحد عشر ألفًا». Three different forms of one word, decided by the last two digits alone.

The second point in that snippet matters more than it looks: tripletToWords(group, 'm', ...) always passes masculine. Why? Because the noun being counted here is not the riyal or the lira, it is the word ألف itself, which is masculine. It is «ثلاثة آلاف امرأة», not «ثلاث آلاف امرأة». Passing the currency's gender down into this layer is a subtle bug that only surfaces with feminine currencies.

Step 6: Assembling the Whole Number

export function integerToWords(
  value: number,
  gender: Gender = 'm',
  form: HundredsForm = 'مائة',
): string {
  if (!Number.isFinite(value)) throw new RangeError('Not a finite number');
  const negative = value < 0;
  let n = Math.abs(Math.trunc(value));
  if (n > MAX_VALUE) throw new RangeError(`Number too large for tafqit: max ${MAX_VALUE}`);
  if (n === 0) return ZERO;
 
  // Split into groups of three, least significant first. MAX_VALUE is below
  // Number.MAX_SAFE_INTEGER, so ordinary arithmetic stays exact and there is
  // no need for BigInt.
  const groups: number[] = [];
  while (n > 0) {
    groups.push(n % 1000);
    n = Math.floor(n / 1000);
  }
 
  const parts: string[] = [];
  for (let level = groups.length - 1; level >= 0; level -= 1) {
    const g = groups[level];
    if (g === 0) continue;
    parts.push(level === 0 ? tripletToWords(g, gender, form) : scaleGroup(g, level, form));
  }
 
  const words = parts.join(' و');
  return negative ? `سالب ${words}` : words;
}

Only the final group receives gender, because only that group counts the real noun; everything above it counts a scale word.

Step 7: Currencies, and the Minor Unit That Is Not Always 100

// src/currencies.ts
import type { Gender } from './numbers';
 
export type Currency = {
  code: string;
  /** Singular major unit, e.g. ريال سعودي. */
  name: string;
  gender: Gender;
  /** Singular minor unit, e.g. هللة. Absent for currencies with no minor unit. */
  minorName?: string;
  minorGender?: Gender;
  /** Minor units per major. */
  minor: number;
};
 
export const CURRENCIES: Record<string, Currency> = {
  SAR: { code: 'SAR', name: 'ريال سعودي', gender: 'm', minorName: 'هللة', minorGender: 'f', minor: 100 },
  AED: { code: 'AED', name: 'درهم إماراتي', gender: 'm', minorName: 'فلس', minorGender: 'm', minor: 100 },
  KWD: { code: 'KWD', name: 'دينار كويتي', gender: 'm', minorName: 'فلس', minorGender: 'm', minor: 1000 },
  BHD: { code: 'BHD', name: 'دينار بحريني', gender: 'm', minorName: 'فلس', minorGender: 'm', minor: 1000 },
  OMR: { code: 'OMR', name: 'ريال عماني', gender: 'm', minorName: 'بيسة', minorGender: 'f', minor: 1000 },
  JOD: { code: 'JOD', name: 'دينار أردني', gender: 'm', minorName: 'فلس', minorGender: 'm', minor: 1000 },
  TND: { code: 'TND', name: 'دينار تونسي', gender: 'm', minorName: 'مليم', minorGender: 'm', minor: 1000 },
  SYP: { code: 'SYP', name: 'ليرة سورية', gender: 'f', minorName: 'قرش', minorGender: 'm', minor: 100 },
  EGP: { code: 'EGP', name: 'جنيه مصري', gender: 'm', minorName: 'قرش', minorGender: 'm', minor: 100 },
};
 
export function getCurrency(code: string): Currency | undefined {
  return CURRENCIES[code.toUpperCase()];
}

The grammar needs exactly two things: the gender of the unit noun, because it drives the reversal rule, and how many minor units make one major. The first separates «ثلاثة» from «ثلاث»; the second separates five hundred fils from fifty.

Note minorGender on the Saudi riyal in particular: هللة is feminine while ريال is masculine. Passing the major unit's gender down to the minor unit produces «خمسة وسبعون هللة» — a mistake that shows up on more than half of all invoices, because fractional amounts are more common than round ones.

Step 8: The Decimal Trap

This is where money actually goes missing:

Math.round(parseFloat('1.005') * 100);   // 100 — not 101
Math.round(parseFloat('8.165') * 100);   // 816 — not 817
Math.round(parseFloat('1.15') * 100);    // 115 — this one survives

1.005 and 8.165 cannot be represented exactly in binary floating point: multiplied by a hundred they come out as 100.49999999999999 and 816.4999999999999. Both land just under the halfway point by an invisible margin, so Math.round rounds them down where decimal rounding would round them up. The result is a lost halala on entirely ordinary amounts.

Pay attention to the third line especially: 1.15 survives. Its product is 114.99999999999999, and Math.round correctly lifts that to 115. That is why this defect passes review — the first value a developer tries is usually one of the survivors, and the bug only surfaces on one particular invoice months later.

The fix is not to pass through floating point twice: read the digits from the string directly.

// src/split.ts
 
/**
 * Split a decimal amount into major and minor units without going through
 * floating point twice. Reading the digits from the string keeps the minor
 * part exact.
 */
export function splitAmount(input: string, minorPer: number): { major: number; minor: number } {
  const [wholeRaw, fracRaw = ''] = input.split('.');
  const digits = String(minorPer).length - 1; // 100 -> 2, 1000 -> 3
  const padded = (fracRaw + '0'.repeat(digits)).slice(0, digits);
  let minor = padded === '' ? 0 : Number(padded);
 
  if ((wholeRaw || '').replace(/^0+/, '').length > 16) {
    throw new RangeError('Number too large for tafqit');
  }
  let major = Number(wholeRaw || '0');
 
  // A fraction longer than the currency's precision rounds into the minor unit,
  // and can carry all the way into the major one: 1.999 SAR is 2 riyals.
  const next = fracRaw[digits];
  if (next && Number(next) >= 5) {
    minor += 1;
    if (minor >= minorPer) {
      minor = 0;
      major += 1;
    }
  }
  return { major, minor };
}

Padding with zeros and then slicing is what makes 1.5 yield 50 halalas for the riyal and 500 fils for the Kuwaiti dinar, from the same code and with no per-currency special case. The carry from the minor unit into the major one is necessary too: 1.999 SAR is two riyals, not one riyal and a hundred halalas.

Step 9: The Money Layer and «فقط لا غير»

// src/index.ts
import { integerToWords, MAX_VALUE, ZERO, type Gender, type HundredsForm } from './numbers';
import { getCurrency, type Currency } from './currencies';
import { normalizeDigits } from './normalize';
import { splitAmount } from './split';
 
const CLOSING = 'فقط لا غير';
 
export type TafqitOptions = {
  /** Gender of the counted noun. Ignored when `currency` is set. */
  gender?: Gender;
  /** مائة (default, usual in Gulf official documents) or مئة. */
  hundreds?: HundredsForm;
  /** ISO code, e.g. 'SAR'. Adds the unit names and the closing formula. */
  currency?: string;
  /** Override the closing formula. Defaults to on for currency amounts. */
  closing?: boolean | string;
};
 
function unitPhrase(count: number, words: string, name: string): string {
  // The convention in cheques and invoices is to append the unit name
  // uninflected — «ألف وخمسمائة وعشرون ريال سعودي» — rather than decline it.
  return count === 0 ? '' : `${words} ${name}`;
}
 
export function tafqit(value: number | string, options: TafqitOptions = {}): string {
  const { gender = 'm', hundreds = 'مائة' } = options;
  const currency: Currency | undefined = options.currency ? getCurrency(options.currency) : undefined;
  if (options.currency && !currency) {
    throw new Error(`Unknown currency: ${options.currency}`);
  }
 
  const raw = normalizeDigits(String(value).trim()).replace(/[,\s٬]/g, '');
  if (!/^-?\d*(\.\d*)?$/.test(raw) || raw === '' || raw === '-') {
    throw new Error(`Not a number: ${value}`);
  }
  const negative = raw.startsWith('-');
  const body = negative ? raw.slice(1) : raw;
 
  const closingText =
    typeof options.closing === 'string'
      ? options.closing
      : (options.closing ?? Boolean(currency))
        ? CLOSING
        : '';
 
  if (!currency) {
    const [whole] = body.split('.');
    const n = Number(whole || '0');
    if (n > MAX_VALUE) throw new RangeError('Number too large for tafqit');
    const words = integerToWords(negative ? -n : n, gender, hundreds);
    return closingText ? `${words} ${closingText}` : words;
  }
 
  const { major, minor } = splitAmount(body, currency.minor);
  if (major > MAX_VALUE) throw new RangeError('Number too large for tafqit');
 
  const parts: string[] = [];
  if (major > 0 || minor === 0) {
    parts.push(unitPhrase(major, integerToWords(major, currency.gender, hundreds), currency.name));
  }
  if (minor > 0 && currency.minorName) {
    const minorWords = integerToWords(minor, currency.minorGender ?? 'm', hundreds);
    parts.push(unitPhrase(minor, minorWords, currency.minorName));
  }
 
  let out = parts.filter(Boolean).join(' و');
  if (negative) out = `سالب ${out}`;
  return closingText ? `${out} ${closingText}` : out;
}
 
export { ZERO, MAX_VALUE };

Note unitPhrase: the currency name is appended in the singular and uninflected, not pluralised, giving «ألف وخمسمائة وعشرون ريال سعودي» and «ثلاث ليرة سورية». That is the convention on cheques and invoices, and it is deliberate rather than a shortcut: pluralising and declining opens a door to interpretation that a financial document does not want. The currency's gender is still doing its work — «ثلاث», not «ثلاثة», because ليرة is feminine.

And «فقط لا غير» is not decoration. Writing the amount in words exists in the first place to stop a figure being altered after signing, and the closing phrase seals the sentence so nothing can be appended to it. That is why this implementation defaults it on whenever a currency is given, and lets you switch it off when you are spelling out a bare number in teaching material.

Step 10: Wiring It Into an Invoicing Pipeline

The architectural mistake that undoes everything above is handing a floating-point number from the database to the function. Keep amounts as integer minor units throughout the system, and convert to a decimal string exactly once, at the edge:

// src/invoice.ts
import { tafqit } from './index';
 
type InvoiceLine = { description: string; amountMinor: number };
 
/**
 * Amounts live as integer minor units everywhere inside the system, and are
 * turned into a decimal string exactly once, at the edge, for tafqit. That
 * single boundary is what keeps the words and the figure in agreement.
 */
export function amountInWords(totalMinor: number, currency: string, minorPer = 100): string {
  const major = Math.trunc(totalMinor / minorPer);
  const minor = Math.abs(totalMinor % minorPer);
  const decimals = String(minorPer).length - 1;
  const asString = `${major}.${String(minor).padStart(decimals, '0')}`;
  return tafqit(asString, { currency });
}
 
export function renderTotals(lines: InvoiceLine[], currency = 'SAR', minorPer = 100) {
  const totalMinor = lines.reduce((sum, l) => sum + l.amountMinor, 0);
  const decimals = String(minorPer).length - 1;
  return {
    figure: (totalMinor / minorPer).toFixed(decimals),
    words: amountInWords(totalMinor, currency, minorPer),
  };
}

That single boundary is what guarantees the printed figure and the printed words express the same value. If the system derives the figure through one path and the words through another, you are writing two amounts onto the document that may disagree — which is the exact situation Article 5 was written to resolve.

Testing Your Implementation

These tests are not a formality. Every case in them stands for a grammatical rule that fails silently:

// src/tafqit.test.ts
import test from 'node:test';
import assert from 'node:assert/strict';
import { tafqit } from './index';
import { integerToWords } from './numbers';
 
test('3–10 reverse the gender of the counted noun', () => {
  assert.equal(integerToWords(3, 'm'), 'ثلاثة');
  assert.equal(integerToWords(3, 'f'), 'ثلاث');
});
 
test('standalone ten is the inverse of the عشر inside a teen', () => {
  assert.equal(integerToWords(10, 'm'), 'عشرة');
  assert.equal(integerToWords(10, 'f'), 'عشر');
  assert.equal(integerToWords(13, 'm'), 'ثلاثة عشر');
  assert.equal(integerToWords(13, 'f'), 'ثلاث عشرة');
});
 
test('the scale word form follows the last component, not the size', () => {
  assert.equal(integerToWords(100000), 'مائة ألف');
  assert.equal(integerToWords(3000), 'ثلاثة آلاف');
  assert.equal(integerToWords(11000), 'أحد عشر ألفًا');
  assert.equal(integerToWords(234000), 'مائتان وأربعة وثلاثون ألفًا');
});
 
test('the minor unit is not always 100', () => {
  assert.match(tafqit('1.5', { currency: 'SAR' }), /خمسون هللة/);
  assert.match(tafqit('1.5', { currency: 'KWD' }), /خمسمائة فلس/);
});
 
test('the decimal does not swallow a halala', () => {
  assert.match(tafqit('1.15', { currency: 'SAR' }), /خمس عشرة هللة/);
  assert.match(tafqit('1.005', { currency: 'SAR' }), /هللة/);
  assert.match(tafqit('8.165', { currency: 'SAR' }), /سبع عشرة هللة/);
});
 
test('the minor unit carries into the major one', () => {
  assert.equal(tafqit('1.999', { currency: 'SAR' }), 'اثنان ريال سعودي فقط لا غير');
});
 
test('the currency carries its own gender', () => {
  assert.equal(tafqit(3, { currency: 'SYP' }), 'ثلاث ليرة سورية فقط لا غير');
});
 
test('Arabic-Indic digits are accepted as pasted', () => {
  assert.equal(tafqit('١٢٣'), tafqit('123'));
});

Run them with node --test after compiling. The 1.005 and 8.165 cases are specifically the ones that expose the parseFloat path, while the values a developer normally tries survive it. This suite, rather than the function itself, is the part worth copying into your project.

Troubleshooting

The text renders reversed or with disconnected letters in a PDF. That is not tafqit, it is the rendering engine: most PDF generators implement neither the bidirectional algorithm nor Arabic letter shaping. Check that your library supports connected Arabic text before blaming the function.

«مائة» or «مئة»? Both are correct. Gulf official documents lean towards «مائة», modern writing towards «مئة». The hundreds option lets you match the rest of your paperwork; what matters most is staying consistent within one system.

Amounts beyond the ceiling. MAX_VALUE stops at the trillions because there is no agreed name past that point. Throwing is better than emitting an invented word onto a financial document.

Does ZATCA require the amount in words on an invoice? No. The mandatory tax-invoice fields do not include tafqit; it is a banking and contractual requirement, not a tax one. What ZATCA actually requires is covered in the simplified tax invoice and Article 53 guide.

Next Steps

Conclusion

Tafqit looks like a formatting concern and is usually treated as one: a single line at the bottom of a template, written in a hurry and reviewed by nobody. But Article 5 of the Commercial Papers Law puts that line in the position of the controlling text, which makes an error in it an error in the amount rather than in the appearance.

The rules we encoded are not many: reversal for 3–10, the split in the compound tens, the counted form following the last component, the gender of the currency noun, and the number of minor units — plus one decimal boundary that is never crossed twice. But every one of them fails silently, producing Arabic that looks right and reads wrong. Which is why the most valuable part of this tutorial is not the function; it is the test suite that stops it regressing.

Writing this inside an existing invoicing or payroll system? If even one document leaves your system with an amount in words on it, it is worth half an hour on your tafqit output and your rounding logic before a bank finds the problem for you. Send us one sample document and we will tell you where the five mistakes above sit in your system.