diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3f050d..a1990e8 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
+
+- `validatePlate` / `isValidPlate` for Romanian registration plates, including county lookup and input normalization
+
## [0.1.0] - 2026-10-27
### Added
diff --git a/README.md b/README.md
index a82e679..77d80f1 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**, postal codes and licence plates) 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
@@ -25,13 +25,13 @@ The package is not on the npm registry yet; this installs it straight from GitHu
- **Current rules.** Accepts county code `70`, which the civil-status system (SIIEASC) now assigns to newly generated CNPs. Many older validators reject these valid codes.
- **Explains the error.** Messages are in Romanian and say what is wrong: `Cifra de control este greșită (așteptat 7)`.
-- **Decodes, not just validates.** Sex, birth date, county, bank code.
+- **Decodes, not just validates.** Sex, birth date, county, bank code and plate county.
- **Private.** Everything runs locally. Nothing is sent anywhere.
## Usage
```ts
-import { validateCnp, validateCui, validateIbanRo } from "validro";
+import { validateCnp, validateCui, validateIbanRo, validatePlate } 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 } }
+
+validatePlate("cj-12 abc");
+// { valid: true, error: null, data: { plate: "CJ12ABC", countyCode: "CJ", county: "Cluj" } }
+
+validatePlate("B 123 ABC");
+// { valid: true, error: null, data: { plate: "B123ABC", countyCode: "B", county: "București" } }
```
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 `isValidPlate`.
## API
@@ -71,11 +77,13 @@ 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` |
+| `validatePlate(plate)` | `ValidationResult`; normalized plate and county details |
+| `isValidCnp`, `isValidCui`, `isValidIbanRo`, `isValidPostalCode`, `isValidPlate` | `boolean` |
| `cnpControlDigit(first12)` | the CNP control digit |
| `cuiControlDigit(body)` | the CUI control digit |
| `ibanChecksumOk(iban)` | mod-97 check, works for any country |
-| `CNP_COUNTIES` | county codes and names |
+| `CNP_COUNTIES` | county codes and names for CNPs |
+| `PLATE_COUNTIES` | registration-plate county codes and names |
## What is checked
@@ -85,6 +93,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 |
+| Licence plate | known Romanian county code, 2 digits and 3 letters; București (`B`) accepts 2 or 3 digits; spaces, dots and dashes are ignored |
Spaces, dots and dashes in the input are ignored.
@@ -102,7 +111,7 @@ All examples in this repository are fictitious: sample CNPs use birth dates in t
## Roadmap
-- [ ] Romanian licence plates
+- [x] Romanian licence plates
- [ ] Phone numbers, normalized to `+40`
- [x] Postal codes
- [ ] Python port
diff --git a/src/index.ts b/src/index.ts
index 6758db9..78ce769 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 { validatePlate, isValidPlate, PLATE_COUNTIES } from "./plate.js";
+export type { PlateInfo } from "./plate.js";
export { CNP_COUNTIES } from "./counties.js";
export type { ValidationResult } from "./result.js";
diff --git a/src/plate.ts b/src/plate.ts
new file mode 100644
index 0000000..c4ac14e
--- /dev/null
+++ b/src/plate.ts
@@ -0,0 +1,79 @@
+import { clean, fail, ok, type ValidationResult } from "./result.js";
+
+export const PLATE_COUNTIES: Readonly> = Object.freeze({
+ AB: "Alba",
+ AR: "Arad",
+ AG: "Argeș",
+ BC: "Bacău",
+ BH: "Bihor",
+ BN: "Bistrița-Năsăud",
+ BT: "Botoșani",
+ BV: "Brașov",
+ BR: "Brăila",
+ BZ: "Buzău",
+ CS: "Caraș-Severin",
+ CL: "Călărași",
+ CT: "Constanța",
+ CV: "Covasna",
+ DB: "Dâmbovița",
+ DJ: "Dolj",
+ GL: "Galați",
+ GR: "Giurgiu",
+ GJ: "Gorj",
+ HR: "Harghita",
+ HD: "Hunedoara",
+ IL: "Ialomița",
+ IS: "Iași",
+ IF: "Ilfov",
+ MM: "Maramureș",
+ MH: "Mehedinți",
+ MS: "Mureș",
+ NT: "Neamț",
+ OT: "Olt",
+ PH: "Prahova",
+ SM: "Satu Mare",
+ SJ: "Sălaj",
+ SB: "Sibiu",
+ SV: "Suceava",
+ TR: "Teleorman",
+ TM: "Timiș",
+ TL: "Tulcea",
+ VS: "Vaslui",
+ VL: "Vâlcea",
+ VN: "Vrancea",
+ B: "București",
+});
+
+export interface PlateInfo {
+ plate: string;
+ countyCode: string;
+ county: string;
+}
+
+/**
+ * Validates the standard Romanian registration-plate format.
+ * Spaces, dots and dashes are ignored; the returned plate is uppercase and compact.
+ */
+export function validatePlate(input: string): ValidationResult {
+ if (typeof input !== "string") return fail("Numărul de înmatriculare trebuie să fie un șir de caractere");
+
+ const plate = clean(input).toUpperCase();
+ const bucharestMatch = /^B(\d{2,3})([A-Z]{3})$/.exec(plate);
+ const countyMatch = /^([A-Z]{2})(\d{2})([A-Z]{3})$/.exec(plate);
+
+ let countyCode: string;
+ if (bucharestMatch) {
+ countyCode = "B";
+ } else if (countyMatch) {
+ countyCode = countyMatch[1];
+ } else {
+ return fail("Numărul trebuie să conțină indicativul județului, 2 cifre (3 pentru București sunt permise) și 3 litere");
+ }
+
+ const county = PLATE_COUNTIES[countyCode];
+ if (!county) return fail("Indicativul județului nu este valid");
+
+ return ok({ plate, countyCode, county });
+}
+
+export const isValidPlate = (input: string): boolean => validatePlate(input).valid;
diff --git a/test/plate.test.ts b/test/plate.test.ts
new file mode 100644
index 0000000..45c861f
--- /dev/null
+++ b/test/plate.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import { isValidPlate, validatePlate } from "../src/index.js";
+
+describe("validatePlate", () => {
+ it("accepts county and Bucharest plates and returns county data", () => {
+ expect(validatePlate("CJ 12 ABC")).toEqual({
+ valid: true,
+ error: null,
+ data: { plate: "CJ12ABC", countyCode: "CJ", county: "Cluj" },
+ });
+ expect(validatePlate("B 123 ABC").data).toEqual({
+ plate: "B123ABC",
+ countyCode: "B",
+ county: "București",
+ });
+ expect(validatePlate("B-12-ABC").valid).toBe(true);
+ });
+
+ it("normalizes separators and letter case", () => {
+ expect(validatePlate(" cj-12 abc ").data).toEqual({
+ plate: "CJ12ABC",
+ countyCode: "CJ",
+ county: "Cluj",
+ });
+ expect(validatePlate("b.123.abc").data?.plate).toBe("B123ABC");
+ });
+
+ it("accepts known county codes", () => {
+ expect(isValidPlate("AB12ABC")).toBe(true);
+ expect(isValidPlate("IF12ABC")).toBe(true);
+ expect(isValidPlate("VN12ABC")).toBe(true);
+ });
+
+ it.each([
+ "CJ1ABC",
+ "CJ1234ABC",
+ "CJ12AB",
+ "CJ12ABCD",
+ "XX12ABC",
+ "B1ABC",
+ "B1234ABC",
+ "B12AB",
+ "B123ABCD",
+ "12ABC",
+ ])("rejects invalid plate %s", (plate) => {
+ expect(isValidPlate(plate)).toBe(false);
+ });
+
+ it("explains malformed input", () => {
+ expect(validatePlate("CJ1ABC").error).toContain("indicativul județului");
+ });
+});