From 33ac3b84c89695e947f714809bc581921d8faec4 Mon Sep 17 00:00:00 2001
From: Aaqibhafeez Khan
Date: Wed, 28 Oct 2026 06:34:33 +0530
Subject: [PATCH] feat: add Romanian phone number validation
---
CHANGELOG.md | 4 ++++
README.md | 18 +++++++++++++-----
src/index.ts | 2 ++
src/phone-ro.ts | 39 +++++++++++++++++++++++++++++++++++++++
test/phone-ro.test.ts | 34 ++++++++++++++++++++++++++++++++++
5 files changed, 92 insertions(+), 5 deletions(-)
create mode 100644 src/phone-ro.ts
create mode 100644 test/phone-ro.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3f050d..d986813 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project are documented here. The format follows [Kee
## [Unreleased]
+### Added
+
+- `validatePhoneRo` / `isValidPhoneRo` for Romanian mobile and landline numbers, normalized to E.164
+
## [0.1.0] - 2026-10-27
### Added
diff --git a/README.md b/README.md
index a82e679..011c30e 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
Try it in your browser
-Validate and decode Romanian identifiers (**CNP**, **CUI/CIF**, **IBAN** and postal codes) with error messages you can show directly to users. Zero dependencies, fully typed, runs in Node and in the browser.
+Validate and decode Romanian identifiers (**CNP**, **CUI/CIF**, **IBAN**, phone numbers and postal codes) with error messages you can show directly to users. Zero dependencies, fully typed, runs in Node and in the browser.
```bash
npm install github:Robert-GeorgeDev/ValidRO
@@ -31,7 +31,7 @@ The package is not on the npm registry yet; this installs it straight from GitHu
## Usage
```ts
-import { validateCnp, validateCui, validateIbanRo } from "validro";
+import { validateCnp, validateCui, validateIbanRo, validatePhoneRo } from "validro";
validateCnp("3000101221147");
// {
@@ -51,6 +51,12 @@ validateIbanRo("ro49 aaaa 1b31 0075 9384 0000");
// { valid: true, error: null,
// data: { iban: "RO49AAAA1B31007593840000", formatted: "RO49 AAAA 1B31 0075 9384 0000",
// bankCode: "AAAA", treasury: false } }
+
+validatePhoneRo("0723 456 789");
+// { valid: true, error: null, data: { normalized: "+40723456789" } }
+
+validatePhoneRo("0040723456789");
+// { valid: true, error: null, data: { normalized: "+40723456789" } }
```
Every `validate*` function returns the same shape, so you can handle all of them the same way:
@@ -61,7 +67,7 @@ type ValidationResult =
| { valid: false; error: string; data: null };
```
-Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo` and `isValidPostalCode`.
+Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo`, `isValidPostalCode` and `isValidPhoneRo`.
## API
@@ -71,7 +77,8 @@ Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo` and `is
| `validateCui(cui)` | `ValidationResult` |
| `validateIbanRo(iban)` | `ValidationResult` |
| `validatePostalCode(postalCode)` | `ValidationResult`; exactly 6 digits |
-| `isValidCnp`, `isValidCui`, `isValidIbanRo`, `isValidPostalCode` | `boolean` |
+| `validatePhoneRo(phone)` | `ValidationResult`; `data.normalized` is in E.164 format |
+| `isValidCnp`, `isValidCui`, `isValidIbanRo`, `isValidPostalCode`, `isValidPhoneRo` | `boolean` |
| `cnpControlDigit(first12)` | the CNP control digit |
| `cuiControlDigit(body)` | the CUI control digit |
| `ibanChecksumOk(iban)` | mod-97 check, works for any country |
@@ -85,6 +92,7 @@ Only need true or false? Use `isValidCnp`, `isValidCui`, `isValidIbanRo` and `is
| CUI / CIF | optional `RO` prefix, 2–10 digits, no leading zero, control digit (key `753217532`) |
| IBAN (RO) | 24 characters: `RO`, 2 check digits, 4-letter bank code, 16 alphanumerics, ISO 13616 mod-97 |
| Postal code | exactly 6 digits |
+| Phone (RO) | Romanian mobile (`07`) and landline (`02` / `03`) formats; accepts national, `+40` and `0040` prefixes; normalizes to `+40` E.164 |
Spaces, dots and dashes in the input are ignored.
@@ -103,7 +111,7 @@ All examples in this repository are fictitious: sample CNPs use birth dates in t
## Roadmap
- [ ] Romanian licence plates
-- [ ] Phone numbers, normalized to `+40`
+- [x] Phone numbers, normalized to `+40`
- [x] Postal codes
- [ ] Python port
diff --git a/src/index.ts b/src/index.ts
index 6758db9..3155707 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,5 +6,7 @@ export { validateIbanRo, isValidIbanRo, ibanChecksumOk } from "./iban.js";
export type { IbanInfo } from "./iban.js";
export { validatePostalCode, isValidPostalCode } from "./postal-code.js";
export type { PostalCodeInfo } from "./postal-code.js";
+export { validatePhoneRo, isValidPhoneRo } from "./phone-ro.js";
+export type { PhoneRoInfo } from "./phone-ro.js";
export { CNP_COUNTIES } from "./counties.js";
export type { ValidationResult } from "./result.js";
diff --git a/src/phone-ro.ts b/src/phone-ro.ts
new file mode 100644
index 0000000..940792d
--- /dev/null
+++ b/src/phone-ro.ts
@@ -0,0 +1,39 @@
+import { clean, fail, ok, type ValidationResult } from "./result.js";
+
+export interface PhoneRoInfo {
+ /** Romanian phone number normalized to E.164 format. */
+ normalized: string;
+}
+
+/**
+ * Validates a Romanian mobile or landline number and normalizes it to E.164.
+ * Accepts national numbers (07..., 02... or 03...) and +40 / 0040 prefixes.
+ * Spaces, dots and dashes are ignored.
+ */
+export function validatePhoneRo(input: string): ValidationResult {
+ if (typeof input !== "string") return fail("Numărul de telefon trebuie să fie un șir de caractere");
+
+ const phone = clean(input);
+ let nationalNumber: string;
+
+ if (phone.startsWith("+40")) {
+ nationalNumber = phone.slice(3);
+ } else if (phone.startsWith("0040")) {
+ nationalNumber = phone.slice(4);
+ } else if (/^0/.test(phone)) {
+ if (!/^0[237]\d{8}$/.test(phone)) {
+ return fail("Numărul de telefon trebuie să fie un număr mobil sau fix din România");
+ }
+ nationalNumber = phone.slice(1);
+ } else {
+ return fail("Numărul de telefon trebuie să înceapă cu 0, +40 sau 0040");
+ }
+
+ if (!/^[237]\d{8}$/.test(nationalNumber)) {
+ return fail("Numărul de telefon trebuie să fie un număr mobil sau fix din România");
+ }
+
+ return ok({ normalized: `+40${nationalNumber}` });
+}
+
+export const isValidPhoneRo = (input: string): boolean => validatePhoneRo(input).valid;
diff --git a/test/phone-ro.test.ts b/test/phone-ro.test.ts
new file mode 100644
index 0000000..0b04b72
--- /dev/null
+++ b/test/phone-ro.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { isValidPhoneRo, validatePhoneRo } from "../src/phone-ro.js";
+
+describe("Romanian phone number", () => {
+ it("normalizes national mobile numbers", () => {
+ expect(validatePhoneRo("0723 456 789")).toEqual({
+ valid: true,
+ error: null,
+ data: { normalized: "+40723456789" },
+ });
+ expect(validatePhoneRo("07-23-456-789").data).toEqual({ normalized: "+40723456789" });
+ });
+
+ it("accepts international prefixes and normalizes to E.164", () => {
+ expect(validatePhoneRo("+40 723 456 789").data).toEqual({ normalized: "+40723456789" });
+ expect(validatePhoneRo("0040723456789").data).toEqual({ normalized: "+40723456789" });
+ });
+
+ it("accepts Romanian landline numbers", () => {
+ expect(validatePhoneRo("021 345 6789").data).toEqual({ normalized: "+40213456789" });
+ expect(validatePhoneRo("0313456789").data).toEqual({ normalized: "+40313456789" });
+ });
+
+ it("rejects unsupported prefixes and malformed lengths", () => {
+ expect(isValidPhoneRo("0123456789")).toBe(false);
+ expect(isValidPhoneRo("072345678")).toBe(false);
+ expect(isValidPhoneRo("+44123456789")).toBe(false);
+ expect(isValidPhoneRo("not a phone number")).toBe(false);
+ });
+
+ it("returns Romanian error messages", () => {
+ expect(validatePhoneRo("123").error).toContain("trebuie să înceapă");
+ });
+});