TL;DR

A Romanian CNP validator checks five things: 13 digits, a first digit from 1 to 9, a birth date that exists and is not in the future, a county code (01 to 48, 51, 52, or 70, which the new civil status system has used since May 2024) and the check digit. The check digit is the sum of the first 12 digits multiplied by the matching digits of 279146358279, mod 11, with a remainder of 10 written as 1. Store a valid CNP encrypted, with a keyed hash for lookups, because a plain SHA-256 of one can be brute forced in about half an hour on one CPU core.

Your checkout, payroll or rental contract form asks for a Romanian personal numeric code (CNP), and a 13-digit regex accepts every typo. A Romanian CNP validator has to check more than the length: a digit for sex and century, a birth date that exists, a county code and a weighted check digit. This tutorial writes that check in TypeScript, decodes the parts, turns it into a zod rule and a React field that validates as you type, and ends with what to do before you store a single CNP. The field is Masked Input from Wingo UI, the library we build, so we are not neutral. The validator is plain TypeScript you can paste anywhere.

What is the cod numeric personal format?

Thirteen digits in seven parts, written S AA LL ZZ JJ NNN C. The Romanian Wikipedia article on the CNP documents each one. Here they are on 1960523123457, the sample code from the demo further down:

Digits Part Meaning In the sample
1 S Sex and century of birth 1: male, born 1900 to 1999
2 to 3 AA Year of birth 96
4 to 5 LL Month of birth 05
6 to 7 ZZ Day of birth 23
8 to 9 JJ County code 12: Cluj
10 to 12 NNN Serial number, 001 to 999 345
13 C Check digit 7

From 1 to 8, the first digit is odd for men and even for women. 1 and 2 mean born in the 1900s, 3 and 4 in the 1800s, 5 and 6 in the 2000s. 7 and 8 are foreign residents, and for them the century is not encoded. English Wikipedia also lists 9 for foreign citizens; the Romanian article stops at 8, so treat 9 as rare and accept it.

The county codes are 01 to 39 for the counties in alphabetical order (Alba to Vrancea), 40 for Bucharest, 41 to 46 for its six sectors, 47 and 48 for the former sectors 7 and 8, which only appear on old codes, and 51 and 52 for Călărași and Giurgiu. There is no 49 or 50.

And since May 2024 there is 70. Codes generated by Romania's new civil status IT system carry 70 in place of the county for every registration, wherever the person was born. The Ministry of Internal Affairs said in May 2024 that this started with births registered from May 8 in Ilfov county and seven cities, plus the birth records of people who had just become citizens, and that existing codes do not change. The Romanian Wikipedia article now lists 70 as well. Two consequences for your code: a range check of 01 to 52 rejects every code the new system issues, and you can no longer read a county from a new code.

How does the CNP checksum algorithm work?

The CNP check digit is a weighted sum mod 11. Multiply each of the first 12 digits by the digit in the same position of the key 279146358279, add the products and divide by 11. The remainder is the check digit, except that a remainder of 10 becomes 1. Here is the sample worked by hand:

Position 1 2 3 4 5 6 7 8 9 10 11 12
Digit 1 9 6 0 5 2 3 1 2 3 4 5
Key 2 7 9 1 4 6 3 5 8 2 7 9
Product 2 63 54 0 20 12 9 5 16 6 28 45

The products add up to 260, and 260 mod 11 is 7, the last digit of 1960523123457.

The fold of 10 into 1 is where the check gets weaker. Remainders 1 and 10 both produce a 1, so about 2 codes in 11 end in 1, and a typo that moves the sum from one to the other passes. 1960523120031 is valid; change its twelfth digit and 1960523120021 passes too.

We measured it with the checkCnp function below. Over 20,000 random valid codes, we tried every single-digit change and every swap of two neighboring digits. The checksum alone missed 1.7% of the changes, every one of them through the fold, and 1.9% of the swaps. Checking the date and the county as well brought both down to about 1.2%. Of a million random 13-digit strings, 10% passed the checksum and 0.14% passed every check. So about 1 typo in 80 passes every check, which is why the form below also reads the code back to the person.

How do I validate a CNP in JavaScript or TypeScript?

Run the checks in order and say which one failed, so the form can explain the problem instead of printing "invalid". Return the decoded parts from the same pass, so nothing parses the code twice. The code is TypeScript with no dependency; drop the types and it runs as plain JavaScript:

// lib/cnp.ts export type CnpProblem = "format" | "sex" | "date" | "county" | "checksum"; export type CnpInfo = { cnp: string; sex: "male" | "female" | null; // "1996-05-23"; null for 7 to 9, whose century is not encoded birthDate: string | null; // "12"; "70" for codes issued by the civil status system since May 2024 county: string; countyName: string | null; serial: string; }; export type CnpCheck = { problem: CnpProblem; info: null } | { problem: null; info: CnpInfo }; const WEIGHTS = [2, 7, 9, 1, 4, 6, 3, 5, 8, 2, 7, 9]; // 01 to 39 in alphabetical order, then Bucharest and its sectors (7 and 8 no longer exist) const COUNTIES: Record<string, string> = Object.fromEntries( [ "Alba", "Arad", "Argeș", "Bacău", "Bihor", "Bistrița-Năsăud", "Botoșani", "Brașov", "Brăila", "Buzău", "Caraș-Severin", "Cluj", "Constanța", "Covasna", "Dâmbovița", "Dolj", "Galați", "Gorj", "Harghita", "Hunedoara", "Ialomița", "Iași", "Ilfov", "Maramureș", "Mehedinți", "Mureș", "Neamț", "Olt", "Prahova", "Satu Mare", "Sălaj", "Sibiu", "Suceava", "Teleorman", "Timiș", "Tulcea", "Vaslui", "Vâlcea", "Vrancea", "București", "București, Sector 1", "București, Sector 2", "București, Sector 3", "București, Sector 4", "București, Sector 5", "București, Sector 6", "București, Sector 7", "București, Sector 8", ].map((name, i) => [String(i + 1).padStart(2, "0"), name]), ); COUNTIES["51"] = "Călărași"; COUNTIES["52"] = "Giurgiu"; const CENTURY: Record<string, number> = { "1": 1900, "2": 1900, "3": 1800, "4": 1800, "5": 2000, "6": 2000 }; export function cnpCheckDigit(first12: string): number { let sum = 0; for (let i = 0; i < 12; i++) sum += Number(first12[i]) * WEIGHTS[i]; // a remainder of 10 does not fit in one digit, so it becomes 1 return sum % 11 === 10 ? 1 : sum % 11; } // "1996-05-23", or null when that day does not exist function isoDate(year: number, month: number, day: number): string | null { const date = new Date(Date.UTC(year, month - 1, day)); if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null; return date.toISOString().slice(0, 10); } // the calendar day in Romania, where the codes are issued export const todayInRomania = () => new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Bucharest" }).format(new Date()); export function checkCnp(input: string, today = todayInRomania()): CnpCheck { const cnp = input.replace(/[\s.-]/g, ""); const fail = (problem: CnpProblem): CnpCheck => ({ problem, info: null }); if (!/^\d{13}$/.test(cnp)) return fail("format"); const s = cnp[0]; if (s === "0") return fail("sex"); const [yy, mm, dd] = [cnp.slice(1, 3), cnp.slice(3, 5), cnp.slice(5, 7)].map(Number); let birthDate: string | null = null; if (CENTURY[s]) { birthDate = isoDate(CENTURY[s] + yy, mm, dd); if (!birthDate || birthDate > today) return fail("date"); } else { // 7 to 9: the day has to exist in one of the two centuries it could be from const candidates = [2000 + yy, 1900 + yy].map((year) => isoDate(year, mm, dd)); if (!candidates.some((date) => date && date <= today)) return fail("date"); } const county = cnp.slice(7, 9); if (county !== "70" && !COUNTIES[county]) return fail("county"); if (cnpCheckDigit(cnp) !== Number(cnp[12])) return fail("checksum"); return { problem: null, info: { cnp, sex: s === "9" ? null : Number(s) % 2 === 1 ? "male" : "female", birthDate, county, countyName: COUNTIES[county] ?? null, serial: cnp.slice(9, 12), }, }; } export const isValidCnp = (input: string) => checkCnp(input).problem === null; 
checkCnp("1960 523 123 457"); // { problem: null, info: { cnp: "1960523123457", sex: "male", birthDate: "1996-05-23", county: "12", countyName: "Cluj", serial: "345" } } checkCnp("1960523123458"); // { problem: "checksum", info: null } checkCnp("1000229123456"); // { problem: "date", info: null }, 1900 was not a leap year checkCnp("1960523491239"); // { problem: "county", info: null }, there is no county 49 isValidCnp("5240601701230"); // true, county 70 

Four details that CNP validation in JavaScript often gets wrong:

  • The century comes from the first digit. new Date(24, 5, 1) is June 1, 1924, because JavaScript maps two-digit years to the 1900s. Build the full year first, and check that the day survived the round trip, or February 29, 1900 slips through.
  • A birth date cannot be in the future. 5300101123456 decodes to January 1, 2030. Compare against today in Romania, not in UTC or in the server's zone.
  • The county list has gaps and a new member. 49 and 50 do not exist, and 70 does.
  • Codes starting with 7 to 9 have no century. We accept the day if it exists in either century and return birthDate: null instead of guessing, because a wrong guess turns an 11-year-old resident into a 111-year-old.

How do I read the birth date, age and county from a CNP?

Take them from info, and turn them into a sentence the person can compare with their ID card. Reading a valid code back catches the mistake no checksum can: someone typing a valid code that belongs to their spouse.

// lib/cnp.ts (continued) // "Male, born May 23, 1996, Cluj" export function describeCnp(info: CnpInfo, locale = "en-US"): string { const sex = info.sex === "male" ? "Male" : info.sex === "female" ? "Female" : "Foreign national"; const born = info.birthDate ? `born ${new Intl.DateTimeFormat(locale, { dateStyle: "long", timeZone: "UTC" }).format(new Date(info.birthDate))}` : "resident"; return [sex, born, info.countyName].filter(Boolean).join(", "); } // full years on a given day: ageOn("2008-10-10", "2026-10-09") is 17 export function ageOn(birthDate: string, today = todayInRomania()): number { const [by, bm, bd] = birthDate.split("-").map(Number); const [ty, tm, td] = today.split("-").map(Number); return ty - by - (tm < bm || (tm === bm && td < bd) ? 1 : 0); } 

A code with county 70 reads "Male, born June 1, 2024", without a county, which is the truth.

How do I write a zod rule for a CNP?

Wrap checkCnp in a check that maps each problem to its own message, then strip the separators so the database gets the 13 digits. The schema uses zod 4 (.check() and the error param):

// lib/cnp-schema.ts import { z } from "zod"; import { ageOn, checkCnp, type CnpProblem } from "@/lib/cnp"; const MESSAGES: Record<CnpProblem, string> = { format: "A CNP has 13 digits", sex: "A CNP starts with a digit from 1 to 9", date: "Digits 2 to 7 are not a real birth date", county: "Digits 8 and 9 are not a county code", checksum: "The last digit does not match. Check for a typo", }; export const cnp = z .string() .trim() .min(1, { error: "Enter your CNP", abort: true }) .check((ctx) => { const { problem } = checkCnp(ctx.value); if (problem) ctx.issues.push({ code: "custom", message: MESSAGES[problem], input: ctx.value }); }) .transform((value) => value.replace(/[\s.-]/g, "")); // for contracts that need an adult; 7 to 9 have no birth date, so ask for one separately export const adultCnp = cnp.refine( (value) => { const { info } = checkCnp(value); return !info?.birthDate || ageOn(info.birthDate) >= 18; }, { error: "You need to be 18 or older" }, ); 

"Digits 2 to 7 are not a real birth date" tells the person where to look, which "Invalid CNP" never does. If you use the form-validation lib from Wingo UI Pro, it has rules for CUI and IBAN but none for CNP, so this rule sits next to them: with const v = createValidators(), write z.object({ email: v.email(), iban: v.iban(), cnp }).

How do I build a CNP field that checks as you type?

Use a masked field with a numeric keypad, and show the verdict the moment the 13th digit lands. The cnp preset of Masked Input sets inputMode="numeric" and autoComplete="off", keeps the value to 13 digits, drops spaces, dots and dashes from a paste, and pops in a check or an alert icon once the code is complete. The red ring and aria-invalid wait until the person leaves the field. The preset's own check accepts any county from 01 to 52, so it lets 49 and 50 through and rejects 70. Pass validate={isValidCnp} and the field agrees with the zod rule on every code.

Live demo: Masked Input. The original article embeds an interactive demo here (Type 1960523123457: a check appears once the 13th digit is in. Change the last digit and it turns into an alert; press Save to see the error message). Try it live on wingo-ui.com or open the Masked Input docs. Install: npx wingo-ui@latest add masked-input

Wire it with React Hook Form's Controller (npm install react-hook-form @hookform/resolvers zod). Use Controller, not register: the input element holds the masked text, and the raw digits come through onValueChange:

"use client"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button } from "@/components/ui/button"; import { MaskedInput } from "Source:https://dev.to/wingo-ui/romanian-cnp-validator-in-typescript-zod-and-react-5a6m

Related posts