@handijit/tr 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,1435 @@
1
- # Temporary Holding Version
1
+ # @handijit/tr
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@handijit/tr.svg)](https://www.npmjs.com/package/@handijit/tr)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@handijit/tr.svg)](https://www.npmjs.com/package/@handijit/tr)
5
+ [![license](https://img.shields.io/npm/l/@handijit/tr.svg)](./LICENSE)
6
+
7
+ Text, validation and formatting helpers for software that is used in Turkey:
8
+ upper and lower case that get "i" and "ı" right, alphabetical sorting, search
9
+ keys and slugs, T.C. identity number, tax number, IBAN and phone validation,
10
+ lira amounts in digits and in words, `dd.MM.yyyy` dates, the 81 provinces and
11
+ licence plates. Every function is pure and gives the same answer in Node, in a
12
+ browser and in Electron, whatever locale the machine is set to.
13
+
14
+ - **0 runtime dependencies.**
15
+ - **4.36 kB gzip** for the whole minified bundle (3.96 kB
16
+ brotli). Every function is its own module and the package is marked
17
+ `"sideEffects": false`, so a bundler keeps only what you import.
18
+ - **No locale data needed.** Casing, sorting, money and the month and day names
19
+ are implemented in the package; nothing depends on the ICU data the runtime
20
+ was built with or on the locale of the machine. `Intl.DateTimeFormat` is used
21
+ in one place only: to move an instant into a time zone.
22
+ - **Money never touches a float.** Amounts are whole kuruş (`number`, safe
23
+ integers) from parsing to formatting.
24
+ - **Linear time, bounded memory.** Every function is O(n) in the length of its
25
+ input, every regular expression is linear, and the two internal caches are
26
+ capped (8 date formatters, 81 province names).
27
+ - **1200 tests, 100% statement, branch, function and line coverage**,
28
+ plus a type test for every signature. The examples in this file are checked
29
+ by a test as well.
30
+ - **ESM, CommonJS, a `<script>` bundle and TypeScript declarations** in one
31
+ package. Node 24 or newer.
32
+
33
+ ## Table of contents
34
+
35
+ - [Requirements](#requirements)
36
+ - [Installation](#installation)
37
+ - [Quick start](#quick-start)
38
+ - [When to use it](#when-to-use-it)
39
+ - [API reference](#api-reference)
40
+ - [Text](#text): [`toUpperCase`](#touppercase),
41
+ [`toLowerCase`](#tolowercase), [`capitalize`](#capitalize),
42
+ [`toTitleCase`](#totitlecase), [`compareTurkish`](#compareturkish),
43
+ [`foldText`](#foldtext), [`slugify`](#slugify)
44
+ - [Identity and bank numbers](#identity-and-bank-numbers):
45
+ [`isValidTckn`](#isvalidtckn), [`isValidVkn`](#isvalidvkn),
46
+ [`isValidIban`](#isvalidiban), [`normalizeIban`](#normalizeiban),
47
+ [`formatIban`](#formatiban)
48
+ - [Phone numbers](#phone-numbers): [`isValidPhone`](#isvalidphone),
49
+ [`getPhoneType`](#getphonetype), [`normalizePhone`](#normalizephone),
50
+ [`formatPhone`](#formatphone)
51
+ - [Money](#money): [`formatMoney`](#formatmoney),
52
+ [`parseMoney`](#parsemoney), [`moneyToWords`](#moneytowords)
53
+ - [Dates](#dates): [`formatDate`](#formatdate),
54
+ [`formatDateTime`](#formatdatetime), [`parseDate`](#parsedate),
55
+ [`getMonthName`](#getmonthname), [`getDayName`](#getdayname),
56
+ [`MONTH_NAMES`](#month_names), [`DAY_NAMES`](#day_names)
57
+ - [Geography](#geography): [`PROVINCES`](#provinces),
58
+ [`getProvinceByCode`](#getprovincebycode),
59
+ [`getProvinceByName`](#getprovincebyname),
60
+ [`isValidLicensePlate`](#isvalidlicenseplate)
61
+ - [Guides](#guides)
62
+ - [Server-side rendering and Electron](#server-side-rendering-and-electron)
63
+ - [Semantics](#semantics)
64
+ - [Performance](#performance)
65
+ - [Bundle size](#bundle-size)
66
+ - [TypeScript](#typescript)
67
+ - [FAQ and troubleshooting](#faq-and-troubleshooting)
68
+ - [Breaking changes](#breaking-changes)
69
+ - [Contributing](#contributing)
70
+ - [Releasing](#releasing)
71
+ - [License](#license)
72
+
73
+ ## Requirements
74
+
75
+ - **Node.js 24 or newer** (`engines.node`). CI runs on Node 24 and 26.
76
+ - **Browsers and Electron:** any engine that runs ES2023 syntax. The build is
77
+ not downlevelled. Beyond the syntax the package needs regular expression
78
+ lookbehind (`toTitleCase`), Unicode property escapes (`toTitleCase`,
79
+ `compareTurkish`) and `Intl.DateTimeFormat` with IANA time zones (the date
80
+ functions, when they are given a `Date` or a timestamp).
81
+ - No DOM, no Node API: the package reads nothing from `window`, `navigator`,
82
+ `process` or the file system.
83
+
84
+ ## Installation
85
+
86
+ ```bash
87
+ npm install @handijit/tr
88
+ ```
89
+
90
+ ```bash
91
+ yarn add @handijit/tr
92
+ ```
93
+
94
+ ```bash
95
+ pnpm add @handijit/tr
96
+ ```
97
+
98
+ ## Quick start
99
+
100
+ ```ts
101
+ import {
102
+ compareTurkish,
103
+ formatDate,
104
+ formatMoney,
105
+ formatPhone,
106
+ isValidTckn,
107
+ moneyToWords,
108
+ parseMoney,
109
+ toUpperCase,
110
+ } from "@handijit/tr";
111
+
112
+ toUpperCase("iyi günler"); // → "İYİ GÜNLER"
113
+ ["Çorum", "Bolu", "Ağrı"].sort(compareTurkish); // → ["Ağrı", "Bolu", "Çorum"]
114
+ isValidTckn("10000000146"); // → true
115
+ formatPhone("+905321234567"); // → "0 (532) 123 45 67"
116
+ parseMoney("3.490,50 TL"); // → 349050
117
+ formatMoney(349050); // → "3.490,50 TL"
118
+ moneyToWords(349050); // → "üç bin dört yüz doksan Türk lirası elli kuruş"
119
+ formatDate("2026-11-10"); // → "10.11.2026"
120
+ ```
121
+
122
+ Every line marked `// →` in this file, and every `// throws` line, is checked
123
+ against the code by `__tests__/readme.test.ts`.
124
+
125
+ ## When to use it
126
+
127
+ Use it when
128
+
129
+ - your interface is in Turkish and `"iyi günler".toUpperCase()` giving
130
+ `"IYI GÜNLER"` is a bug for you,
131
+ - the same text has to come out of the server and out of the browser
132
+ (server-side rendering, PDFs, e-mails, an Electron main and renderer process)
133
+ and you cannot count on both sides having the same locale data,
134
+ - you check a T.C. identity number, a tax number, an IBAN, a phone number or a
135
+ licence plate in a form before it goes to a server,
136
+ - you keep money as whole kuruş and need to read it from, and write it back
137
+ to, the form people type it in (`3.490,50`).
138
+
139
+ Do not use it when
140
+
141
+ - you need more than one locale: use `Intl` or an internationalisation library,
142
+ - you need to know that an identity number belongs to a real person or that an
143
+ IBAN belongs to an open account: these functions check the arithmetic of the
144
+ number, not a registry,
145
+ - you handle phone numbers or IBANs of other countries: only Turkish ones pass,
146
+ - you keep money as a floating-point number of lira: convert to kuruş first.
147
+
148
+ ## API reference
149
+
150
+ All 30 exports are named exports of `@handijit/tr`; there is no default
151
+ export and there are no deep imports.
152
+
153
+ How invalid input is handled follows one rule, spelled out in
154
+ [Semantics](#semantics): the `isValid…` functions accept any value and answer
155
+ `false`; functions that read text a person typed return `null` when it cannot
156
+ be read; a value of the wrong type is a programming error and throws.
157
+
158
+ ### Text
159
+
160
+ #### `toUpperCase`
161
+
162
+ ```ts
163
+ toUpperCase(text: string): string
164
+ ```
165
+
166
+ | Parameter | Type | Default | Description |
167
+ | --------- | -------- | -------- | ------------------- |
168
+ | `text` | `string` | required | Text to upper-case. |
169
+
170
+ **Returns** the text in upper case by the Turkish rule: "i" becomes "İ" and
171
+ "ı" becomes "I". Every other character follows the Unicode default mapping.
172
+
173
+ ```ts
174
+ toUpperCase("iyi günler"); // → "İYİ GÜNLER"
175
+ toUpperCase("ılık su"); // → "ILIK SU"
176
+ "iyi günler".toUpperCase(); // → "IYI GÜNLER"
177
+ ```
178
+
179
+ - The result is the same as `text.toLocaleUpperCase("tr-TR")` on a runtime
180
+ with full locale data, and stays that way on a runtime without it.
181
+ - The default mapping can lengthen the text: "ß" becomes "SS".
182
+ - `toUpperCase("")` is `""`. A value that is not a string throws a
183
+ `TypeError`.
184
+
185
+ #### `toLowerCase`
186
+
187
+ ```ts
188
+ toLowerCase(text: string): string
189
+ ```
190
+
191
+ | Parameter | Type | Default | Description |
192
+ | --------- | -------- | -------- | ------------------- |
193
+ | `text` | `string` | required | Text to lower-case. |
194
+
195
+ **Returns** the text in lower case by the Turkish rule: "I" becomes "ı" and
196
+ "İ" becomes "i".
197
+
198
+ ```ts
199
+ toLowerCase("IŞIK İLE DİYARBAKIR"); // → "ışık ile diyarbakır"
200
+ "IŞIK".toLowerCase(); // → "işik"
201
+ ```
202
+
203
+ - "İ" written as two characters ("I" followed by the combining dot above,
204
+ U+0307) is read as "İ" and becomes a plain "i".
205
+ - One rare sequence differs from `toLocaleLowerCase("tr-TR")`: "I", then some
206
+ other combining mark, then U+0307. See
207
+ [Known limitations](#known-limitations).
208
+
209
+ #### `capitalize`
210
+
211
+ ```ts
212
+ capitalize(text: string): string
213
+ ```
214
+
215
+ | Parameter | Type | Default | Description |
216
+ | --------- | -------- | -------- | ----------------------------------------- |
217
+ | `text` | `string` | required | Text whose first letter is to be capital. |
218
+
219
+ **Returns** the text with its first character upper-cased by the Turkish rule.
220
+ The rest is left exactly as written.
221
+
222
+ ```ts
223
+ capitalize("izmir"); // → "İzmir"
224
+ capitalize("ısparta"); // → "Isparta"
225
+ capitalize("istanbul BÜYÜKŞEHİR"); // → "İstanbul BÜYÜKŞEHİR"
226
+ capitalize(""); // → ""
227
+ ```
228
+
229
+ - The first character is taken by code point, so an emoji or another character
230
+ outside the Basic Multilingual Plane is not cut in half.
231
+ - Leading whitespace is not skipped: `capitalize(" izmir")` changes nothing.
232
+
233
+ #### `toTitleCase`
234
+
235
+ ```ts
236
+ toTitleCase(text: string): string
237
+ ```
238
+
239
+ | Parameter | Type | Default | Description |
240
+ | --------- | -------- | -------- | ----------------------------- |
241
+ | `text` | `string` | required | Text to write in title style. |
242
+
243
+ **Returns** the text with every word starting with a Turkish capital letter
244
+ and continuing in lower case.
245
+
246
+ ```ts
247
+ toTitleCase("ığdır İL özel idaresi"); // → "Iğdır İl Özel İdaresi"
248
+ toTitleCase("ANKARA'DA KIŞ"); // → "Ankara'da Kış"
249
+ toTitleCase("iş-yeri (izmir)"); // → "İş-Yeri (İzmir)"
250
+ toTitleCase("ahmet ve ayşe"); // → "Ahmet Ve Ayşe"
251
+ ```
252
+
253
+ - A word starts at a letter that does not follow a letter, a digit, a
254
+ combining mark or an apostrophe. A suffix after an apostrophe therefore
255
+ stays small ("Ankara'da"), and so does a letter after a digit ("5a").
256
+ - There is no list of small words: "ve", "ile" and "veya" are capitalised
257
+ like any other word.
258
+ - The whole text is lower-cased first, so an abbreviation written in capitals
259
+ ("KDV") comes out as "Kdv".
260
+
261
+ #### `compareTurkish`
262
+
263
+ ```ts
264
+ compareTurkish(left: string, right: string): number
265
+ ```
266
+
267
+ | Parameter | Type | Default | Description |
268
+ | --------- | -------- | -------- | ------------ |
269
+ | `left` | `string` | required | First text. |
270
+ | `right` | `string` | required | Second text. |
271
+
272
+ **Returns** `-1` when `left` sorts before `right` in the Turkish alphabet, `1`
273
+ when it sorts after, and `0` when the two are the same text. Pass it to
274
+ `Array.prototype.sort`.
275
+
276
+ ```ts
277
+ const cities = ["Çorum", "Bolu", "Ağrı", "Iğdır", "İzmir", "Zonguldak"];
278
+ [...cities].sort(compareTurkish); // → ["Ağrı", "Bolu", "Çorum", "Iğdır", "İzmir", "Zonguldak"]
279
+ [...cities].sort(); // → ["Ağrı", "Bolu", "Iğdır", "Zonguldak", "Çorum", "İzmir"]
280
+ compareTurkish("ılık", "ihtiyaç"); // → -1
281
+ compareTurkish("çay", "cuma"); // → 1
282
+ ["kâr", "Kar", "kar", "kara"].sort(compareTurkish); // → ["kar", "Kar", "kâr", "kara"]
283
+ ```
284
+
285
+ - The order of letters is `a b c ç d e f g ğ h ı i j k l m n o ö p q r s ş t
286
+ u ü v w x y z`. ASCII punctuation and spaces sort before digits, digits
287
+ before letters, and every other character after the letters, by code point.
288
+ - Letters decide first. Only when two texts have the same letters does an
289
+ accent decide ("kar" before "kâr"), and after that the case, lower case
290
+ first ("kar" before "Kar").
291
+ - It is not a natural sort: digits are compared one by one, so "a10" sorts
292
+ before "a2".
293
+ - The comparator is a total order (consistent, transitive), so the result of
294
+ a sort does not depend on the engine. It returns `0` only for texts that
295
+ are equal after Unicode composition (NFC).
296
+ - It does not use `Intl.Collator`. On the word list of the test suite it
297
+ agrees with `Intl.Collator("tr-TR")`; it is not a full implementation of
298
+ the Unicode Collation Algorithm.
299
+
300
+ #### `foldText`
301
+
302
+ ```ts
303
+ foldText(text: string): string
304
+ ```
305
+
306
+ | Parameter | Type | Default | Description |
307
+ | --------- | -------- | -------- | ------------------------------- |
308
+ | `text` | `string` | required | Text to turn into a search key. |
309
+
310
+ **Returns** a key for case-insensitive, accent-insensitive search: the text
311
+ in Turkish lower case with "ç ğ ı ö ş ü" replaced by "c g i o s u" and every
312
+ other accent removed. Fold both the text and the query, then compare.
313
+
314
+ ```ts
315
+ foldText("Çağrı IŞIK"); // → "cagri isik"
316
+ foldText("İstanbul"); // → "istanbul"
317
+ foldText("Kâğıt"); // → "kagit"
318
+ foldText("Şişli").includes(foldText("SIS")); // → true
319
+ ```
320
+
321
+ - Both "I" and "İ" end up as "i", so a search matches whichever keyboard the
322
+ query was typed on.
323
+ - Characters are not removed and spaces are not collapsed; only letters
324
+ change. A letter that has no base letter in Unicode keeps its form ("ß"
325
+ stays "ß").
326
+ - The key is for comparing, not for showing: it cannot be turned back into
327
+ the text.
328
+
329
+ #### `slugify`
330
+
331
+ ```ts
332
+ slugify(text: string): string
333
+ ```
334
+
335
+ | Parameter | Type | Default | Description |
336
+ | --------- | -------- | -------- | ------------------------- |
337
+ | `text` | `string` | required | Text to turn into a slug. |
338
+
339
+ **Returns** a slug made of `a` to `z`, `0` to `9` and single hyphens, safe in
340
+ a URL and in a file name.
341
+
342
+ ```ts
343
+ slugify("Şırnak İl Özel İdaresi"); // → "sirnak-il-ozel-idaresi"
344
+ slugify(" Ön Muhasebe & Stok Takibi (2026) "); // → "on-muhasebe-stok-takibi-2026"
345
+ slugify("Kâğıt, Karton"); // → "kagit-karton"
346
+ slugify("!!!"); // → ""
347
+ ```
348
+
349
+ - Every run of other characters becomes one hyphen; hyphens at the ends are
350
+ dropped.
351
+ - The result is empty when the text has no letter or digit that survives.
352
+ Check for `""` before you use it as an identifier.
353
+ - Letters without a Latin base letter are not transliterated:
354
+ `slugify("straße")` is `"stra-e"`.
355
+
356
+ ### Identity and bank numbers
357
+
358
+ The three validators check the arithmetic of a number. They cannot tell
359
+ whether the number was ever issued.
360
+
361
+ #### `isValidTckn`
362
+
363
+ ```ts
364
+ isValidTckn(value: unknown): boolean
365
+ ```
366
+
367
+ | Parameter | Type | Default | Description |
368
+ | --------- | --------- | -------- | ----------------------------- |
369
+ | `value` | `unknown` | required | The identity number, as text. |
370
+
371
+ **Returns** `true` when the value is a string of 11 digits that does not
372
+ start with 0 and whose tenth and eleventh digits are the check digits of the
373
+ first nine.
374
+
375
+ ```ts
376
+ isValidTckn("10000000146"); // → true
377
+ isValidTckn("100 000 001 46"); // → true
378
+ isValidTckn("10000000147"); // → false
379
+ isValidTckn("00000000000"); // → false
380
+ isValidTckn(10000000146); // → false
381
+ ```
382
+
383
+ - Whitespace anywhere in the text is ignored. Any other separator makes the
384
+ value invalid.
385
+ - A number, `null`, `undefined` or any other non-string is `false`, never an
386
+ error: pass `input.value` straight in.
387
+
388
+ #### `isValidVkn`
389
+
390
+ ```ts
391
+ isValidVkn(value: unknown): boolean
392
+ ```
393
+
394
+ | Parameter | Type | Default | Description |
395
+ | --------- | --------- | -------- | ------------------------ |
396
+ | `value` | `unknown` | required | The tax number, as text. |
397
+
398
+ **Returns** `true` when the value is a string of 10 digits whose last digit is
399
+ the check digit of the first nine (the tax identification number, "vergi
400
+ kimlik numarası").
401
+
402
+ ```ts
403
+ isValidVkn("1234567890"); // → true
404
+ isValidVkn("123 456 78 90"); // → true
405
+ isValidVkn("1234567891"); // → false
406
+ isValidVkn("0000000000"); // → false
407
+ isValidVkn(1234567890); // → false
408
+ ```
409
+
410
+ - Whitespace is ignored; a non-string is `false`.
411
+ - A sole proprietor is identified by the 11-digit T.C. identity number
412
+ instead. To accept either, use `isValidVkn(value) || isValidTckn(value)`.
413
+
414
+ #### `isValidIban`
415
+
416
+ ```ts
417
+ isValidIban(value: unknown): boolean
418
+ ```
419
+
420
+ | Parameter | Type | Default | Description |
421
+ | --------- | --------- | -------- | ------------------ |
422
+ | `value` | `unknown` | required | The IBAN, as text. |
423
+
424
+ **Returns** `true` when the value is a Turkish IBAN: "TR", 24 digits, and a
425
+ correct ISO 13616 checksum (mod 97).
426
+
427
+ ```ts
428
+ isValidIban("TR33 0006 1005 1978 6457 8413 26"); // → true
429
+ isValidIban("tr330006100519786457841326"); // → true
430
+ isValidIban("TR33 0006 1005 1978 6457 8413 27"); // → false
431
+ isValidIban("DE89 3704 0044 0532 0130 00"); // → false
432
+ ```
433
+
434
+ - Whitespace and letter case are ignored.
435
+ - IBANs of other countries are `false`, whatever their checksum.
436
+ - The bank code inside the IBAN is not looked up.
437
+
438
+ #### `normalizeIban`
439
+
440
+ ```ts
441
+ normalizeIban(value: string): string | null
442
+ ```
443
+
444
+ | Parameter | Type | Default | Description |
445
+ | --------- | -------- | -------- | ------------------------- |
446
+ | `value` | `string` | required | The IBAN as it was typed. |
447
+
448
+ **Returns** the IBAN in the form to store and to send to a bank, 26
449
+ characters without spaces and in upper case, or `null` when it is not a valid
450
+ Turkish IBAN.
451
+
452
+ ```ts
453
+ normalizeIban("tr33 0006 1005 1978 6457 8413 26"); // → "TR330006100519786457841326"
454
+ normalizeIban("TR33 0006 1005 1978 6457 8413 27"); // → null
455
+ ```
456
+
457
+ #### `formatIban`
458
+
459
+ ```ts
460
+ formatIban(value: string): string | null
461
+ ```
462
+
463
+ | Parameter | Type | Default | Description |
464
+ | --------- | -------- | -------- | -------------------------- |
465
+ | `value` | `string` | required | The IBAN, in any spelling. |
466
+
467
+ **Returns** the IBAN in groups of four for reading, or `null` when it is not
468
+ a valid Turkish IBAN.
469
+
470
+ ```ts
471
+ formatIban("TR330006100519786457841326"); // → "TR33 0006 1005 1978 6457 8413 26"
472
+ formatIban("tr33 0006 1005 1978 6457 8413 26"); // → "TR33 0006 1005 1978 6457 8413 26"
473
+ formatIban("TR33"); // → null
474
+ ```
475
+
476
+ - It formats complete, valid IBANs only. It is not an input mask for a field
477
+ that is still being typed in.
478
+
479
+ ### Phone numbers
480
+
481
+ A Turkish phone number has the country code 90 and a ten-digit national
482
+ number. The first digit of the national number decides what it is: 5 is a
483
+ mobile line, 2, 3 and 4 are geographic (landline) area codes, 8 is a
484
+ non-geographic number such as 0850 and 0800.
485
+
486
+ All four functions read the same spellings: with or without `+90`, `0090`,
487
+ `90` or the leading `0`, and with spaces, hyphens, dots and parentheses as
488
+ separators.
489
+
490
+ #### `isValidPhone`
491
+
492
+ ```ts
493
+ isValidPhone(value: unknown): boolean
494
+ ```
495
+
496
+ | Parameter | Type | Default | Description |
497
+ | --------- | --------- | -------- | -------------------------- |
498
+ | `value` | `unknown` | required | The phone number, as text. |
499
+
500
+ **Returns** `true` when the value is a Turkish phone number in one of the
501
+ spellings above.
502
+
503
+ ```ts
504
+ isValidPhone("0532 123 45 67"); // → true
505
+ isValidPhone("+90 (212) 123 45 67"); // → true
506
+ isValidPhone("532 123 45 67"); // → true
507
+ isValidPhone("444 0 123"); // → false
508
+ isValidPhone("+49 30 123456"); // → false
509
+ ```
510
+
511
+ - Only the length and the first digit of the national number are checked.
512
+ Whether an area code or an operator prefix is in use is not.
513
+ - Short numbers (444 xx xx, 112) and numbers of other countries are `false`.
514
+ - After a `+` the country code is required: `"+532 123 45 67"` is `false`.
515
+ - A non-string is `false`.
516
+
517
+ #### `getPhoneType`
518
+
519
+ ```ts
520
+ getPhoneType(value: string): PhoneType | null
521
+
522
+ type PhoneType = "mobile" | "landline" | "nonGeographic";
523
+ ```
524
+
525
+ | Parameter | Type | Default | Description |
526
+ | --------- | -------- | -------- | ----------------- |
527
+ | `value` | `string` | required | The phone number. |
528
+
529
+ **Returns** the kind of line, or `null` when the value is not a Turkish phone
530
+ number.
531
+
532
+ ```ts
533
+ getPhoneType("0532 123 45 67"); // → "mobile"
534
+ getPhoneType("0212 123 45 67"); // → "landline"
535
+ getPhoneType("0850 123 45 67"); // → "nonGeographic"
536
+ getPhoneType("123"); // → null
537
+ ```
538
+
539
+ - Use `getPhoneType(value) === "mobile"` for a field that must hold a mobile
540
+ number, for example before sending an SMS.
541
+
542
+ #### `normalizePhone`
543
+
544
+ ```ts
545
+ normalizePhone(value: string): string | null
546
+ ```
547
+
548
+ | Parameter | Type | Default | Description |
549
+ | --------- | -------- | -------- | --------------------------------- |
550
+ | `value` | `string` | required | The phone number as it was typed. |
551
+
552
+ **Returns** the number in E.164 form (`+90` and ten digits), the form to store
553
+ and to hand to an SMS or call service, or `null` when the value is not a
554
+ Turkish phone number.
555
+
556
+ ```ts
557
+ normalizePhone("0 (532) 123 45 67"); // → "+905321234567"
558
+ normalizePhone("532-123-45-67"); // → "+905321234567"
559
+ normalizePhone("0090 212 123 45 67"); // → "+902121234567"
560
+ normalizePhone("0532 123 45 6"); // → null
561
+ normalizePhone("+1 212 555 01 00"); // → null
562
+ ```
563
+
564
+ #### `formatPhone`
565
+
566
+ ```ts
567
+ formatPhone(value: string, options?: FormatPhoneOptions): string | null
568
+
569
+ type FormatPhoneOptions = { style?: "national" | "international" };
570
+ ```
571
+
572
+ | Parameter | Type | Default | Description |
573
+ | --------------- | ------------------------------- | ------------ | ------------------------------------------------------------------------------- |
574
+ | `value` | `string` | required | The phone number, in any spelling. |
575
+ | `options.style` | `"national" \| "international"` | `"national"` | `"national"` writes `0 (532) 123 45 67`, `"international"` `+90 532 123 45 67`. |
576
+
577
+ **Returns** the number written for reading, or `null` when the value is not a
578
+ Turkish phone number.
579
+
580
+ ```ts
581
+ formatPhone("+905321234567"); // → "0 (532) 123 45 67"
582
+ formatPhone("05321234567", { style: "international" }); // → "+90 532 123 45 67"
583
+ formatPhone("0212 123 45 67", { style: "international" }); // → "+90 212 123 45 67"
584
+ formatPhone("12345"); // → null
585
+ ```
586
+
587
+ - A `style` that is neither of the two names falls back to `"national"`.
588
+
589
+ ### Money
590
+
591
+ Amounts are whole kuruş: 349050 is 3.490,50 TL. Keep them that way in your
592
+ state, your database and your arithmetic, and call these functions at the
593
+ edges, where a person types or reads the amount.
594
+
595
+ #### `formatMoney`
596
+
597
+ ```ts
598
+ formatMoney(kurus: number, options?: FormatMoneyOptions): string
599
+
600
+ type FormatMoneyOptions = { symbol?: boolean };
601
+ ```
602
+
603
+ | Parameter | Type | Default | Description |
604
+ | ---------------- | --------- | -------- | ----------------------------------------------------------------------- |
605
+ | `kurus` | `number` | required | The amount in whole kuruş. May be negative. |
606
+ | `options.symbol` | `boolean` | `false` | `true` writes the lira sign before the amount instead of `TL` after it. |
607
+
608
+ **Returns** the amount with dots between thousands, a comma before the two
609
+ kuruş digits, and the currency.
610
+
611
+ ```ts
612
+ formatMoney(349050); // → "3.490,50 TL"
613
+ formatMoney(349050, { symbol: true }); // → "₺3.490,50"
614
+ formatMoney(5); // → "0,05 TL"
615
+ formatMoney(-1250); // → "-12,50 TL"
616
+ formatMoney(-1250, { symbol: true }); // → "-₺12,50"
617
+ formatMoney(123456789012); // → "1.234.567.890,12 TL"
618
+ formatMoney(0); // → "0,00 TL"
619
+ ```
620
+
621
+ - The space before `TL` is an ordinary space and the minus is the ASCII
622
+ hyphen-minus, so the text can be compared, searched and parsed back.
623
+ - `-0` is written without a sign.
624
+ - Only `symbol: true` switches the sign on; any other value means `TL`.
625
+ - An amount that is not a whole number, or lies outside the safe integer
626
+ range, throws a `RangeError`; a value that is not a number throws a
627
+ `TypeError`. Nothing is rounded for you:
628
+
629
+ ```ts
630
+ formatMoney(12.5); // throws RangeError: formatMoney: expected a whole number of kuruş within the safe integer range, received 12.5
631
+ formatMoney("12,50"); // throws TypeError: formatMoney: expected an amount in kuruş as a number, received string
632
+ ```
633
+
634
+ #### `parseMoney`
635
+
636
+ ```ts
637
+ parseMoney(text: string): number | null
638
+ ```
639
+
640
+ | Parameter | Type | Default | Description |
641
+ | --------- | -------- | -------- | --------------------------- |
642
+ | `text` | `string` | required | The amount as it was typed. |
643
+
644
+ **Returns** the amount in whole kuruş, or `null` when the text is not an
645
+ amount in Turkish notation.
646
+
647
+ ```ts
648
+ parseMoney("3.490,50 TL"); // → 349050
649
+ parseMoney("₺3.490,50"); // → 349050
650
+ parseMoney("3490,5"); // → 349050
651
+ parseMoney("1250"); // → 125000
652
+ parseMoney("-12,50 TL"); // → -1250
653
+ parseMoney("12.500"); // → 1250000
654
+ parseMoney("1250.50"); // → null
655
+ parseMoney("1,250.50"); // → null
656
+ parseMoney("12,345"); // → null
657
+ parseMoney(""); // → null
658
+ ```
659
+
660
+ - The comma is the decimal separator and a dot is always a thousands
661
+ separator. A dot is accepted only where it closes a complete group of three
662
+ digits, so `"12.500"` is twelve thousand five hundred lira and `"1250.50"`
663
+ is not read at all. The function never guesses.
664
+ - At most two digits may follow the comma. One digit is tens of kuruş:
665
+ `"3490,5"` is 3.490,50.
666
+ - `TL`, `TRY` (in any letter case) or `₺` may stand before or after the
667
+ amount, once. A sign (`+`, `-` or the minus sign U+2212) may stand before
668
+ the amount or before the currency, once.
669
+ - Whitespace is ignored wherever it is, which also reads `"1 250,50"`.
670
+ - An amount beyond the safe integer range is `null`. The result is never
671
+ `-0`.
672
+ - `parseMoney(formatMoney(amount))` gives `amount` back for every amount
673
+ `formatMoney` accepts, with and without `symbol`.
674
+
675
+ #### `moneyToWords`
676
+
677
+ ```ts
678
+ moneyToWords(kurus: number): string
679
+ ```
680
+
681
+ | Parameter | Type | Default | Description |
682
+ | --------- | -------- | -------- | ------------------------------------------- |
683
+ | `kurus` | `number` | required | The amount in whole kuruş. May be negative. |
684
+
685
+ **Returns** the amount in Turkish words, the way it is written out on an
686
+ invoice or a receipt.
687
+
688
+ ```ts
689
+ moneyToWords(349050); // → "üç bin dört yüz doksan Türk lirası elli kuruş"
690
+ moneyToWords(100000); // → "bin Türk lirası"
691
+ moneyToWords(100000000); // → "bir milyon Türk lirası"
692
+ moneyToWords(50); // → "elli kuruş"
693
+ moneyToWords(0); // → "sıfır Türk lirası"
694
+ moneyToWords(-250075); // → "eksi iki bin beş yüz Türk lirası yetmiş beş kuruş"
695
+ ```
696
+
697
+ - Words are separated by single spaces and written in lower case; "Türk
698
+ lirası" keeps its capital. Pass the result through `toUpperCase` or
699
+ `capitalize` if your document needs another style.
700
+ - One thousand is "bin" and one hundred is "yüz", never "bir bin" or "bir
701
+ yüz". The larger units keep it: "bir milyon".
702
+ - An amount below one lira is read by its kuruş alone ("elli kuruş"); a whole
703
+ number of lira has no kuruş part.
704
+ - The largest amount, `Number.MAX_SAFE_INTEGER` kuruş, is in the trillions of
705
+ lira, and "trilyon" is the largest word the function needs.
706
+ - The input rules are those of `formatMoney`:
707
+
708
+ ```ts
709
+ moneyToWords(0.1 + 0.2); // throws RangeError: moneyToWords: expected a whole number of kuruş within the safe integer range, received 0.30000000000000004
710
+ ```
711
+
712
+ ### Dates
713
+
714
+ A date reaches these functions in one of two forms, and they are not the same
715
+ thing:
716
+
717
+ - An **instant**: a `Date` or a timestamp in milliseconds. Which calendar day
718
+ it falls on depends on the time zone, so it is read in `options.timeZone`,
719
+ which defaults to `"Europe/Istanbul"`. The time zone of the machine is never
720
+ used.
721
+ - A **calendar date**: a string in the ISO form `"yyyy-MM-dd"`, what an
722
+ `<input type="date">` and a SQL `DATE` column hold. It has no time and no
723
+ time zone; it is written as it stands and `timeZone` is ignored.
724
+
725
+ #### `formatDate`
726
+
727
+ ```ts
728
+ formatDate(date: Date | number | string, options?: TimeZoneOptions): string
729
+
730
+ type TimeZoneOptions = { timeZone?: string };
731
+ ```
732
+
733
+ | Parameter | Type | Default | Description |
734
+ | ------------------ | -------------------------- | ------------------- | ----------------------------------------------------------- |
735
+ | `date` | `Date \| number \| string` | required | An instant, or a calendar date as `"yyyy-MM-dd"`. |
736
+ | `options.timeZone` | `string` | `"Europe/Istanbul"` | IANA time zone an instant is read in. Ignored for a string. |
737
+
738
+ **Returns** the date as `dd.MM.yyyy`.
739
+
740
+ ```ts
741
+ formatDate("2026-11-10"); // → "10.11.2026"
742
+ formatDate(new Date("2026-11-10T21:30:00Z")); // → "11.11.2026"
743
+ formatDate(new Date("2026-11-10T21:30:00Z"), { timeZone: "UTC" }); // → "10.11.2026"
744
+ formatDate(1794346200000); // → "11.11.2026"
745
+ ```
746
+
747
+ - A string must be a real date in the ISO form. Anything else, including a
748
+ date already written with dots and a full ISO date-time, throws a
749
+ `RangeError`:
750
+
751
+ ```ts
752
+ formatDate("10.11.2026"); // throws RangeError: formatDate: expected a real calendar date like "2026-11-10", received "10.11.2026"
753
+ formatDate("2026-02-30"); // throws RangeError: formatDate: expected a real calendar date like "2026-11-10", received "2026-02-30"
754
+ ```
755
+
756
+ - An invalid `Date`, `NaN`, and an instant outside the years 0001 to 9999
757
+ throw a `RangeError`. A value of another type throws a `TypeError`.
758
+ - An unknown time zone throws a `RangeError`; it is not replaced by the
759
+ default, because the date would then be silently wrong:
760
+
761
+ ```ts
762
+ formatDateTime(0, { timeZone: "Mars/Olympus" }); // throws RangeError: formatDateTime: unknown time zone "Mars/Olympus", expected an IANA name like "Europe/Istanbul"
763
+ ```
764
+
765
+ #### `formatDateTime`
766
+
767
+ ```ts
768
+ formatDateTime(date: Date | number, options?: TimeZoneOptions): string
769
+ ```
770
+
771
+ | Parameter | Type | Default | Description |
772
+ | ------------------ | ---------------- | ------------------- | -------------------------------------- |
773
+ | `date` | `Date \| number` | required | An instant. |
774
+ | `options.timeZone` | `string` | `"Europe/Istanbul"` | IANA time zone the instant is read in. |
775
+
776
+ **Returns** the date and the time of day as `dd.MM.yyyy HH:mm`, on the 24-hour
777
+ clock.
778
+
779
+ ```ts
780
+ formatDateTime(new Date("2026-11-10T11:30:00Z")); // → "10.11.2026 14:30"
781
+ formatDateTime(new Date("2026-11-10T11:30:00Z"), { timeZone: "Europe/Berlin" }); // → "10.11.2026 12:30"
782
+ formatDateTime(new Date("2026-11-10T21:30:00Z")); // → "11.11.2026 00:30"
783
+ formatDateTime(0); // → "01.01.1970 02:00"
784
+ ```
785
+
786
+ - Midnight is `00:00`, never `24:00`. Seconds are cut off, not rounded.
787
+ - A string is not accepted, because a calendar date has no time of day:
788
+
789
+ ```ts
790
+ formatDateTime("2026-11-10"); // throws TypeError: formatDateTime: expected a Date or a timestamp in milliseconds, received string
791
+ ```
792
+
793
+ #### `parseDate`
794
+
795
+ ```ts
796
+ parseDate(text: string): string | null
797
+ ```
798
+
799
+ | Parameter | Type | Default | Description |
800
+ | --------- | -------- | -------- | ---------------------------------- |
801
+ | `text` | `string` | required | A date as it was typed, day first. |
802
+
803
+ **Returns** the calendar date in the ISO form `"yyyy-MM-dd"`, or `null` when
804
+ the text is not a real date written as `dd.MM.yyyy`.
805
+
806
+ ```ts
807
+ parseDate("10.11.2026"); // → "2026-11-10"
808
+ parseDate("1.2.2026"); // → "2026-02-01"
809
+ parseDate("31.02.2026"); // → null
810
+ parseDate("29.02.2026"); // → null
811
+ parseDate("29.02.2024"); // → "2024-02-29"
812
+ parseDate("10/11/2026"); // → null
813
+ parseDate("10.11.26"); // → null
814
+ ```
815
+
816
+ - The day and the month may have one or two digits, the year has exactly
817
+ four. Whitespace around the date is ignored; whitespace inside it is not.
818
+ - A date that does not exist is `null`: the 31st of a 30-day month, the 29th
819
+ of February outside a leap year, month 13, year 0000.
820
+ - The result is a string, not a `Date`, on purpose: a calendar date turned
821
+ into a `Date` picks up a time zone and can slide to the day before. Store
822
+ the string, and build a `Date` only where you need an instant.
823
+ - The result goes straight back into `formatDate`:
824
+
825
+ ```ts
826
+ formatDate(parseDate("5.3.2026") ?? "2000-01-01"); // → "05.03.2026"
827
+ ```
828
+
829
+ #### `getMonthName`
830
+
831
+ ```ts
832
+ getMonthName(date: Date | number | string, options?: TimeZoneOptions): string
833
+ ```
834
+
835
+ | Parameter | Type | Default | Description |
836
+ | ------------------ | -------------------------- | ------------------- | ----------------------------------------------------------- |
837
+ | `date` | `Date \| number \| string` | required | An instant, or a calendar date as `"yyyy-MM-dd"`. |
838
+ | `options.timeZone` | `string` | `"Europe/Istanbul"` | IANA time zone an instant is read in. Ignored for a string. |
839
+
840
+ **Returns** the Turkish name of the month the date falls in.
841
+
842
+ ```ts
843
+ getMonthName("2026-11-10"); // → "Kasım"
844
+ getMonthName(new Date("2026-12-31T22:00:00Z")); // → "Ocak"
845
+ getMonthName(new Date("2026-12-31T22:00:00Z"), { timeZone: "UTC" }); // → "Aralık"
846
+ ```
847
+
848
+ - Invalid input is handled as in [`formatDate`](#formatdate).
849
+
850
+ #### `getDayName`
851
+
852
+ ```ts
853
+ getDayName(date: Date | number | string, options?: TimeZoneOptions): string
854
+ ```
855
+
856
+ | Parameter | Type | Default | Description |
857
+ | ------------------ | -------------------------- | ------------------- | ----------------------------------------------------------- |
858
+ | `date` | `Date \| number \| string` | required | An instant, or a calendar date as `"yyyy-MM-dd"`. |
859
+ | `options.timeZone` | `string` | `"Europe/Istanbul"` | IANA time zone an instant is read in. Ignored for a string. |
860
+
861
+ **Returns** the Turkish name of the day of the week the date falls on.
862
+
863
+ ```ts
864
+ getDayName("2026-11-10"); // → "Salı"
865
+ getDayName(new Date("2026-11-10T21:30:00Z")); // → "Çarşamba"
866
+ getDayName(new Date("2026-11-10T21:30:00Z"), { timeZone: "UTC" }); // → "Salı"
867
+ ```
868
+
869
+ - Invalid input is handled as in [`formatDate`](#formatdate).
870
+
871
+ #### `MONTH_NAMES`
872
+
873
+ ```ts
874
+ MONTH_NAMES: readonly string[]
875
+ ```
876
+
877
+ The twelve month names, January first. The list is frozen.
878
+
879
+ ```ts
880
+ MONTH_NAMES; // → ["Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık"]
881
+ MONTH_NAMES[0]; // → "Ocak"
882
+ ```
883
+
884
+ - The index is the month number minus one, which is also what
885
+ `Date.prototype.getMonth` returns.
886
+
887
+ #### `DAY_NAMES`
888
+
889
+ ```ts
890
+ DAY_NAMES: readonly string[]
891
+ ```
892
+
893
+ The seven day names, **Monday first**, as a Turkish calendar shows them. The
894
+ list is frozen.
895
+
896
+ ```ts
897
+ DAY_NAMES; // → ["Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi", "Pazar"]
898
+ DAY_NAMES[0]; // → "Pazartesi"
899
+ ```
900
+
901
+ - `Date.prototype.getDay` counts from Sunday. To index this list with it, use
902
+ `DAY_NAMES[(date.getDay() + 6) % 7]`, or call [`getDayName`](#getdayname),
903
+ which also takes care of the time zone.
904
+
905
+ ### Geography
906
+
907
+ #### `PROVINCES`
908
+
909
+ ```ts
910
+ PROVINCES: readonly Province[]
911
+
912
+ type Province = { readonly code: number; readonly name: string };
913
+ ```
914
+
915
+ The 81 provinces in the order of their licence plate codes. The list and its
916
+ entries are frozen.
917
+
918
+ ```ts
919
+ PROVINCES.length; // → 81
920
+ PROVINCES[0]; // → { code: 1, name: "Adana" }
921
+ PROVINCES[33]; // → { code: 34, name: "İstanbul" }
922
+ PROVINCES[80]; // → { code: 81, name: "Düzce" }
923
+
924
+ const names = PROVINCES.map((province) => province.name);
925
+ names.sort(compareTurkish).slice(0, 4); // → ["Adana", "Adıyaman", "Afyonkarahisar", "Ağrı"]
926
+ ```
927
+
928
+ - `code` is a number, 1 to 81. Write it as it stands on a plate with
929
+ `String(code).padStart(2, "0")`.
930
+ - `name` is the official name in Turkish spelling: "Afyonkarahisar",
931
+ "Kahramanmaraş", "Şanlıurfa", "Mersin".
932
+ - The list is in plate order, not in alphabetical order. Sort a copy with
933
+ [`compareTurkish`](#compareturkish) for a select box.
934
+
935
+ #### `getProvinceByCode`
936
+
937
+ ```ts
938
+ getProvinceByCode(code: number | string): Province | null
939
+ ```
940
+
941
+ | Parameter | Type | Default | Description |
942
+ | --------- | ------------------ | -------- | -------------------------------------------- |
943
+ | `code` | `number \| string` | required | The plate code as a number or as its digits. |
944
+
945
+ **Returns** the province with that plate code, or `null` when there is none.
946
+
947
+ ```ts
948
+ getProvinceByCode(34); // → { code: 34, name: "İstanbul" }
949
+ getProvinceByCode("06"); // → { code: 6, name: "Ankara" }
950
+ getProvinceByCode(82); // → null
951
+ getProvinceByCode(6.5); // → null
952
+ getProvinceByCode("abc"); // → null
953
+ ```
954
+
955
+ - A string may have one or two digits and spaces around them.
956
+ - The object returned is the entry of `PROVINCES` itself, so two lookups of
957
+ the same code are `===`.
958
+ - A value that is neither a number nor a string throws a `TypeError`.
959
+
960
+ #### `getProvinceByName`
961
+
962
+ ```ts
963
+ getProvinceByName(name: string): Province | null
964
+ ```
965
+
966
+ | Parameter | Type | Default | Description |
967
+ | --------- | -------- | -------- | ------------------ |
968
+ | `name` | `string` | required | The province name. |
969
+
970
+ **Returns** the province with that name, or `null`. Letter case, Turkish
971
+ letters and spaces around the name do not matter: the name is compared by its
972
+ [`foldText`](#foldtext) key.
973
+
974
+ ```ts
975
+ getProvinceByName("istanbul"); // → { code: 34, name: "İstanbul" }
976
+ getProvinceByName("ISTANBUL"); // → { code: 34, name: "İstanbul" }
977
+ getProvinceByName(" şanlıurfa "); // → { code: 63, name: "Şanlıurfa" }
978
+ getProvinceByName("Sanliurfa"); // → { code: 63, name: "Şanlıurfa" }
979
+ getProvinceByName("Urfa"); // → null
980
+ getProvinceByName("İçel"); // → null
981
+ ```
982
+
983
+ - Only the current official names are known. Short and former names ("Urfa",
984
+ "Antep", "Maraş", "Afyon", "İçel", "İzmit", "Adapazarı") are `null`.
985
+ - It matches whole names only; it is not a search. For a search box, filter
986
+ `PROVINCES` by `foldText(province.name).includes(foldText(query))`.
987
+
988
+ #### `isValidLicensePlate`
989
+
990
+ ```ts
991
+ isValidLicensePlate(value: unknown): boolean
992
+ ```
993
+
994
+ | Parameter | Type | Default | Description |
995
+ | --------- | --------- | -------- | ------------------- |
996
+ | `value` | `unknown` | required | The plate, as text. |
997
+
998
+ **Returns** `true` when the value has the shape of a civil licence plate: a
999
+ province code from 01 to 81, then one to three letters, then digits, in one of
1000
+ the six layouts below.
1001
+
1002
+ | Layout | Example |
1003
+ | ------------ | ------------ |
1004
+ | `99 X 9999` | `06 A 1234` |
1005
+ | `99 X 99999` | `06 A 12345` |
1006
+ | `99 XX 999` | `35 AB 123` |
1007
+ | `99 XX 9999` | `35 AB 1234` |
1008
+ | `99 XXX 99` | `01 ABC 12` |
1009
+ | `99 XXX 999` | `34 ABC 123` |
1010
+
1011
+ ```ts
1012
+ isValidLicensePlate("34 ABC 123"); // → true
1013
+ isValidLicensePlate("34abc123"); // → true
1014
+ isValidLicensePlate("06 A 12345"); // → true
1015
+ isValidLicensePlate("82 ABC 123"); // → false
1016
+ isValidLicensePlate("34 QWX 123"); // → false
1017
+ isValidLicensePlate("34 ÇŞ 123"); // → false
1018
+ isValidLicensePlate("34-ABC-123"); // → false
1019
+ ```
1020
+
1021
+ - The spaces between the three parts are optional and letter case does not
1022
+ matter. Other separators make the value invalid.
1023
+ - The letters are the 23 that plates use: no Ç, Ğ, İ, Ö, Ş, Ü and no Q, W, X.
1024
+ - Only the shape is checked. The digit group may start with zeros, and no
1025
+ rule about which letter groups a province hands out is applied.
1026
+ - Plates outside the civil layouts (official, police, military, diplomatic,
1027
+ temporary) are `false`.
1028
+ - Read the province of a valid plate with
1029
+ `getProvinceByCode(plate.trim().slice(0, 2))`.
1030
+
1031
+ ## Guides
1032
+
1033
+ ### Validate a customer form and store clean values
1034
+
1035
+ Validate what was typed, then store the normalised form, so that the same
1036
+ customer is not saved twice under two spellings.
1037
+
1038
+ ```ts
1039
+ import {
1040
+ getPhoneType,
1041
+ isValidTckn,
1042
+ isValidVkn,
1043
+ normalizeIban,
1044
+ normalizePhone,
1045
+ } from "@handijit/tr";
1046
+
1047
+ const form = {
1048
+ taxId: "1234567890",
1049
+ phone: "0 (532) 123 45 67",
1050
+ iban: "tr33 0006 1005 1978 6457 8413 26",
1051
+ };
1052
+
1053
+ // A company has a 10-digit tax number, a sole proprietor an 11-digit identity number.
1054
+ isValidVkn(form.taxId) || isValidTckn(form.taxId); // → true
1055
+ getPhoneType(form.phone) === "mobile"; // → true
1056
+
1057
+ const customer = {
1058
+ taxId: form.taxId.replace(/\s/g, ""),
1059
+ phone: normalizePhone(form.phone),
1060
+ iban: normalizeIban(form.iban),
1061
+ };
1062
+ customer.phone; // → "+905321234567"
1063
+ customer.iban; // → "TR330006100519786457841326"
1064
+ ```
1065
+
1066
+ ### Add up an invoice without losing a kuruş
1067
+
1068
+ Parse each amount once, do the arithmetic on integers, and format at the end.
1069
+ With floating-point lira `0.1 + 0.2` is `0.30000000000000004`; with kuruş
1070
+ `10 + 20` is `30`.
1071
+
1072
+ ```ts
1073
+ import { formatMoney, moneyToWords, parseMoney } from "@handijit/tr";
1074
+
1075
+ const typed = ["1.250,50", "349,90 TL", "0,10"];
1076
+ const amounts = typed.map((text) => parseMoney(text) ?? 0);
1077
+ amounts; // → [125050, 34990, 10]
1078
+
1079
+ const subtotal = amounts.reduce((sum, amount) => sum + amount, 0);
1080
+ // Rounding is your decision, taken once, on an integer.
1081
+ const vat = Math.round((subtotal * 20) / 100);
1082
+ const total = subtotal + vat;
1083
+
1084
+ formatMoney(subtotal); // → "1.600,50 TL"
1085
+ formatMoney(vat); // → "320,10 TL"
1086
+ formatMoney(total, { symbol: true }); // → "₺1.920,60"
1087
+ moneyToWords(total); // → "bin dokuz yüz yirmi Türk lirası altmış kuruş"
1088
+ ```
1089
+
1090
+ ### Search and sort a customer list
1091
+
1092
+ Fold the query once and each name once; sort what is left with
1093
+ `compareTurkish`.
1094
+
1095
+ ```ts
1096
+ import { compareTurkish, foldText } from "@handijit/tr";
1097
+
1098
+ const customers = [
1099
+ "Şahin Ticaret",
1100
+ "Işık Market",
1101
+ "İnci Kırtasiye",
1102
+ "Çınar Yapı",
1103
+ ];
1104
+
1105
+ const query = foldText("ISIK");
1106
+ customers.filter((name) => foldText(name).includes(query)); // → ["Işık Market"]
1107
+
1108
+ [...customers].sort(compareTurkish); // → ["Çınar Yapı", "Işık Market", "İnci Kırtasiye", "Şahin Ticaret"]
1109
+ ```
1110
+
1111
+ For a long list, compute `foldText(name)` once when the list loads, not on
1112
+ every keystroke.
1113
+
1114
+ ### Keep dates as ISO strings, show them with dots
1115
+
1116
+ Store a date of the calendar (an invoice date, a due date) as `"yyyy-MM-dd"`
1117
+ and a moment in time (when a record was created) as a timestamp. Format both
1118
+ for the screen.
1119
+
1120
+ ```ts
1121
+ import {
1122
+ formatDate,
1123
+ formatDateTime,
1124
+ getDayName,
1125
+ parseDate,
1126
+ } from "@handijit/tr";
1127
+
1128
+ const dueDate = parseDate("15.12.2026");
1129
+ dueDate; // → "2026-12-15"
1130
+
1131
+ const createdAt = Date.UTC(2026, 10, 10, 21, 30);
1132
+ formatDateTime(createdAt); // → "11.11.2026 00:30"
1133
+ formatDate(createdAt); // → "11.11.2026"
1134
+ getDayName(createdAt); // → "Çarşamba"
1135
+ ```
1136
+
1137
+ The last three lines say "11 November" although the instant is still 10
1138
+ November in UTC: in Istanbul it is already past midnight. This is the reason
1139
+ the time zone is a parameter with a fixed default and is not taken from the
1140
+ machine.
1141
+
1142
+ ### Fill a province select and read a plate
1143
+
1144
+ ```ts
1145
+ import {
1146
+ compareTurkish,
1147
+ getProvinceByCode,
1148
+ isValidLicensePlate,
1149
+ PROVINCES,
1150
+ } from "@handijit/tr";
1151
+
1152
+ const options = [...PROVINCES].sort((a, b) => compareTurkish(a.name, b.name));
1153
+ options[0]; // → { code: 1, name: "Adana" }
1154
+ options[80]; // → { code: 67, name: "Zonguldak" }
1155
+
1156
+ const plate = "35 AB 1234";
1157
+ isValidLicensePlate(plate); // → true
1158
+ getProvinceByCode(plate.slice(0, 2)); // → { code: 35, name: "İzmir" }
1159
+ ```
1160
+
1161
+ ## Server-side rendering and Electron
1162
+
1163
+ - **No hydration mismatch.** A server in Frankfurt with a minimal Node image
1164
+ and a browser in İzmir produce the same text, because nothing reads the
1165
+ locale or the time zone of the machine. `formatDate(order.createdAt)` in a
1166
+ Next.js Server Component and in a Client Component give the same string.
1167
+ - **No environment check is needed.** The package touches neither `window`
1168
+ nor `process`, so it can be imported in a Server Component, a Client
1169
+ Component, an Edge runtime, a Web Worker, and in both the main and the
1170
+ renderer process of an Electron application.
1171
+ - **`Date` objects from another realm work.** A `Date` that crossed an
1172
+ `iframe` or an Electron context boundary fails `instanceof Date`; the date
1173
+ functions read it all the same.
1174
+ - **Nothing happens at import time.** No lookup table is built and no
1175
+ formatter is created until a function that needs it is called.
1176
+
1177
+ ## Semantics
1178
+
1179
+ | Input | `isValid…` | Read what a person typed (`parseMoney`, `parseDate`, `normalize…`, `formatIban`, `formatPhone`, `getPhoneType`, `getProvinceBy…`) | Write a value of the program (`formatMoney`, `moneyToWords`, `formatDate`, `formatDateTime`, `getMonthName`, `getDayName`, text functions) |
1180
+ | -------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
1181
+ | A value of the wrong type (`null`, `undefined`, a number for a text) | `false` | throws `TypeError` | throws `TypeError` |
1182
+ | Text or a number that is well typed but not valid | `false` | `null` | throws `RangeError` |
1183
+ | Empty string | `false` | `null` | text functions return `""`; date functions throw `RangeError` |
1184
+ | Whitespace around the value | ignored | ignored | kept (text functions) |
1185
+ | An option outside its documented values (`style`, `symbol`) | not applicable | the default is used | the default is used |
1186
+ | An unknown `timeZone` | not applicable | not applicable | throws `RangeError` |
1187
+
1188
+ More precisely:
1189
+
1190
+ | Case | Result |
1191
+ | --------------------------------------------- | -------------------------------------------------------------------------- |
1192
+ | Whitespace inside a TCKN, VKN, IBAN or amount | ignored |
1193
+ | Whitespace inside a date (`"10. 11. 2026"`) | `null` |
1194
+ | Kuruş with a fraction, `NaN`, `±Infinity` | `RangeError` |
1195
+ | Kuruş beyond `Number.MAX_SAFE_INTEGER` | `RangeError` when formatting, `null` when parsing |
1196
+ | `-0` kuruş | formatted as zero, never produced by `parseMoney` |
1197
+ | `bigint` kuruş | `TypeError` |
1198
+ | An invalid `Date`, a `NaN` timestamp | `RangeError` |
1199
+ | An instant outside the years 0001 to 9999 | `RangeError` |
1200
+ | A `Date` from another realm | accepted |
1201
+ | An object that only looks like a `Date` | `TypeError` |
1202
+ | A boxed string (`new String("x")`) | `false` from `isValid…`, `TypeError` elsewhere |
1203
+ | Text in decomposed Unicode form (NFD) | `compareTurkish`, `foldText` and `toLowerCase` read it as the same letters |
1204
+
1205
+ Error messages start with the name of the function and say what was expected
1206
+ and what arrived, without echoing the value of a wrong type:
1207
+
1208
+ ```ts
1209
+ toUpperCase(34); // throws TypeError: toUpperCase: expected a string, received number
1210
+ getProvinceByCode(null); // throws TypeError: getProvinceByCode: expected a plate code as a number or a string, received null
1211
+ formatDate(new Date(Number.NaN)); // throws RangeError: formatDate: received an invalid date
1212
+ ```
1213
+
1214
+ ## Performance
1215
+
1216
+ `npm run bench` measures the built package (`build/index.mjs`), after three
1217
+ warm-up rounds, for a fixed number of calls. The table is the output of that
1218
+ script on the machine named in its first line; expect other absolute numbers
1219
+ on yours.
1220
+
1221
+ Node v26.11.0 · Apple M5 · darwin/arm64
1222
+
1223
+ | Scenario | Iterations | Total (ms) | µs/op |
1224
+ | -------------------------------------- | ---------: | ---------: | -----: |
1225
+ | toUpperCase: 15 characters | 1,000,000 | 85.8 | 0.086 |
1226
+ | toUpperCase: 10 000 characters | 2,000 | 68.2 | 34.113 |
1227
+ | toTitleCase: 15 characters | 1,000,000 | 291.0 | 0.291 |
1228
+ | foldText: 15 characters | 1,000,000 | 213.5 | 0.213 |
1229
+ | foldText: 10 000 characters | 2,000 | 92.7 | 46.343 |
1230
+ | slugify: 15 characters | 1,000,000 | 304.1 | 0.304 |
1231
+ | compareTurkish: two province names | 1,000,000 | 298.1 | 0.298 |
1232
+ | compareTurkish: sort 81 province names | 10,000 | 934.1 | 93.407 |
1233
+ | isValidTckn: valid | 1,000,000 | 38.3 | 0.038 |
1234
+ | isValidVkn: valid | 1,000,000 | 84.2 | 0.084 |
1235
+ | isValidIban: valid, with spaces | 1,000,000 | 229.3 | 0.229 |
1236
+ | formatIban: compact input | 1,000,000 | 338.9 | 0.339 |
1237
+ | normalizePhone: 0 (532) 123 45 67 | 1,000,000 | 103.3 | 0.103 |
1238
+ | formatPhone: +905321234567 | 1,000,000 | 98.4 | 0.098 |
1239
+ | formatMoney: 349050 | 1,000,000 | 26.9 | 0.027 |
1240
+ | formatMoney: largest safe amount | 1,000,000 | 49.3 | 0.049 |
1241
+ | parseMoney: 3.490,50 TL | 1,000,000 | 106.7 | 0.107 |
1242
+ | moneyToWords: 349050 | 1,000,000 | 209.9 | 0.210 |
1243
+ | moneyToWords: largest safe amount | 1,000,000 | 549.6 | 0.550 |
1244
+ | formatDate: calendar date string | 1,000,000 | 83.4 | 0.083 |
1245
+ | formatDate: instant | 200,000 | 274.0 | 1.370 |
1246
+ | formatDateTime: instant | 200,000 | 280.7 | 1.403 |
1247
+ | getDayName: instant | 200,000 | 288.1 | 1.441 |
1248
+ | parseDate: 10.11.2026 | 1,000,000 | 87.7 | 0.088 |
1249
+ | getProvinceByCode: 34 | 1,000,000 | 3.8 | 0.004 |
1250
+ | getProvinceByName: İstanbul | 1,000,000 | 125.1 | 0.125 |
1251
+ | isValidLicensePlate: 34 ABC 123 | 1,000,000 | 15.1 | 0.015 |
1252
+
1253
+ Cold start, measured once in a fresh process: first formatDateTime 6.83 ms, first getProvinceByName 0.14 ms.
1254
+
1255
+ - The text functions grow linearly with the length of the text.
1256
+ - The first call of a date function with a given time zone builds an
1257
+ `Intl.DateTimeFormat`; later calls reuse it. Formatters are kept for the 8
1258
+ most recently introduced time zones.
1259
+ - A calendar date string never reaches `Intl`, which is why it is formatted
1260
+ an order of magnitude faster than an instant.
1261
+ - `getProvinceByName` builds its 81-entry lookup on the first call.
1262
+
1263
+ ## Bundle size
1264
+
1265
+ `npm run size` prints this table from the build output and fails when the
1266
+ minified bundle grows beyond its gzip budget of 5000 B.
1267
+
1268
+ | File | Raw | Gzip | Brotli |
1269
+ | -------------------- | -------- | -------- | ------- |
1270
+ | `build/index.mjs` | 29.05 kB | 10.02 kB | 8.65 kB |
1271
+ | `build/index.cjs` | 29.73 kB | 10.11 kB | 8.73 kB |
1272
+ | `build/index.umd.js` | 9.44 kB | 4.36 kB | 3.96 kB |
1273
+ | `build/index.d.ts` | 6.68 kB | 2.37 kB | 2.01 kB |
1274
+
1275
+ The table was produced on Node 26. The gzip column depends on the zlib that
1276
+ Node bundles and differs by a percent or so between Node versions; the raw
1277
+ sizes do not.
1278
+
1279
+ The ESM and CommonJS files are not minified, so that a stack trace in your
1280
+ application points at readable code; your bundler minifies them with the rest
1281
+ of your code and drops the functions you do not import.
1282
+
1283
+ | Consumer | Entry point | How it is chosen |
1284
+ | -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------- |
1285
+ | ESM (`import`) | `build/index.mjs` | `exports["."].import`, `module` |
1286
+ | CommonJS (`require`) | `build/index.cjs` | `exports["."].require`, `main` |
1287
+ | `<script>` and CDNs | `build/index.umd.js` | `unpkg`, `jsdelivr`; global `handijitTr` |
1288
+ | TypeScript | `build/index.d.mts` for `import`, `build/index.d.ts` for `require` | the `types` condition of each branch, top-level `types` |
1289
+
1290
+ ```html
1291
+ <script src="https://unpkg.com/@handijit/tr"></script>
1292
+ <script>
1293
+ handijitTr.formatMoney(349050);
1294
+ </script>
1295
+ ```
1296
+
1297
+ Source maps are published next to every JavaScript file.
1298
+
1299
+ ## TypeScript
1300
+
1301
+ The declarations ship with the package; there is no `@types` package to
1302
+ install. Five types are exported next to the functions:
1303
+
1304
+ ```ts
1305
+ import type {
1306
+ FormatMoneyOptions,
1307
+ FormatPhoneOptions,
1308
+ PhoneType,
1309
+ Province,
1310
+ TimeZoneOptions,
1311
+ } from "@handijit/tr";
1312
+ ```
1313
+
1314
+ - The `isValid…` functions take `unknown`, so a value from a form library or
1315
+ from `JSON.parse` needs no cast. They return `boolean`, not a type
1316
+ predicate: a valid TCKN is still just a `string`.
1317
+ - Functions that can fail on what a person typed return `T | null`, never
1318
+ `undefined`, so `??` and a strict null check both work on the result.
1319
+ - `PROVINCES`, `MONTH_NAMES` and `DAY_NAMES` are `readonly` in the types and
1320
+ frozen at run time. Copy before you sort: `[...PROVINCES].sort(…)`.
1321
+ - With `noUncheckedIndexedAccess`, `MONTH_NAMES[0]` is `string | undefined`.
1322
+ `getMonthName` and `getDayName` return a plain `string`.
1323
+ - The declarations resolve under `node10`, `node16` and `bundler` module
1324
+ resolution, for `import` and for `require`: the package ships the same
1325
+ declarations twice, as `index.d.mts` and as `index.d.ts`, and
1326
+ `npm run verify` checks the result with `attw`.
1327
+
1328
+ ## FAQ and troubleshooting
1329
+
1330
+ **Why not `toLocaleUpperCase("tr-TR")`, `Intl.Collator` and
1331
+ `Intl.NumberFormat`?**
1332
+ They are correct where the runtime has Turkish locale data, and silently fall
1333
+ back to another locale where it does not (a Node build with reduced ICU
1334
+ data, some embedded and edge runtimes). Their output also comes from the
1335
+ locale data of the runtime, which is not the same in every engine and every
1336
+ version, so a snapshot test or a server-rendered page can differ from what
1337
+ the browser prints. This package gives you one fixed, documented output.
1338
+
1339
+ **Is a number that passes `isValidTckn` a real identity number?**
1340
+ No. It has correct check digits, which catches typing mistakes. Whether it
1341
+ belongs to a person can only be answered by the population registry.
1342
+
1343
+ **`parseMoney("1250.50")` is `null`. Why?**
1344
+ In Turkish notation the dot separates thousands and the comma separates
1345
+ kuruş. `1250.50` is English notation; reading it would mean guessing, and
1346
+ `"12.500"` shows how badly a guess can go. If your input is a JavaScript
1347
+ number literal, you do not need a parser: `Math.round(Number(text) * 100)`.
1348
+
1349
+ **`formatMoney(12.5)` throws. Why does it not round?**
1350
+ Because 12.5 kuruş is a bug upstream, in a division or a percentage. Decide
1351
+ how to round where the fraction is created, then pass the integer.
1352
+
1353
+ **The date is one day off.**
1354
+ You are probably formatting an instant and expecting the calendar day of
1355
+ another time zone. An instant is read in `"Europe/Istanbul"` unless you pass
1356
+ `timeZone`. If the value is a date without a time (a birthday, a due date),
1357
+ keep it as `"yyyy-MM-dd"` and pass the string: it is never shifted.
1358
+
1359
+ **`formatDate("2026-11-10T21:30:00Z")` throws.**
1360
+ A string is taken as a calendar date and must be exactly `"yyyy-MM-dd"`. For
1361
+ an ISO date-time, pass `new Date(text)`.
1362
+
1363
+ **`getProvinceByName("Urfa")` is `null`.**
1364
+ Only the 81 official names are matched. Map the short names you want to
1365
+ accept in your own code.
1366
+
1367
+ **My phone number is valid but `isValidPhone` says `false`.**
1368
+ Short numbers such as `444 0 123`, emergency numbers and numbers of other
1369
+ countries are outside what this function accepts. It accepts ten-digit
1370
+ national numbers that start with 2, 3, 4, 5 or 8.
1371
+
1372
+ ### Known limitations
1373
+
1374
+ - `toLowerCase`: for "I" followed by a combining mark other than the dot
1375
+ above and then by U+0307, `toLocaleLowerCase("tr-TR")` removes the U+0307
1376
+ and keeps "i"; this function gives "ı" and keeps the marks. For every
1377
+ single code point of Unicode, `toUpperCase` and `toLowerCase` match the
1378
+ `tr-TR` mappings of the runtime the tests run on.
1379
+ - `toTitleCase` has no list of small words and no list of abbreviations.
1380
+ - `compareTurkish` is not a natural sort and not a full Unicode collation.
1381
+ - `isValidIban` is for Turkish IBANs only and does not look up the bank code.
1382
+ - `isValidPhone` does not know which area codes and operator prefixes are in
1383
+ use.
1384
+ - `isValidLicensePlate` knows the six civil layouts only.
1385
+ - `getProvinceByName` knows no short or former names.
1386
+ - The date functions cover the years 0001 to 9999 and the proleptic Gregorian
1387
+ calendar.
1388
+
1389
+ ## Breaking changes
1390
+
1391
+ None. 1.0.0 is the first release. From here on, any change in what a function
1392
+ returns for an input that was already accepted, including the correction of a
1393
+ wrong answer, is released as a new major version and listed here in a table
1394
+ of old and new behaviour.
1395
+
1396
+ ## Contributing
1397
+
1398
+ ```bash
1399
+ npm install
1400
+ npm run verify # lint → typecheck → test → build → size → lint:package
1401
+ npm run bench # after a build; prints the table in "Performance"
1402
+ ```
1403
+
1404
+ - `npm test` runs 1200 tests in 44 files, in Node and
1405
+ in jsdom, and fails below 100% statement, branch, function or line coverage.
1406
+ - Every module and helper under `lib/` has a test file of the same name
1407
+ under `__tests__/`.
1408
+ - Identity numbers, tax numbers and IBANs in the tests are generated from
1409
+ made-up digits by the helpers in `__tests__/support/`. Do not add a number
1410
+ that belongs to a person or a company.
1411
+ - A new export goes into `lib/index.ts`, `__tests__/index.test.ts`,
1412
+ `__tests__/types.test.ts`, this file and `CHANGELOG.md`.
1413
+
1414
+ ## Releasing
1415
+
1416
+ 1. Move the entries under `Unreleased` in `CHANGELOG.md` to a section for the
1417
+ new version.
1418
+ 2. `npm version patch|minor|major`.
1419
+ 3. Push to `master`.
1420
+
1421
+ The `Publish to npm` workflow then checks whether that version is on npm. If
1422
+ it is not, it runs every check of `npm run verify`, installs the packed
1423
+ tarball into an empty project and runs it as ESM and as CommonJS, publishes
1424
+ with the `NPM_ACCESS_TOKEN` secret and a signed provenance statement, pushes
1425
+ the annotated tag `v<version>` and creates the GitHub Release from the
1426
+ CHANGELOG section. A push that does not change the version publishes nothing
1427
+ and is not a failure.
1428
+
1429
+ While the package is not on npm at all, the workflow runs all the checks,
1430
+ publishes nothing and says so in its summary: the first version is published
1431
+ by a maintainer with `npm publish --access public`.
1432
+
1433
+ ## License
1434
+
1435
+ [MIT](./LICENSE) © Han Dijit