@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/CHANGELOG.md +46 -0
- package/LICENSE +21 -0
- package/README.md +1434 -2
- package/build/index.cjs +789 -0
- package/build/index.cjs.map +1 -0
- package/build/index.d.mts +136 -0
- package/build/index.d.ts +136 -0
- package/build/index.mjs +758 -0
- package/build/index.mjs.map +1 -0
- package/build/index.umd.js +2 -0
- package/build/index.umd.js.map +1 -0
- package/package.json +98 -4
package/README.md
CHANGED
|
@@ -1,3 +1,1435 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @handijit/tr
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@handijit/tr)
|
|
4
|
+
[](https://www.npmjs.com/package/@handijit/tr)
|
|
5
|
+
[](./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
|