persianist
v0.1.0
Published
Zero-dependency TypeScript toolkit for Persian/Farsi text, Jalali (Shamsi) and Hijri dates, relative time and Iranian holidays, Iranian validators, Persian numbers and currency, and Iranian geography.
Downloads
129
Maintainers
Keywords
Readme
persianist
Zero-dependency TypeScript toolkit for Persian/Farsi text, Jalali (Shamsi) and Hijri dates, Iranian validators, Persian numbers and currency, and Iranian geography.
🇮🇷 مستندات فارسی — Persian documentation
- Zero runtime dependencies — the Jalali math is implemented here, not delegated
- ESM + CJS, with full TypeScript declarations for both
- Tree-shakeable,
sideEffects: false, plus subpath entries for CJS consumers - Platform-neutral — Node, browsers, Bun, Deno, and edge runtimes
- Jalali and Hijri math verified against ICU, day by day, across two centuries
Install
npm install persianistQuick start
import { normalize, toPersianDigits } from 'persianist'
import { formatJalali, toJalali } from 'persianist'
import { isValidNationalId, getBankFromCardNumber } from 'persianist'
import { fromNow, isIranianHoliday } from 'persianist'
import { formatCurrency, numberToPersianWords } from 'persianist'
import { findPlace, getCapitalOfProvince } from 'persianist'
normalize(' كيف مُحَمَّد ') // 'کیف محمد'
toPersianDigits('تلفن: 021') // 'تلفن: ۰۲۱'
toJalali(new Date('2024-03-20T12:00:00Z')) // { jy: 1403, jm: 1, jd: 1 }
formatJalali({ jy: 1403, jm: 1, jd: 1 }, 'dddd D MMMM YYYY')
// 'چهارشنبه ۱ فروردین ۱۴۰۳'
isValidNationalId('0499370899') // true
getBankFromCardNumber('6037991199500590')?.name // 'بانک ملی ایران'
fromNow(new Date(Date.now() - 3 * 60_000)) // '۳ دقیقه پیش'
isIranianHoliday(1403, 1, 1) // true — نوروز
numberToPersianWords(1234) // 'یک هزار و دویست و سی و چهار'
formatCurrency(50000) // '۵۰٬۰۰۰ تومان'
findPlace('اصفهان')?.nameEn // 'Isfahan'
getCapitalOfProvince('26')?.name // 'تهران'Everything is exported from the root. To keep a CJS bundle small, import from a subpath instead:
import { toPersianDigits } from 'persianist/text'
import { toJalali } from 'persianist/date'
import { isValidNationalId } from 'persianist/validate'
import { formatNumber } from 'persianist/number'
import { getProvinces } from 'persianist/geo'
persianist/geocarries ~290 KB of place data. If you only need the other modules and your bundler cannot tree-shake — notably under CJS — import from the subpaths.
There is no default export — named exports keep the package tree-shakeable. Use
import * as persianist from 'persianist' if you want a namespace.
Text utilities
Digits
Persian uses Extended Arabic-Indic digits (۰۱۲, U+06F0–U+06F9); Arabic uses
Arabic-Indic (٠١٢, U+0660–U+0669). They render almost identically and are
different code points. Every function here handles both.
| Function | Description |
| --- | --- |
| toPersianDigits(input) | ASCII and Arabic-Indic → ۰۱۲ |
| toArabicDigits(input) | ASCII and Persian → ٠١٢ |
| toEnglishDigits(input) | Persian and Arabic-Indic → 012 |
| hasPersianDigits(input) / hasArabicDigits(input) | Detection |
toEnglishDigits('۱۲۳٤٥٦') // '123456' — mixed blocks in one string
toPersianDigits(123) // '۱۲۳'Characters
| Function | Description |
| --- | --- |
| arabicToPersianChars(input) | ي→ی, ك→ک, ة→ه, ۀ→ه, ؤ→و, أ/إ→ا … |
| removeDiacritics(input) | Strips harakat, superscript alef, and tatweel |
| removeBidiControls(input) | Strips bidi marks and the BOM — never ZWNJ or ZWJ |
آ is deliberately preserved: it is a distinct Persian letter, not an Arabic artifact.
Half-spaces (ZWNJ)
| Function | Description |
| --- | --- |
| normalizeZWNJ(input) | Collapses runs, resolves space-adjacent ZWNJ, trims edges |
| removeZWNJ(input) / replaceZWNJ(input, replacement?) | Remove or substitute |
| applyHalfSpaces(input) | Heuristic — inserts half-spaces |
applyHalfSpaces('می رود') // 'میرود'
applyHalfSpaces('کتاب ها') // 'کتابها'
applyHalfSpaces('مو ها') // 'موها' — و does not join forward
applyHalfSpacesis lossy. Persian cannot distinguish the verb prefix «می» from the noun «می» (wine), or the suffix «تر» from the adjective «تر» (wet), without a lexicon. Expect a small false-positive rate. It is opt-in and excluded from thestandardpreset.
Whitespace, punctuation, detection
| Function | Description |
| --- | --- |
| normalizeWhitespace(input) | CRLF→LF, NBSP→space, collapses runs |
| trimPersian(input) | Trims whitespace and ZWNJ and bidi marks |
| collapseNewlines(input, max?) | Bounds consecutive blank lines |
| toPersianPunctuation(input) | ,→،, ;→؛, ?→؟, %→٪ |
| toEnglishPunctuation(input) | The inverse, plus ٫/٬ separators |
| normalizePunctuationSpacing(input) | 'سلام ،دنیا' → 'سلام، دنیا' |
| removePunctuation(input) | Strips Persian and ASCII punctuation |
| isPersian(input, options?) | Whole-string check, configurable |
| containsPersian / containsArabic | Substring checks |
| countWords / countCharacters | ZWNJ compounds count as one word |
slugify
slugify('سلام دنیا') // 'سلام-دنیا'
slugify('میرود') // 'می-رود' (ZWNJ becomes a separator)
slugify('کتاب ۱۲۳') // 'کتاب-123'
slugify('Hello سلام!') // 'hello-سلام'
slugify('یک دو سه چهار', { maxLength: 10 }) // cut at a separator, never mid-wordOptions: separator, lowercase, digitsToEnglish, zwnjAsSeparator, allow, maxLength.
Sorting
import { comparePersian, sortPersian } from 'persianist/text'
['گاو', 'کبک', 'آب'].sort(comparePersian) // ['آب', 'کبک', 'گاو']
sortPersian(cities, { key: (city) => city.name })
sortPersian(chapters, { key: (c) => c.title, numeric: true })Array.sort() orders by code point, which scatters the alphabet: ک (U+06A9) and گ
(U+06AF) land after ل م ن و ه (U+0644–U+0648) instead of before them, and و/ه
come out swapped. Intl.Collator('fa') gets it right but depends on the runtime's ICU
data, which edge and mobile runtimes trim or omit. This is a fixed table, so the order is
identical everywhere.
Insensitive to the things that are only spelling — Arabic letter forms, harakat, digit
set, Latin case, ZWNJ — so «كتاب» and «کتاب» compare equal. A real space stays
significant, because «کتاب خانه» and «کتابخانه» are different words while «میرود» and
«میرود» are not. { numeric: true } reads digit runs as numbers, so «فصل ۲» sorts before
«فصل ۱۰». Mixed lists band Persian first, then digits, then Latin.
PERSIAN_ALPHABET is exported if you need the order itself.
Keyboard layout
import { fixKeyboardLayout } from 'persianist/text'
fixKeyboardLayout('sghl') // 'سلام'
fixKeyboardLayout('nkdh') // 'دنیا'
fixKeyboardLayout('سلام', { from: 'fa' }) // 'sghl'Recovers text typed with the wrong layout active. The keystrokes were right and only the mapping was wrong, so the damage is a pure substitution and fully reversible — every Persian letter round-trips.
Targets the standard Windows Persian (Iran) layout, including shift+B for the
half-space. Keys whose Persian output is uncertain pass through unchanged rather than
being guessed at.
Apply this only to text you already know is mangled: it substitutes every character it recognizes, so running it over correctly-typed text will mangle that.
containsPersianmakes a reasonable guard.
Truncation
truncatePersian('سلام دنیای زیبا', 10) // 'سلام…'
truncatePersian('سلام دنیای زیبا', 10, { words: false }) // 'سلام دنیا…'
truncatePersian('میرود به خانه', 5) // 'می…'maxLength bounds the result, ellipsis included, so the output always fits the space
you budgeted. Three things a generic slice gets wrong: it can leave a dangling ZWNJ
that binds to the ellipsis and renders as a joined form; it can split a compound into
«میر»; and it counts UTF-16 units, so it can cut a surrogate pair in half.
Transliteration
toFinglish('خانه') // 'khaneh'
toFinglish('ایران') // 'iran'
toFinglish('سَلام') // 'salam' — voweled input
toFinglish('سلام') // 'slam' — unvoweled
toFinglish('سلام', { shortVowel: 'a' }) // 'salam' — guessed
slugify(toFinglish('خانه ما')) // 'khaneh-ma'A deterministic ASCII projection, not a pronunciation guide. Persian does not write its short vowels, so «سلام» is stored as the four letters s-l-a-m and comes out
slam. Nothing can recover a vowel that was never in the string; only a lexicon could, and this package does not carry one.{ shortVowel: 'a' }inserts one between adjacent consonants — it fixes «سلام» and breaks «تهران» intotaharan, which is why it is off by default.
Use it for ASCII slugs and stable matching keys, where consistency matters and spelling does not. For rendering someone's name in Latin, ask them.
normalize
One composable pipeline. Defaults are the safe set; conversions that change meaning or presentation are opt-in.
| Option | Default | Effect |
| --- | --- | --- |
| unicodeForm | 'NFC' | Unicode normalization form, or false to skip |
| arabicChars | true | Fold Arabic letter forms |
| diacritics | true | Strip harakat and tatweel |
| zwnj | true | Clean up ZWNJ usage |
| whitespace | true | Normalize whitespace |
| bidiControls | true | Strip bidi marks |
| trim | true | Trim whitespace and ZWNJ |
| digitsToEnglish | false | ۱۲۳ → 123 |
| digitsToPersian | false | 123 → ۱۲۳ |
| halfSpaces | false | Apply the lossy heuristic |
| persianPunctuation | false | , → ، |
| englishPunctuation | false | ، → , |
| punctuationSpacing | false | Fix spacing around punctuation |
import { createNormalizer, NORMALIZE_PRESETS, normalize } from 'persianist'
normalize(' كيف ') // 'کیف'
normalize('كتاب ۱۲۳، test', NORMALIZE_PRESETS.search) // 'کتاب 123, test'
const forSearch = createNormalizer(NORMALIZE_PRESETS.search)Presets: minimal, standard, search, display.
Jalali dates
Conversion
import { gregorianToJalali, jalaliToDate, toGregorian, toJalali } from 'persianist/date'
gregorianToJalali(2024, 3, 20) // { jy: 1403, jm: 1, jd: 1 }
toGregorian(1403, 1, 1) // { gy: 2024, gm: 3, gd: 20 }
toJalali(new Date()) // today
jalaliToDate(1403, 1, 1, 13, 45) // a DateTimezones
toJalali reads local-time components by default. This matters more than it looks:
new Date('2024-03-20') parses as UTC midnight, which is still 2024-03-20 in
Asia/Tehran but 2024-03-19 in America/Los_Angeles — a different Jalali day.
Pass { utc: true } to toJalali, toJalaliDateTime, jalaliToDate, nowJalali, and
formatJalali to work in UTC instead.
jalaliWeekday is computed from the Julian Day Number, never from Date, so it carries
no timezone dependency at all.
Info and arithmetic
isLeapJalaliYear(1403) // true
jalaliMonthLength(1403, 12) // 30 (29 in a non-leap year)
isValidJalaliDate(1402, 12, 30) // false — 1402 is not a leap year
jalaliWeekday(1399, 12, 30) // 0 = شنبه
addJalaliMonths({ jy: 1403, jm: 1, jd: 31 }, 6) // { jy: 1403, jm: 7, jd: 30 } — clamped
addJalaliYears({ jy: 1403, jm: 12, jd: 30 }, 1) // { jy: 1404, jm: 12, jd: 29 } — clamped
diffJalaliDays({ jy: 1404, jm: 1, jd: 1 }, { jy: 1403, jm: 1, jd: 1 }) // 366Also: jalaliYearLength, jalaliDayOfYear, jalaliWeekOfYear, jalaliSeason,
isJalaliWeekend, startOf*/endOf* for week/month/year, compareJalali.
Formatting
formatJalali({ jy: 1403, jm: 1, jd: 1 }) // '۱۴۰۳/۰۱/۰۱'
formatJalali({ jy: 1403, jm: 1, jd: 1 }, 'YYYY/MM/DD', { digits: 'en' }) // '1403/01/01'
formatJalali({ jy: 1403, jm: 1, jd: 1 }, 'D MMMM YYYY') // '۱ فروردین ۱۴۰۳'
formatJalali({ jy: 1403, jm: 1, jd: 1 }, '[سال] YYYY') // 'سال ۱۴۰۳'| Token | Output | | Token | Output |
| --- | --- | --- | --- | --- |
| YYYY YY | 1403 / 03 | | HH H | 00–23 |
| MMMM MMM | فروردین / فرو | | hh h | 01–12 |
| MM M | 01 / 1 | | mm m | minutes |
| DD D | 05 / 5 | | ss s | seconds |
| DDDD DDD | day of year | | SSS | milliseconds |
| dddd ddd d | شنبه / ش / 0 | | A a | ق.ظ / ب.ظ |
| Q | quarter 1–4 | | […] | literal escape |
Output uses Persian digits by default — pass { digits: 'en' } or { digits: 'ar' }.
Parsing
parseJalali('1403/01/01') // { jy: 1403, jm: 1, jd: 1 }
parseJalali('۱۴۰۳/۰۱/۰۱') // same — Persian digits accepted
parseJalali('1 فروردین 1403', 'D MMMM YYYY') // { jy: 1403, jm: 1, jd: 1 }
parseJalali('1402/12/30') // null — not a real date
parseJalali('1402/12/30', 'YYYY/MM/DD', { strict: false }) // parsed anyway
parseJalaliDate('1403/01/01') // a DateRelative time
import { fromNow } from 'persianist/date'
fromNow(new Date(Date.now() - 3 * 60_000)) // '۳ دقیقه پیش'
fromNow({ jy: 1403, jm: 5, jd: 9 }, { now: { jy: 1403, jm: 5, jd: 10 } }) // 'دیروز'
fromNow(inTwoDays) // 'پسفردا'
fromNow(lastMonth) // '۲ ماه پیش'Days and above are measured in calendar days, not elapsed milliseconds — so 23:00
last night reads «دیروز», not «۲ ساعت پیش», which is what a Persian speaker would say.
A bare JalaliDate carries no clock, so it never produces an hour or minute result;
it answers «امروز» instead.
{ numeric: 'always' } suppresses «دیروز»/«فردا»/«پریروز»/«پسفردا» in favour of counts.
{ now } fixes the reference point — useful for tests, and for rendering a list against
one timestamp. timeAgo is an alias.
Working days
import { addJalaliBusinessDays, isJalaliBusinessDay } from 'persianist/date'
isJalaliBusinessDay(1403, 1, 1) // false — نوروز
isJalaliBusinessDay(1403, 1, 10) // false — a جمعه
addJalaliBusinessDays({ jy: 1403, jm: 1, jd: 8 }, 5) // { jy: 1403, jm: 1, jd: 16 }
diffJalaliBusinessDays(deadline, today) // working days betweenAlso nextJalaliBusinessDay and previousJalaliBusinessDay. diffJalaliBusinessDays
is the exact inverse of addJalaliBusinessDays — the starting date is never counted, so
"five working days from now" means five days of work after today.
Iran's official weekend is جمعه alone, which is the default. Banks and government offices commonly close پنجشنبه too and the private sector varies, so it is configurable rather than assumed:
addJalaliBusinessDays(date, 5, { weekend: [5, 6] }) // پنجشنبه and جمعه off
addJalaliBusinessDays(date, 5, { holidays: false }) // weekend only, no calendarHolidays count as non-working by default, so these functions inherit the lunar-holiday caveat: a religious holiday's date is estimated from the tabular Hijri calendar and can be a day out. For a deadline that money depends on, pass
{ holidays: false }and apply a calendar you control.
Durations, countdowns and age
import { formatDuration, jalaliAge, remainingTime } from 'persianist/date'
formatDuration(90_061_000) // '۱ روز و ۱ ساعت'
formatDuration(90_061_000, { units: 4 }) // '۱ روز و ۱ ساعت و ۱ دقیقه و ۱ ثانیه'
remainingTime(deadline)
// { isPast: false, totalMs: 183600000, days: 2, hours: 3,
// minutes: 0, seconds: 0, text: '۲ روز و ۳ ساعت' }
jalaliAge({ jy: 1370, jm: 5, jd: 10 }) // { years: 33, months: 0, days: 0 }fromNow gives one phrase; remainingTime gives the structure a countdown needs.
totalMs is always positive and isPast carries the direction; the components are a
remainder breakdown, not four separate totals.
formatDuration shows only the largest non-zero units — a two-day span does not trail
seconds nobody reads, and '۲ روز و ۵ دقیقه' skips the hours it does not have.
jalaliAge counts in Jalali months rather than 30-day blocks, so the year ticks over on
the birthday and a borrow takes the real length of the preceding month — 30 for a leap
اسفند. Returns null for a future or impossible date.
Calendar grids
import { eachJalaliDay, getJalaliMonthMatrix } from 'persianist/date'
eachJalaliDay({ jy: 1403, jm: 1, jd: 1 }, { jy: 1403, jm: 1, jd: 5 }) // 5 dates
eachJalaliDay(start, end, { step: 7 }) // one per week
const weeks = getJalaliMonthMatrix(1403, 1)
weeks.length // 5
weeks[0][0] // { date: {…}, inMonth: false, weekday: 0, isWeekend: false }getJalaliMonthMatrix returns the grid a datepicker renders: rows of seven starting on
شنبه, padded with the neighbouring months' days so no row is short — inMonth tells
them apart. Pass { fixedWeeks: true } for six rows every month, which stops a
datepicker changing height as the user pages through. { firstDayOfWeek } moves the
starting column.
eachJalaliDay is inclusive at both ends and returns [] when the range runs backwards,
rather than silently walking it in reverse.
Hijri (قمری) dates
import { toHijri, jalaliToHijri, hijriToJalali, HIJRI_MONTH_NAMES } from 'persianist/date'
jalaliToHijri(1403, 1, 1) // { hy: 1445, hm: 9, hd: 10 }
hijriToJalali(1445, 9, 10) // { jy: 1403, jm: 1, jd: 1 }
HIJRI_MONTH_NAMES[8] // 'رمضان'This is the tabular Islamic calendar, and Iran's is not. Months here alternate 30 and 29 days with 11 leap years per 30; Iran fixes its Hijri dates by crescent sighting announced from Tehran, which no arithmetic can predict. Measured against the Umm al-Qura calendar over 847 month starts (1412–1482 AH), this lands on the same day 54.8% of the time, within one day 98.6%, and never more than two days out. Use it to display an approximate Hijri date, not to schedule anything.
The math is cross-checked against Intl.DateTimeFormat('en-u-ca-islamic-civil') for every
day from 1900 to 2100 — 73,050 days, zero mismatches. h2d and d2h are exported
alongside g2d/d2g/j2d/d2j, so a known offset is one addition away:
d2h(j2d(jy, jm, jd) + 1).
Also: hijriMonthLength, hijriYearLength, isLeapHijriYear, isValidHijriDate,
gregorianToHijri, hijriToGregorian, nowHijri.
Holidays
import { getJalaliOccasions, isIranianHoliday } from 'persianist/date'
isIranianHoliday(1403, 1, 1) // true — نوروز
isIranianHoliday(1403, 1, 10) // true — a جمعه
isIranianHoliday(1403, 1, 8) // false
getJalaliOccasions(1403, 1, 1)[0]
// { title: 'نوروز', titleEn: 'Nowruz', holiday: true, calendar: 'solar', estimated: false }The solar holidays sit on fixed Jalali dates and are exact. The lunar ones are
resolved through the Hijri calendar above, so every one of them comes back with
estimated: true — they can land a day either side of the official announcement. Check
that flag before putting a date in front of anyone who will plan around it.
جمعه counts as a holiday; pass { weekend: false } to ask about the calendar alone.
This is the official public-holiday list — 10 solar and 17 lunar — not an exhaustive calendar of مناسبتها. Observed-but-working days (چهارشنبهسوری, شب یلدا) and movable observances are out of scope.
Validators
Every isX returns a boolean and never throws, for any input. Every
normalizeX / formatX / getXInfo returns null on invalid input rather than
throwing — this composes better in form validation. All of them accept Persian and
Arabic-Indic digits, and tolerate spaces and dashes.
National ID (کد ملی)
isValidNationalId('0499370899') // true
isValidNationalId('۰۴۹۹۳۷۰۸۹۹') // true
isValidNationalId('1111111111') // false — repdigits are never issued
normalizeNationalId('12345679') // '0012345679' — left-padded
formatNationalId('0499370899') // '049-937089-9'Sheba / IBAN
isValidSheba('IR580540105180021273113007') // true
isValidSheba('IR58 0540 1051 8002 1273 1130 07') // true
isValidSheba('580540105180021273113007') // true — bare form
formatSheba('IR580540105180021273113007') // 'IR58 0540 1051 8002 1273 1130 07'
getShebaInfo('IR580540105180021273113007')?.bank?.name // 'بانک پارسیان'Bank cards
isValidCardNumber('6037991199500590') // true
isValidCardNumber('0000000000000000') // false
formatCardNumber('6037991234567893') // '6037-9912-3456-7893'
getBankFromCardNumber('6037991234567893')?.slug // 'melli'Mobile and landline
isValidMobile('+989121234567') // true
normalizeMobile('+989121234567') // '09121234567'
normalizeMobile('09121234567', 'international') // '+989121234567'
getMobileOperator('09351234567') // 'irancell'
getMobileInfo('09121234567')?.province // 'تهران'
isValidLandline('02112345678') // true
getLandlineInfo('03112345678')?.province // 'اصفهان'Postal code
isValidPostalCode('1619735514') // true
isValidPostalCode('1111111111') // false
formatPostalCode('1619735514') // '16197-35514'Legal-entity ID and economic code
isValidLegalId('10380284790') // true — a company's 11-digit شناسهٔ ملی
formatLegalId('10380284790') // '10380-28479-0'
isValidEconomicCode('10380284790') // true — a company
isValidEconomicCode('0499370899') // true — a sole trader
getEconomicCodeInfo('10380284790')?.type // 'legal'The شناسهٔ ملی is the company equivalent of a کد ملی: 11 digits, a different checksum,
and not interchangeable with the individual's 10-digit number. Since 1398 there is no
separate کد اقتصادی — a company's is its شناسهٔ ملی and an individual's is their کد ملی —
so isValidEconomicCode dispatches on length rather than inventing a third algorithm.
The شناسهٔ ملی checksum folds remainders of both 0 and 10 to a check digit of
0. For the tenth of identifiers ending in zero, one substitution per position slips through undetected. That is the published algorithm, not a shortcut taken here — treat this as well-formedness, not proof of registration.
Bills
isValidBill('7748317800142', '1770160') // true
getBillType('7748317800142') // 'landline'
getBillAmount('1770160') // 17000 — ریال
getBillInfo('7748317800142', '1770160')
// { billId: '7748317800142', paymentId: '1770160',
// amount: 17000, type: 'landline', typeName: 'تلفن ثابت' }Every Iranian utility bill carries a شناسهٔ قبض / شناسهٔ پرداخت pair. The payment ID's final check digit is computed over both numbers, so a payment ID that is internally consistent still fails against the wrong bill — which is exactly the mistake a payment form needs to catch:
isValidBillId('1117213000142') // true — a valid bill on its own
isValidPaymentId('1117213000142', '1770160') // false — but not this bill's paymenttype comes from the digit before the bill ID's check digit: water, electricity,
gas, landline, mobile, municipality, traffic-fine, or unknown. Amounts are in
rials — pass through rialToToman from persianist/number to display them.
Extracting from free text
import { extractCardNumbers, extractMobiles, extractNationalIds } from 'persianist/validate'
extractCardNumbers('کارتم ۶۰۳۷-۹۹۱۲-۳۴۵۶-۷۸۹۳ هست') // ['6037991234567893']
extractMobiles('تماس: ۰۹۱۲۱۲۳۴۵۶۷ یا +989351234567')
// ['09121234567', '09351234567']
extractNationalIds('کد ملی ۰۴۹۹۳۷۰۸۹۹') // ['0499370899']Also extractShebas, extractPostalCodes and extractPlates. Each one is a permissive
pattern followed by the validator above it, so a 16-digit run that fails Luhn is a phone
number someone spaced oddly, not a card. Values come back normalized rather than as
they appeared, so they go straight into a lookup or a database column.
The extractors do not find each other's values. Given a message containing all six,
extractNationalIds returns the کد ملی and not the first ten digits of the card — every
pattern is anchored against adjacent digits, and card numbers require one separator style
throughout.
extractCardNumbers(`${CARD} ${CARD}`) // deduplicated by default
extractCardNumbers(text, { unique: false }) // every occurrence
extractCardNumbers('6037991234567890', { validate: false })
// ['6037991234567890'] — right shape, failed checksum{ validate: false } is for "this looks like a card number, did you mistype it?" flows.
Plates are the one loose case: «و» is both a plate letter and the word "and", so a spaced plate is only recognized when «ایران» is present.
۱۲ ب ۳۴۵ ۲۲is missed;۱۲ ب ۳۴۵ ایران ۲۲and the compact12ب34522are not. Without that rule, «۱۴ و ۳۴۵ نفر ۲۲» in ordinary prose parses as a valid plate.
For positions, use findIdentifiers, which returns every kind at once:
findIdentifiers('کارت ۶۰۳۷۹۹۱۲۳۴۵۶۷۸۹۳')
// [{ type: 'card', value: '6037991234567893', raw: '۶۰۳۷۹۹۱۲۳۴۵۶۷۸۹۳', start: 5, end: 21 }]raw is the text exactly as written and start/end index the string you passed in, so
text.slice(start, end) === raw always holds — digit folding is one character to one
character, which is what makes the offsets usable. Matches come back in document order
and never overlap.
Masking and redaction
import { maskCardNumber, maskMobile, redactText } from 'persianist/validate'
maskCardNumber('6037991234567893') // '6037-****-****-7893'
maskNationalId('0499370899') // '******0899'
maskMobile('09121234567') // '0912***4567'
maskSheba(sheba) // 'IR5805****************3007'
redactText('کارتم ۶۰۳۷-۹۹۱۲-۳۴۵۶-۷۸۹۳ و شماره ۰۹۱۲۱۲۳۴۵۶۷')
// 'کارتم ۶۰۳۷-****-****-۷۸۹۳ و شماره ۰۹۱۲***۴۵۶۷'Only digits are masked, so separators, the IR of a Sheba and the letter of a plate
survive and the result still reads as the thing it is. The defaults follow what Iranian
banking and telecom UIs actually show — the card BIN and last four, the mobile operator
prefix and last four — and keepStart / keepEnd / mask override them.
redactText masks each match as it was written: Persian digits stay Persian, the
writer's own separators stay put, and everything outside a match is untouched. It is
idempotent, so redacting an already-redacted string is a no-op.
redactText(text, { types: ['card'] }) // only cards
redactText(text, { keepStart: 0, keepEnd: 0 }) // hide every digit
redactText(text, { validate: false }) // mask on shape, not checksum
{ validate: false }is the safer setting when the output is a log: by default an order number that happens to be sixteen digits is left alone, because it fails Luhn. Turning validation off masks anything of the right shape.
None of this is a security control. A masked value is for a screen or a log line — it is not anonymized data, and the digits left showing are often enough to identify someone in combination with anything else.
License plates
parsePlate('۱۲ ب ۳۴۵ ایران ۲۲')
// { left: '12', letter: 'ب', right: '345', region: '22',
// category: 'private', categoryName: 'شخصی',
// normalized: '12ب34522', formatted: '۱۲ ب ۳۴۵ ایران ۲۲' }
parsePlate('55 ت 111 ایران 22')?.category // 'taxi'
formatPlate('12ب34522') // '۱۲ ب ۳۴۵ ایران ۲۲'
normalizePlate('12-ب-345-ایران-22') // '12ب34522' — a stable storage keyAccepts any way a plate gets typed: Persian or Arabic digits, «ایران» spelled out or
left off, «الف» written in full, Arabic ي/ك, and any mix of spaces and dashes.
category covers the letters whose meaning is unambiguous — taxi, government,
public-transport, disabled, police, army, sepah, armed-forces, diplomat,
political, private — and is 'unknown' otherwise, which as everywhere here means
"unrecognized", not "invalid".
The regional code is not resolved to a province. A complete, verifiable code → province table is not something this package can source, and a partial one that silently returns
nullfor most of the country would be worse than none — the same reasonCity.countyIdis leftnull.regionis exposed as a plain string so you can map it against a table you trust.
Motorcycle plates use a different layout and are out of scope.
Numbers and currency
Everything here accepts a number, a bigint, or a string in any of the three digit
sets, and tolerates the separators people actually type. Every function returns null
rather than throwing when the input is not a number.
Words
import { numberToOrdinalWords, numberToPersianWords, wordsToNumber } from 'persianist/number'
numberToPersianWords(1234) // 'یک هزار و دویست و سی و چهار'
numberToPersianWords(-3.14) // 'منفی سه ممیز چهارده صدم'
numberToPersianWords(1000, { omitLeadingOne: true }) // 'هزار'
numberToOrdinalWords(3) // 'سوم'
numberToOrdinalWords(30) // 'سیام'
numberToOrdinalWords(1, { classic: true }) // 'اول'
wordsToNumber('دو هزار و پانصد') // 2500
wordsToNumber('سه ممیز چهارده صدم') // 3.14
wordsToNumber('۲۰ هزار') // 20000 — digits and words mixedSpelling goes through the digit strings, never a double, so amounts past
Number.MAX_SAFE_INTEGER come out intact — pass them as a string or a bigint.
Persian uses the long scale: میلیارد is 10⁹ and بیلیون is 10¹², not the
American billion. MAX_WORD_DIGITS (30) is the ceiling; beyond it there is no
settled Persian name, so the result is null rather than an invention.
wordsToNumber is strict: one unrecognized token and the whole parse returns null,
so a typo can never silently become a smaller number. Scales must descend, which is why
'هزار میلیون' is rejected.
Formatting
import { formatCompact, formatNumber, parseNumber, toPersianOrdinal } from 'persianist/number'
formatNumber(1234567) // '۱٬۲۳۴٬۵۶۷'
formatNumber(1234567, { digits: 'en' }) // '1,234,567'
formatNumber(1234.567, { decimals: 2 }) // '۱٬۲۳۴٫۵۷'
formatCompact(2_500_000) // '۲٫۵ میلیون'
formatCompact(3.2e12) // '۳٫۲ هزار میلیارد'
toPersianOrdinal(3) // '۳م'
toPersianOrdinal(30) // '۳۰ام'
parseNumber('۱٬۲۳۴٫۵') // 1234.5
parseNumber('۱۲/۵') // 12.5 — the handwriting decimalThe default separators are ٬ (U+066C) and ٫ (U+066B), the Persian pair — not the
ASCII comma and period, which they only resemble in a Latin font. Choosing
{ digits: 'en' } switches both to , and .; separator and decimalSeparator
override either. Rounding is half-away-from-zero and carries across the decimal point,
so formatNumber(9.99, { decimals: 1 }) is '۱۰٫۰'.
Options: digits, grouping, separator, decimalSeparator, groupSize, decimals,
and precision for formatCompact.
Currency
import { formatCurrency, rialToToman, tomanToRial } from 'persianist/number'
formatCurrency(50_000) // '۵۰٬۰۰۰ تومان'
formatCurrency(50_000, { currency: 'rial' }) // '۵۰٬۰۰۰ ریال'
formatCurrency(50_000, { words: true }) // 'پنجاه هزار تومان'
formatCurrency(2_500_000, { compact: true }) // '۲٫۵ میلیون تومان'
formatCurrency(50_000, { unit: false }) // '۵۰٬۰۰۰'
rialToToman(12_500) // 1250
tomanToRial(1250) // 12_500Iran's currency is the rial; prices are quoted in tomans. Mixing the two up by a factor
of ten is the classic bug in an Iranian checkout, so the conversion is explicit and
currency defaults to 'toman' — the unit a user is reading. rialToToman keeps a
remainder rather than rounding it away: an amount that is not a whole toman is a data
problem worth seeing.
Geography
31 provinces (استان), 435 counties (شهرستان) and 1,407 cities (شهر), each with Persian and English names, a slug, coordinates and population where known. Cities are administrative seats plus every place of 2,000 or more people.
Lookup and hierarchy
import {
getProvinces, getProvince, getCountiesOfProvince,
getCitiesOfProvince, getCapitalOfProvince, getProvinceOfCity,
} from 'persianist/geo'
getProvinces().length // 31
getProvince('26')?.name // 'تهران' — by GeoNames admin1 code
getProvince('isfahan')?.name // 'اصفهان' — by slug
getCountiesOfProvince('26').length // counties of Tehran province
getCapitalOfProvince('42')?.name // 'مشهد'
getProvinceOfCity(city)?.nameEn // 'Fars'getProvince() accepts an id, an admin1 code, or a slug, so you can pass whichever
identifier you happen to hold.
Building a location picker
Province → City is the cascade to build on: every one of the 1,407 cities has an exact province, so the second level is always complete.
import { getProvinces, getCitiesOfProvince } from 'persianist/geo'
const provinces = getProvinces({ sort: 'name' })
const cities = getCitiesOfProvince(provinceCode, { minPopulation: 50_000, sort: 'name' })sort takes 'name' (Persian alphabetical, via comparePersian), 'nameEn', or
'population' (largest first). Without it you get dataset order, and the cached array is
returned as-is — no allocation on the common lookup, and no way for a caller to reorder
the dataset for everyone else.
minPopulation is what makes the list usable. The dataset includes every place of 2,000
people and up, so Tehran province returns 111 cities unfiltered — mostly villages.
At 50,000 that becomes 27, and at 100,000 it becomes 16.
Cities with no recorded population are kept rather than dropped: all 45 of them are county seats, and silently removing a شهرستان seat from a location picker would be worse than letting a few small places through. A test pins that premise.
Do not build Province → County → City. GeoNames records a parent county for only 4.8%
of cities — 67 of 1,407 — so getCitiesOfCounty returns nothing for 378 of the 435
counties, including تهران itself. The county tier is exact as its own level
(getCountiesOfProvince covers all 435) but cannot act as a parent for cities. See
What the county tier can and cannot tell you.
For 1,407 cities, one fuzzy search field often beats two dependent selects:
searchCities(query, { provinceId: '26' }) // tolerant of typos, Arabic ي/ك, Persian digitsSearch
Tolerant of Arabic letter forms, diacritics, tatweel, ZWNJ, Persian digits and case —
it runs the same normalize() pipeline documented above.
import { findPlace, searchPlaces, searchCities } from 'persianist/geo'
findPlace('اصفهان')?.nameEn // 'Isfahan'
findPlace('كرمانشاه')?.nameEn // 'Kermanshah' (Arabic ك)
findPlace('اصفهــان')?.nameEn // 'Isfahan' (tatweel)
findPlace('mashad')?.nameEn // 'Mashhad' (typo)
searchPlaces('آباد', { provinceId: '26', limit: 10 })
searchCities('شیراز') // [{ place, kind: 'city', score: 1 }, …]Results are ranked exact → prefix → substring → fuzzy, with ties broken toward the more
populous place. Pass { fuzzy: false } for strict matching.
Coordinates and distance
import { distanceBetween, nearestPlace, placesWithin } from 'persianist/geo'
distanceBetween(tehran, mashhad) // ≈ 737 (km, haversine)
nearestPlace({ lat: 35.7, lon: 51.4 }) // { place, distanceKm }
placesWithin({ lat: 35.7, lon: 51.4 }, 50) // nearest firstLinking a phone number to a province
getLandlineInfo() returns a provinceId that the geo module resolves. It is a plain
string rather than a resolved object on purpose — otherwise every persianist/validate
bundle would drag in the whole place dataset.
import { getLandlineInfo } from 'persianist/validate'
import { getProvince } from 'persianist/geo'
const info = getLandlineInfo('03112345678')
getProvince(info.provinceId)?.name // 'اصفهان'What the county tier can and cannot tell you
County → Province is exact, and City → Province is exact for all 1,407 cities.
City.countyId is null for most cities. GeoNames records a county for only a small
minority of places, and there is no reliable way to infer the rest — a nearest-centroid
guess, constrained to the correct province, measured 64% accurate against the rows
where the truth is known. Rather than state a parent that is wrong one time in three, this
package leaves it null. As everywhere else here, null means "not recorded", never
"none". Use the province for parentage you can rely on.
Accuracy and data freshness
The Jalali math is the Borkowski algorithm, valid for Jalali years −61 to 2455
(MIN_JALALI_YEAR / MAX_JALALI_YEAR); outside that range jalCal throws a
RangeError. It is cross-checked against Intl.DateTimeFormat('en-u-ca-persian') for
every day from 1901-01-01 to 2099-12-31 — 72,684 days, zero mismatches — and the test
suite round-trips every day from 1300/01/01 to 1450/12/29.
The Hijri conversion is the tabular Islamic calendar, exact as arithmetic and
approximate as a calendar — see the caveat above. Everything derived from it, which means
every lunar holiday, inherits that and is flagged estimated: true. The holiday tables
are the official list as it stood 2026-08; Iran adds one-off closures by decree, and those
are not predictable from any calendar.
The checksum algorithms (national ID, شناسهٔ ملی, Sheba mod-97, card Luhn, bill and
payment IDs, postal code) are stable. The lookup tables are not: bank BIN assignments
shift after mergers, MVNO mobile prefixes get reallocated, and area codes change.
getBankFromCardNumber, getMobileOperator, getLandlineInfo, getBillType and
parsePlate().category are best-effort — a null or 'unknown' result means
"unrecognized", never "invalid". Tables were verified 2026-08 and live under
src/validate/data/.
The geography data is a snapshot of the GeoNames Iran dump, generated by
scripts/build-geo.ts and committed under src/geo/data/. Persian names are passed
through this package's own normalize(), which repairs the Arabic ي/ك forms, harakat
and stray bidi marks present in roughly 17% of upstream rows. Regenerate with
npm run build:geo (needs Bun).
Credits
Geographic data © GeoNames, licensed under CC BY 4.0. Modified: filtered to provinces, counties and cities; Persian names normalized; English province names replaced with curated forms. No endorsement by GeoNames is implied.
Roadmap
Three lookup tables are wanted but deliberately absent, all for the same reason — no open dataset this package can verify against:
- کد ملی → issuing city. The first three digits of a national ID are a کد شهرستان.
getNationalIdInfo().areaCodeexposes them; the ~500-row table to resolve them does not exist here. - Plate region → province. See the note under License plates.
- Postal-code prefix → region. Needs an Iran Post prefix table.
On the geography side: villages (آبادی, ~60,000 records — large enough to warrant its own package) and districts (بخش).
If you have an authoritative source for any of the three, that is the fastest way to unblock them.
Contributing
npm install
npm run test # or test:watch
npm run typecheck
npm run check # biome + tsc
npm run build
npm run verify # everything, plus publint and attwLicense
MIT © Samyar Modabber
