npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

persianist

Zero-dependency TypeScript toolkit for Persian/Farsi text, Jalali (Shamsi) and Hijri dates, Iranian validators, Persian numbers and currency, and Iranian geography.

npm license types

🇮🇷 مستندات فارسی — 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 persianist

Quick 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/geo carries ~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

applyHalfSpaces is 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 the standard preset.

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-word

Options: 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. containsPersian makes 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 «تهران» into taharan, 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 Date

Timezones

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 })   // 366

Also: 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 Date

Relative 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 between

Also 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 calendar

Holidays 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 payment

type 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 compact 12ب34522 are 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 key

Accepts 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 null for most of the country would be worse than none — the same reason City.countyId is left null. region is 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 mixed

Spelling 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 decimal

The 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_500

Iran'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 digits

Search

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 first

Linking 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().areaCode exposes 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 attw

License

MIT © Samyar Modabber