# nif-dni-nie-cif-validation: full reference
> The full documentation of the npm package `nif-dni-nie-cif-validation` in one file: the README, the API reference, the migration guide from v1 and the validation rules with their official sources. The short version is llms.txt.
This file is generated by `pnpm docs:llms` (scripts/llms-full.mjs) from README.md, docs/api-design.md, MIGRATION.md, SPEC.md. Links point to the repository: https://github.com/josegoval/nif-dni-nie-cif-validation.
---
Source: Overview (README.md), https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/README.md
`nif-dni-nie-cif-validation` checks Spanish tax IDs (NIF, DNI, K/L/M NIF, NIE and CIF) against the official rules, and tells you why a number is wrong. Every rule it applies has a source documented in [SPEC.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md): the law, official guidance, an AEAT technical note, or a convention labelled as such.
The website has the guides, the API reference, a live validator and the benchmarks, in English, Spanish, Catalan, Basque and Galician: .
## Quick start
```sh
npm install nif-dni-nie-cif-validation
```
pnpm, yarn, bun, deno:
```sh
pnpm add nif-dni-nie-cif-validation
```
```sh
yarn add nif-dni-nie-cif-validation
```
```sh
bun add nif-dni-nie-cif-validation
```
```sh
deno add npm:nif-dni-nie-cif-validation
```
```ts
import { isValidNif, validate } from "nif-dni-nie-cif-validation";
isValidNif("12345678Z"); // true
isValidNif(" 12.345.678-z "); // true: spaces, dots and hyphens are removed, and lower case becomes upper case
isValidNif("B12345675"); // false: wrong control digit
validate("12345678A").error?.message; // 'The control character is not correct: for this DNI it should be "Z".'
```
From a terminal or a script, with no code to write:
```sh
npx nif-dni-nie-cif-validation validate 12345678Z
# 12345678Z: valid DNI 12345678Z
```
More commands (`--json` output, exit codes, test data, CSV checks) are under [Command line](#command-line) and in the [command line guide](https://josegoval.github.io/nif-dni-nie-cif-validation/guides/command-line/) on the website.
## Contents
- [Which function do I need?](#which-function-do-i-need)
- [Features](#features)
- [API overview](#api-overview)
- [Command line](#command-line)
- [Recipes](#recipes): Zod and React Hook Form, Express, test data, messages in Catalan
- [DNI, NIE, NIF and CIF](#dni-nie-nif-and-cif)
- [Law, official guidance and convention](#law-official-guidance-and-convention)
- [Performance](#performance)
- [Comparison with other libraries](#comparison-with-other-libraries)
- [Using this package with AI coding agents](#using-this-package-with-ai-coding-agents)
- [Migrating](#migrating)
- [Contributing, security and license](#contributing-security-and-license)
## Which function do I need?
| I want to… | Use | Import from |
| --- | --- | --- |
| Accept any valid NIF: DNI, K/L/M, NIE or CIF | `isValidNif(value)` | `nif-dni-nie-cif-validation` |
| Accept people only: DNI, K/L/M or NIE | `isValidNaturalPersonNif(value)` | `nif-dni-nie-cif-validation` |
| Accept a DNI (or a K/L/M NIF) | `isValidDni(value)` | `nif-dni-nie-cif-validation` |
| Accept an NIE | `isValidNie(value)` | `nif-dni-nie-cif-validation` |
| Accept companies and other entities (CIF) | `isValidCif(value)`, alias `isValidLegalEntityNif` | `nif-dni-nie-cif-validation` |
| Accept a Spanish VAT number (`ES` + NIF, format only) | `isValidSpanishVat(value)` | `nif-dni-nie-cif-validation` |
| Know **why** a value is invalid, its type, and what to store | `validate(value, options)` | `nif-dni-nie-cif-validation` |
| Get the canonical form to store | `normalize(value)` | `nif-dni-nie-cif-validation` |
| Display it grouped, as `12345678-Z` | `format(value)` | `nif-dni-nie-cif-validation` |
| Detect the type without checking the control character | `getNifType(value)` | `nif-dni-nie-cif-validation` |
| Compute the control character of a number | `computeControlCharacter(partial)` | `nif-dni-nie-cif-validation` |
| Name the kind of organisation of a CIF | `describeCifOrganisation(key, locale)` | `nif-dni-nie-cif-validation` |
| Check only the control character, not the format | `isValidDniLetter(value)`, `isValidCifControlCode(value)` (alias `isValidLegalEntityNifControlCode`) | `nif-dni-nie-cif-validation` |
| Show messages in Spanish, Catalan, Basque or Galician | `es`, `ca`, `eu`, `gl` (and `en`), passed as `{ locale }` | `nif-dni-nie-cif-validation/locales/` |
| Make valid fake numbers for tests | `generateDni`, `generateNie`, `generateCif`, `generateNif`, `createGenerator` | `nif-dni-nie-cif-validation/generate` |
| Make invalid values for negative tests | `generateInvalid(type, { reason })` | `nif-dni-nie-cif-validation/generate` |
| Validate from a terminal, a script or an agent's tool call, or check a CSV file | `npx nif-dni-nie-cif-validation validate `, `check --file --column ` | the command line ([below](#command-line)) |
| Validate a form field with Zod | `zNif`, `zDni`, `zNie`, `zCif`, `zSpanishVat` | `nif-dni-nie-cif-validation/zod` |
| … with Valibot | `vNif`, `vDni`, `vNie`, `vCif`, `vSpanishVat` | `nif-dni-nie-cif-validation/valibot` |
| … with Yup | `yNif`, `yDni`, `yNie`, `yCif`, `ySpanishVat` | `nif-dni-nie-cif-validation/yup` |
| Keep using the v1 regexes and letter tables | `DNI_REGEX`, `NIE_REGEX`, `LEGAL_ENTITY_NIF_REGEX` (alias `CIF_REGEX`), `DNI_CONTROL_LETTERS`, `LEGAL_ENTITY_CONTROL_LETTERS` (alias `CIF_CONTROL_LETTERS`). The regular expressions check the format only, and the tables hold the control letters: prefer the functions above | `nif-dni-nie-cif-validation` |
| Swap the NIE letter for its digit (v1) | `replaceNieLetter(value)`: deprecated, throws on bad input | `nif-dni-nie-cif-validation` |
## Features
- **Correct, with sources.** Every rule has an ID (`DNI-2`, `NIE-3`, `CIF-3`…) and a source in [SPEC.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md): the law, an official government page, the AEAT's technical note, or a convention labelled as such. Every error names the rule that failed. The tests cover every rule, compare the results with [stdnum](https://www.npmjs.com/package/stdnum) on about 100,000 inputs, and compare every v1 function with v1.0.11 on about 490,000 inputs.
- **0 runtime dependencies.** Zod, Valibot and Yup are optional peer dependencies, needed only by their adapters.
- **Small.** One boolean validator adds less than 1 kB minified and gzipped, and `validate()` with its English messages less than 3 kB. CI enforces these budgets (`pnpm size`); the size of each function is under [Performance](#performance).
- **Typed.** Written in TypeScript, with declarations for `import` and for `require`, and the types exported (`ValidationResult`, `NifType`, `NifErrorCode`…).
- **ES modules and CommonJS**, tree-shakable, compiled to ES2016: Node.js 20 or newer and every current browser.
- **Never throws on user input.** Validators take `unknown`: `null`, numbers and objects give `false` (or `NOT_A_STRING` from `validate()`). Only `replaceNieLetter`, which is deprecated, throws on bad input; the generators throw a `RangeError` when their options can't be met, because those are programming errors.
- **Normalization.** `" 12.345.678-z "`, `"x-0123456-7l"` and `"1234567L"` are valid; `normalize()` gives the form to store. `{ normalize: false }` turns off the removal of separators and the zero padding; lower case and the old NIE form are still accepted.
- **Error messages in 5 languages**: English (built in), Spanish, Catalan (also for Valencian), Basque and Galician. Each language is its own import, so your bundle only has the ones you use.
- **Test-data generators** (`/generate`): valid numbers with the library's own control characters, the same for a given seed on every platform, and invalid values for each error code.
- **Schema adapters** for Zod 4, Valibot 1 and Yup 1: they output the normalized value and give the localized message, the error code and the SPEC rule.
- **A command line interface**: `npx nif-dni-nie-cif-validation validate 12345678Z`, with `--json` output and exit codes for scripts and agents, test data and CSV checks. It is not part of the library's bundle.
## API overview
Every export has JSDoc, so your editor shows its documentation: a summary and, for functions, what each parameter and the return value mean, two examples that the tests run, and a link to the SPEC rule. CI checks it. The design and the reasons behind it are in [docs/api-design.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/docs/api-design.md); the changes from v1 in [MIGRATION.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/MIGRATION.md).
### `validate()`: the detailed result
```ts
import { validate } from "nif-dni-nie-cif-validation";
validate(" b-1234567-4 ");
// { valid: true, type: "CIF", normalized: "B12345674",
// meta: { orgKey: "B", orgDescription: "Limited liability company" } }
validate("12345678A");
// { valid: false, type: "DNI", normalized: "12345678A",
// error: { code: "INVALID_CONTROL_CHARACTER", rule: "DNI-2", expected: "Z",
// message: 'The control character is not correct: for this DNI it should be "Z".' } }
```
| Field | Value |
| --- | --- |
| `valid` | `true` or `false`, with the options given |
| `type` | `"DNI"`, `"NIE"`, `"CIF"`, `"NIF_KLM"` or `null` when the format is not recognisable. Set even when the control character is wrong, so you can say "this DNI's letter should be Z" |
| `normalized` | The canonical form to store (upper case, no separators, old NIE form collapsed, short DNI padded), or `null` |
| `error` | Only when invalid: `code`, `message` (in the locale), `rule` (the SPEC.md rule ID) and, for a wrong control character, `expected` |
| `meta` | Only for a CIF: `orgKey` and `orgDescription` (the kind of organisation, in the locale) |
Error codes, and the rules that produce them:
| `error.code` | When | `error.rule` |
| --- | --- | --- |
| `NOT_A_STRING` | The value is not a string | `INPUT-1` |
| `EMPTY` | Empty or made only of spaces and separators | `INPUT-2` |
| `INVALID_LENGTH` | Not 9 characters (10 for an old NIE) | `DNI-1`, `KLM-1`, `NIE-1`, `NIE-3`, `CIF-1`, `VAT-1` |
| `INVALID_FORMAT` | Wrong first character, letters where digits go, or an `ES` prefix without `allowVatPrefix` | `NIF-1`, `DNI-1`, `KLM-1`, `KLM-3`, `NIE-1`, `CIF-1`, `VAT-1` |
| `INVALID_CONTROL_CHARACTER` | Wrong check letter or digit; `expected` has the right one | `DNI-2`, `DNI-3`, `KLM-2`, `NIE-2`, `CIF-3`, `CIF-4` |
| `UNSUPPORTED_TYPE` | A valid document of a type left out of `types` | `POLICY-2` |
| `PLACEHOLDER` | `00000000T`, `00000001R`, `99999999R` or `X0000000T` with `rejectPlaceholders` | `POLICY-1` |
### Options
| Option | Default | Effect | Accepted by |
| --- | --- | --- | --- |
| `normalize` | `true` | Ignore white space, dots, hyphens and slashes, and pad a short DNI (NORM-2 to NORM-4) | every function that validates, `getNifType()`, schemas |
| `cifControl` | `"official"` | `"lenient"` also accepts a letter control for the CIF keys C D F G J U V, as v1 did (CIF-3) | every function that validates, schemas |
| `rejectPlaceholders` | `false` | Reject the placeholder numbers (POLICY-1) | every function that validates, schemas |
| `types` | every type | Accept only these types (POLICY-2) | `validate()`, `zNif`, `vNif`, `yNif` |
| `allowVatPrefix` | `false` | Also accept `ES` + NIF; `normalized` drops the `ES` (VAT-1) | `validate()`, `getNifType()`, schemas other than `*SpanishVat` |
| `locale` | English | The language of `error.message` and `meta.orgDescription` | `validate()`, schemas |
```ts
import { isValidCif, validate } from "nif-dni-nie-cif-validation";
validate("B12345674", { types: ["DNI", "NIE"] }).error?.code; // "UNSUPPORTED_TYPE"
validate("00000000T", { rejectPlaceholders: true }).error?.code; // "PLACEHOLDER"
validate("ES12345678Z", { allowVatPrefix: true }).normalized; // "12345678Z"
isValidCif("G1234567D"); // false: G takes a digit (CIF-3)
isValidCif("G1234567D", { cifControl: "lenient" }); // true, as in v1
```
### Boolean validators
```ts
import {
isValidCif, isValidDni, isValidNaturalPersonNif, isValidNie, isValidNif, isValidSpanishVat,
} from "nif-dni-nie-cif-validation";
isValidNif("X1234567L"); // true: any type
isValidNaturalPersonNif("B12345674"); // false: a CIF is not a person
isValidDni("K1234567L"); // true: DNI and K/L/M NIF
isValidNie("X01234567L"); // true: the old 10-character form (NIE-3)
isValidCif("P2807900B"); // true: Ayuntamiento de Madrid
isValidSpanishVat("ES12345678Z"); // true: the format only, not a VIES lookup
isValidNif(null); // false: never throws
```
They are fast on canonical input and allocate nothing there. With CommonJS:
```js
const { isValidNif } = require("nif-dni-nie-cif-validation");
isValidNif("12345678Z"); // true
```
### Helpers
```ts
import {
computeControlCharacter, describeCifOrganisation, format, getNifType, normalize,
} from "nif-dni-nie-cif-validation";
normalize(" x-0123456-7l "); // "X1234567L"
format("12345678z"); // "12345678-Z"
format("B12345674", { separator: " " }); // "B 1234567 4"
format("12345678A"); // null: only valid documents are formatted
getNifType("12345678A"); // "DNI": the format only, the letter is wrong
computeControlCharacter("B1234567"); // "4"
describeCifOrganisation("B"); // "Limited liability company"
```
### Languages
English is built in. Every other language is its own entry point: import the object and pass it.
```ts
import { describeCifOrganisation, validate } from "nif-dni-nie-cif-validation";
import { es } from "nif-dni-nie-cif-validation/locales/es";
validate("12345678A", { locale: es }).error?.message;
// "El carácter de control no es correcto: para este DNI debería ser «Z»."
describeCifOrganisation("B", es); // "Sociedad de responsabilidad limitada"
```
| Language | Import |
| --- | --- |
| English (the default) | `import { en } from "nif-dni-nie-cif-validation/locales/en"` |
| Spanish (español) | `import { es } from "nif-dni-nie-cif-validation/locales/es"` |
| Catalan (català), also for Valencian (valencià) | `import { ca } from "nif-dni-nie-cif-validation/locales/ca"` |
| Basque (euskara) | `import { eu } from "nif-dni-nie-cif-validation/locales/eu"` |
| Galician (galego) | `import { gl } from "nif-dni-nie-cif-validation/locales/gl"` |
Pass the object, not its code: a string such as `"es"` is ignored and gives English. The sources of the organisation names are in [docs/translations.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/docs/translations.md).
### Test-data generators
```ts
import {
createGenerator, generateCif, generateDni, generateInvalid, generateNie, generateNif,
} from "nif-dni-nie-cif-validation/generate";
generateDni({ seed: 1 }); // "62707394X": the same on every run and platform
generateDni({ seed: 1, kind: "K" }); // "K6270739L"
generateNie({ seed: 1, prefix: "Z" }); // "Z6270739R"
generateCif({ seed: 1, orgKey: "B" }); // "B62707393"
generateNif({ types: ["DNI", "NIE"] }); // a random DNI or NIE (Math.random without a seed)
generateInvalid("DNI", { seed: 1, reason: "INVALID_LENGTH" }); // "652707394X"
const gen = createGenerator(2024); // a stream: different values, the same on every run
gen.dni() === gen.dni(); // false
```
Every generator takes `format: true` for the display form. Valid generated numbers follow SPEC.md and are never on the POLICY-1 placeholder list, but they are made up: one may match a real person or company by chance, so use them in tests only. The main entry point never imports this module.
### Schema adapters: Zod, Valibot, Yup
```ts
import { z } from "zod";
import { zNif } from "nif-dni-nie-cif-validation/zod";
const schema = z.object({ nif: zNif({ types: ["DNI", "NIE"] }) });
schema.parse({ nif: " 12.345.678-z " }); // { nif: "12345678Z" }
schema.safeParse({ nif: "B12345674" }).success; // false: a CIF is not accepted here
```
```ts
import * as v from "valibot";
import { vNie } from "nif-dni-nie-cif-validation/valibot";
v.parse(vNie(), "x-0123456-7l"); // "X1234567L"
```
```ts
import { object } from "yup";
import { yCif } from "nif-dni-nie-cif-validation/yup";
await object({ cif: yCif() }).validate({ cif: "b-1234567-4" }); // { cif: "B12345674" }
```
| Library | Entry point | Schemas | Code and rule of an error |
| --- | --- | --- | --- |
| Zod 4 | `/zod` | `zNif`, `zDni`, `zNie`, `zCif`, `zSpanishVat` | the issue's `params`: `{ code, rule, expected? }` |
| Valibot 1 | `/valibot` | `vNif`, `vDni`, `vNie`, `vCif`, `vSpanishVat` | the issue: `issue.code`, `issue.rule`, `issue.expected` |
| Yup 1 | `/yup` | `yNif`, `yDni`, `yNie`, `yCif`, `ySpanishVat` | the `ValidationError`'s `params` |
Each schema takes the options of `validate()` and accepts exactly what `validate()` accepts. Install only the library you use; importing the core never loads an adapter. More in [docs/api-design.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/docs/api-design.md) (D12).
## Command line
The package also works from a terminal, a shell script or an AI agent's tool call, with no code to write. It needs Node.js 20 or newer:
```sh
npx nif-dni-nie-cif-validation validate 12345678Z " x-0123456-7l " 12345678A
# 12345678Z: valid DNI 12345678Z
# " x-0123456-7l ": valid NIE X1234567L
# 12345678A: invalid [DNI-2 INVALID_CONTROL_CHARACTER] The control character is not correct: for this DNI it should be "Z".
npx nif-dni-nie-cif-validation generate dni --count 3 --seed 42
# 60110375J
# 44829056D
# 85246578E
```
| Command | What it does |
| --- | --- |
| `validate ` | Validates each value: its type and canonical form, or the SPEC rule, error code and message |
| `type ` | Detects the type from the format only, like `getNifType()` |
| `normalize ` | Prints the canonical form, like `normalize()` |
| `generate ` | Prints test values: `dni`, `nie`, `cif` or `nif`, with `--count`, `--seed` and `--format` |
| `check --file --column ` | Validates one column of a CSV file and reports the invalid rows by row number |
`validate` and `check` take the options of `validate()` as flags: `--types DNI,NIE`, `--cif-control lenient`, `--reject-placeholders`, `--allow-vat-prefix` and `--locale es` (or `ca`, `eu`, `gl`). The exit code is 0 when everything is valid, 1 when something is invalid and 2 for a usage error, and `--json` prints one JSON document for scripts and agents (for `validate`, an array with `validate()`'s result for each value). For a CSV file such as this `customers.csv`:
```csv
id,nif,name
1,12345678Z,Ana
2,"B-1234567-4","Acme, S.L."
3,12345678A,Luis
```
```sh
npx nif-dni-nie-cif-validation check --file customers.csv --column nif
# row 4: 12345678A: invalid [DNI-2 INVALID_CONTROL_CHARACTER] The control character is not correct: for this DNI it should be "Z".
# 3 rows checked in customers.csv, column "nif": 2 valid, 1 invalid
```
`npx nif-dni-nie-cif-validation --help` lists every command and option. The JSON shapes and the details of the CSV reader are in [docs/api-design.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/docs/api-design.md) (D13).
## Recipes
Each recipe, and more, is a runnable project in [examples/](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/): [Node, CommonJS](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/node-cjs) and [ES modules](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/node-esm), [React Hook Form with Zod](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/zod-react-hook-form) (Vite and React), [Express](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/express-middleware), [a Next.js server action](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/nextjs-server-action), [Valibot](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/valibot), [Yup](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/yup), [Vitest fixtures with the generators](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/generate-test-data), [bulk validation of a CSV, with its throughput](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/csv-bulk-validation), [Deno](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/deno) and [Bun](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/bun). CI builds, type-checks or runs each one against the packed package.
### Zod and React Hook Form
The schema takes the user's language and outputs the normalized value:
```ts
// nif-schema.ts
import { z } from "zod";
import { es } from "nif-dni-nie-cif-validation/locales/es";
import { zNif } from "nif-dni-nie-cif-validation/zod";
export const schema = z.object({ nif: zNif({ types: ["DNI", "NIE"], locale: es }) });
schema.safeParse({ nif: "12345678A" }).error?.issues[0]?.message;
// "El carácter de control no es correcto: para este DNI debería ser «Z»."
```
`@hookform/resolvers` works with it as it is, and `handleSubmit` receives `"12345678Z"` for `" 12.345.678-z "`:
```tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import type { z } from "zod";
import { schema } from "./nif-schema";
export function NifForm({ save }: { save: (nif: string) => void }) {
const { register, handleSubmit, formState: { errors } } = useForm<
z.input, unknown, z.output
>({ resolver: zodResolver(schema) });
return (
);
}
```
### Express middleware
Reject a request with a useful message, and pass the normalized NIF on:
```ts
import { validate } from "nif-dni-nie-cif-validation";
export function requireNif(
req: { body: { nif?: unknown } },
res: { status(code: number): { json(body: unknown): unknown } },
next: () => void,
): void {
const result = validate(req.body.nif);
if (!result.valid) {
res.status(400).json({ error: result.error }); // { code, message, rule, expected? }
return;
}
req.body.nif = result.normalized; // "12345678Z", ready to store
next();
}
// app.post("/customers", express.json(), requireNif, createCustomer);
```
### Generating test data
Generate NIFs for your fixtures without copying real people's data or writing your own check-letter code:
```ts
import { validate } from "nif-dni-nie-cif-validation";
import { createGenerator, generateInvalid } from "nif-dni-nie-cif-validation/generate";
const gen = createGenerator(42); // the same fixtures on every run
const customers = Array.from({ length: 3 }, (_, id) => ({ id, nif: gen.nif() }));
customers.every((customer) => validate(customer.nif).valid); // true
validate(generateInvalid("NIE", { seed: 7 })).error?.code; // "INVALID_CONTROL_CHARACTER"
validate(generateInvalid("CIF", { seed: 7, reason: "INVALID_FORMAT" })).error?.code; // "INVALID_FORMAT"
```
### Messages in Catalan
Pick the locale from the user's language, with a fallback:
```ts
import { type NifLocale, validate } from "nif-dni-nie-cif-validation";
import { ca } from "nif-dni-nie-cif-validation/locales/ca";
import { en } from "nif-dni-nie-cif-validation/locales/en";
import { es } from "nif-dni-nie-cif-validation/locales/es";
const LOCALES: Record = { ca, en, es };
const localeFor = (language: string) => LOCALES[language.slice(0, 2)] ?? en;
const locale = localeFor("ca-ES"); // for example navigator.language
validate("12345678A", { locale }).error?.message;
// "El caràcter de control no és correcte: per a aquest DNI hauria de ser «Z»."
validate("B12345674", { locale }).meta?.orgDescription; // "Societat de responsabilitat limitada"
```
## DNI, NIE, NIF and CIF
- **NIF** (*número de identificación fiscal*) is the tax ID of every person and entity in Spain (RD 1065/2007). Everything this package validates is a NIF.
- **DNI**: for Spanish citizens, the NIF is the DNI number: 8 digits and a check letter, `12345678Z` ([DNI-1, DNI-2](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md#dni-1)).
- **NIE**: for foreigners, `X`, `Y` or `Z`, 7 digits and a check letter, `X1234567L`. The old 10-character form, `X0` followed by 7 digits and a check letter (`X01234567L`), is still valid ([NIE-1 to NIE-3](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md#nie-1)).
- **K/L/M NIF**: people without a DNI or NIE. K: Spaniards under 14 living in Spain; L: Spaniards living abroad; M: foreigners without an NIE. They are natural persons, not companies ([KLM-1 to KLM-3](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md#klm-1)).
- **CIF**: until 2008, the name of the NIF of legal persons and entities; officially it is now the "NIF of a legal person or entity". An organisation key, 7 digits and a control character, `B12345674` ([CIF-1 to CIF-5](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md#cif-1)). The package keeps the name `CIF` (`isValidCif`, `type: "CIF"`) because it is what people search for.
- **VAT number**: `ES` followed by a NIF (`ES12345678Z`, [VAT-1](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md#vat-1)). A valid format doesn't mean the number is registered in VIES.
## Law, official guidance and convention
Not every rule you find online has an official source. [SPEC.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md) gives each rule one of four tiers:
| Tier | Source | Example |
| --- | --- | --- |
| T1: law | Published in the BOE | The formats of DNI, NIE, K/L/M and CIF, and the CIF organisation keys |
| T2: official page | Ministerio del Interior, AEAT | The DNI check letter (mod 23), which no BOE text defines |
| T3: semi-official | The AEAT's internal technical note | Which CIF keys take a digit and which a letter (CIF-3) |
| T4: convention | Industry practice, no official text | Accepting lower case and separators; the CIF control arithmetic (CIF-4) |
By default only T1 to T3 decide what is valid. T4 conventions apply only to input cleanup (which never makes an invalid document valid), with one documented exception: no official text publishes the CIF control arithmetic, so the universal algorithm is used and was checked against real public-body NIFs ([CIF-4](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md#cif-4)). Everything else is opt-in: `cifControl: "lenient"` (v1's letter-or-digit CIF control), `rejectPlaceholders`, `types` and `allowVatPrefix`. `normalize: false` turns off the removal of separators and the zero padding (lower case and the old NIE form are still accepted). Folklore rules with no source, such as province codes in a CIF or a `T` prefix, are listed in SPEC.md as not implemented.
## Performance
Measured with `pnpm bench:competitors` on AMD EPYC 9V74 80-Core Processor (4 cores, 16 GiB, linux 6.17.0-1022-azure, x64), Node.js v24.21.0, on 2026-10-02, at commit `1166241`. Every number in this section comes from [`bench/results/latest.json`](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/bench/results/latest.json) and is written by `pnpm readme:bench`. How they are measured, and the caveats: [bench/README.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/bench/README.md). Full results: [bench/results/latest.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/bench/results/latest.md).
#### Throughput
Millions of validations per second (**M ops/s, higher is faster**): the median of 3 rounds, each library with its default options, on valid and invalid documents in canonical form (225 DNI, 150 NIE, 270 CIF). *unsupported*: the library has no validator for that type. Absolute numbers depend on the machine; the ratios are what carries over.
| Library | DNI | NIE | CIF |
| --- | ---: | ---: | ---: |
| **nif-dni-nie-cif-validation** (this build) | **60.12** | **57.60** | **55.06** |
| [nif-dni-nie-cif-validation](https://www.npmjs.com/package/nif-dni-nie-cif-validation) 1.0.11 | 8.59 | 6.44 | 7.27 |
| [spain-id](https://www.npmjs.com/package/spain-id) 1.1.14 | 6.96 | 4.01 | 4.50 |
| [better-dni](https://www.npmjs.com/package/better-dni) 4.4.2 | 8.44 | 8.17 | *unsupported* |
| [dni-js](https://www.npmjs.com/package/dni-js) 1.0.0 | 8.90 | 4.49 | *unsupported* |
| [stdnum](https://www.npmjs.com/package/stdnum) 1.12.6 | 0.41 | 0.39 | 0.38 |
| [validator.js `isIdentityCard(x, "ES")`](https://www.npmjs.com/package/validator) 13.15.35 | 5.73 | 3.14 | *unsupported* |
| [validator.js `isTaxID(x, "es-ES")`](https://www.npmjs.com/package/validator) 13.15.35 | 4.22 | 3.42 | *unsupported* |
| [@maistik/validate-nif](https://www.npmjs.com/package/@maistik/validate-nif) 2.0.1 | 9.46 | 8.39 | 7.74 |
| [@kreyo/nif-validator](https://www.npmjs.com/package/@kreyo/nif-validator) 0.1.0 | 2.78 | 2.65 | 2.50 |
| [jsvat](https://www.npmjs.com/package/jsvat) 2.5.4 | 2.10 | 1.59 | 2.37 |
Against v1.0.11, this build is 7.0× (DNI), 8.9× (NIE) and 7.6× (CIF) as fast.
No other library was faster on any of these sets.
#### Bundle size
Minified and gzipped (**min+gzip, lower is smaller**): one validator of each type, bundled with esbuild as `pnpm size` does, and the whole library.
| Library | DNI | NIE | CIF | Any type | Whole library |
| --- | ---: | ---: | ---: | ---: | ---: |
| **nif-dni-nie-cif-validation** (this build) | **623 B** | **586 B** | **568 B** | **929 B** | **4,679 B** |
| [nif-dni-nie-cif-validation](https://www.npmjs.com/package/nif-dni-nie-cif-validation) 1.0.11 | 1,517 B | 1,517 B | 1,518 B | 1,517 B | 1,514 B |
| [spain-id](https://www.npmjs.com/package/spain-id) 1.1.14 | 165 B | 237 B | 363 B | 583 B | 704 B |
| [better-dni](https://www.npmjs.com/package/better-dni) 4.4.2 | 1,135 B | 1,135 B | *unsupported* | 1,134 B | 1,132 B |
| [dni-js](https://www.npmjs.com/package/dni-js) 1.0.0 | 883 B | 882 B | *unsupported* | 882 B | 880 B |
| [stdnum](https://www.npmjs.com/package/stdnum) 1.12.6 | 51,063 B | 51,063 B | 51,063 B | 51,063 B | 51,054 B |
| [validator.js `isIdentityCard(x, "ES")`](https://www.npmjs.com/package/validator) 13.15.35 | 2,539 B | 2,539 B | *unsupported* | 2,539 B | 44,449 B |
| [validator.js `isTaxID(x, "es-ES")`](https://www.npmjs.com/package/validator) 13.15.35 | 6,537 B | 6,537 B | *unsupported* | 6,537 B | 44,449 B |
| [@maistik/validate-nif](https://www.npmjs.com/package/@maistik/validate-nif) 2.0.1 | 215 B | 243 B | 319 B | 456 B | 1,189 B |
| [@kreyo/nif-validator](https://www.npmjs.com/package/@kreyo/nif-validator) 0.1.0 | 533 B | 533 B | 533 B | 533 B | 746 B |
| [jsvat](https://www.npmjs.com/package/jsvat) 2.5.4 | 1,186 B | 1,186 B | 1,186 B | 1,186 B | 5,266 B |
Smaller than this build for one validator of any type: [spain-id](https://www.npmjs.com/package/spain-id) 1.1.14 (583 B), [dni-js](https://www.npmjs.com/package/dni-js) 1.0.0 (882 B), [@maistik/validate-nif](https://www.npmjs.com/package/@maistik/validate-nif) 2.0.1 (456 B) and [@kreyo/nif-validator](https://www.npmjs.com/package/@kreyo/nif-validator) 0.1.0 (533 B). Some of the smaller libraries validate fewer types. This package spends bytes on input normalization (NORM-1 to NORM-4), the CIF-3 control types and K/L/M support. Libraries published only as CommonJS can't be tree-shaken, so one function costs the whole library. Multi-country and multi-purpose libraries (stdnum, validator.js) are larger by design. Also measured, [stdnum](https://www.npmjs.com/package/stdnum) 1.12.6, with a deep import of its Spanish NIF module (not documented): 2,468 B.
#### Agreement with SPEC.md
The share of the 125 fixtures of `test/fixtures` (default options) that each library judges as SPEC.md does, valid or invalid. **This is agreement with SPEC.md, not correctness in the absolute**: the maintainers of this package wrote the rules and the fixtures, so it agrees with them by construction. Many disagreements are decisions that SPEC.md documents (for example CIF-3, or accepting the old NIE form); latest.md lists them per library. "Canonical input" leaves out the fixtures that need normalization.
| Library | DNI | NIE | CIF | K/L/M | All | Canonical input |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| **nif-dni-nie-cif-validation** (this build) | **100.0%** | **100.0%** | **100.0%** | **100.0%** | **100.0%** | **100.0%** |
| [nif-dni-nie-cif-validation](https://www.npmjs.com/package/nif-dni-nie-cif-validation) 1.0.11 | 73.3% | 88.9% | 83.6% | 100.0% | 84.0% | 92.2% |
| [spain-id](https://www.npmjs.com/package/spain-id) 1.1.14 | 90.0% | 77.8% | 86.9% | 36.4% | 82.4% | 85.4% |
| [better-dni](https://www.npmjs.com/package/better-dni) 4.4.2 | 73.3% | 77.8% | *unsupported* | 45.5% | 71.9% | 86.7% |
| [dni-js](https://www.npmjs.com/package/dni-js) 1.0.0 | 76.7% | 77.8% | *unsupported* | 45.5% | 73.4% | 86.7% |
| [stdnum](https://www.npmjs.com/package/stdnum) 1.12.6 | 90.0% | 83.3% | 63.9% | 100.0% | 76.8% | 77.7% |
| [validator.js `isIdentityCard(x, "ES")`](https://www.npmjs.com/package/validator) 13.15.35 | 80.0% | 77.8% | *unsupported* | 45.5% | 75.0% | 86.7% |
| [validator.js `isTaxID(x, "es-ES")`](https://www.npmjs.com/package/validator) 13.15.35 | 80.0% | 77.8% | *unsupported* | 100.0% | 84.4% | 100.0% |
| [@maistik/validate-nif](https://www.npmjs.com/package/@maistik/validate-nif) 2.0.1 | 86.7% | 77.8% | 86.9% | 45.5% | 82.4% | 86.4% |
| [@kreyo/nif-validator](https://www.npmjs.com/package/@kreyo/nif-validator) 0.1.0 | 86.7% | 77.8% | 86.9% | 45.5% | 82.4% | 86.4% |
| [jsvat](https://www.npmjs.com/package/jsvat) 2.5.4 | 90.0% | 83.3% | 82.0% | 100.0% | 86.4% | 89.3% |
## Comparison with other libraries
Checked on 2026-10-01, from each library's README, its package.json and npm page, and the benchmark above (versions and sizes come from latest.json).
| Library | Types | K/L/M | Normalizes input | Result object | Localized messages | Test-data generators | Schemas | Modules | Size, any type (min+gzip) | Last release |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| **nif-dni-nie-cif-validation** (this build) | DNI, NIE, CIF, K/L/M | yes | yes | yes: code, SPEC rule, message | EN, ES, CA, EU, GL | yes | Zod, Valibot, Yup | ESM + CJS | 929 B | this release |
| [spain-id](https://www.npmjs.com/package/spain-id) 1.1.14 | DNI, NIE, CIF | no | partial | no (type only) | no | no | no | ESM + CJS | 583 B | 2026-06-12 |
| [better-dni](https://www.npmjs.com/package/better-dni) 4.4.2 | DNI, NIE | no | separate `normalize()` | no | no | yes | no | CJS / UMD only | 1,134 B | 2021-05-30 |
| [dni-js](https://www.npmjs.com/package/dni-js) 1.0.0 | DNI, NIE | no | partial | no | no | no | no | CJS only | 882 B | 2026-08-08 |
| [stdnum](https://www.npmjs.com/package/stdnum) 1.12.6 | DNI, NIE, CIF, K/L/M, and about 90 countries | yes | yes | yes: error class | English only | no | no | ESM + CJS | 51,063 B | 2026-08-01 |
| [validator.js `isIdentityCard(x, "ES")`](https://www.npmjs.com/package/validator) 13.15.35 | DNI, NIE | no | no | no | no | no | no | ESM (deep imports) + CJS | 2,539 B | 2026-04-02 |
| [validator.js `isTaxID(x, "es-ES")`](https://www.npmjs.com/package/validator) 13.15.35 | DNI, NIE, K/L/M | yes | no | no | no | no | no | ESM (deep imports) + CJS | 6,537 B | 2026-04-02 |
| [@maistik/validate-nif](https://www.npmjs.com/package/@maistik/validate-nif) 2.0.1 | DNI, NIE, CIF | no | partial | partial: `parse()`, without the reason | no | no | no | ESM + CJS | 456 B | 2026-06-14 |
| [@kreyo/nif-validator](https://www.npmjs.com/package/@kreyo/nif-validator) 0.1.0 | DNI, NIE, CIF | opt-in | partial | no | no | no | no | ESM + CJS | 533 B | 2026-04-29 |
| [jsvat](https://www.npmjs.com/package/jsvat) 2.5.4 | EU VAT numbers (ES + NIF) | yes | yes | yes: validity and country | no | no | no | ESM + CJS | 1,186 B | 2024-12-12, deprecated |
If you validate identifiers from many countries, [stdnum](https://www.npmjs.com/package/stdnum) is a good choice: it covers about 90 countries with one API, including every Spanish type. This package does only Spain, and goes deeper there: rule-by-rule sources, the reason for each error, localized messages, normalization, generators and schemas. Where the libraries disagree with SPEC.md, the benchmark counts the disagreements of each library, shows representative ones, and says which follow from a decision that SPEC.md documents ([bench/results/latest.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/bench/results/latest.md)).
## Using this package with AI coding agents
The package ships [`llms.txt`](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/llms.txt), a short guide for AI assistants (functions, snippets, error codes and gotchas), and [`llms-full.txt`](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/llms-full.txt), the full reference. Both are in the npm tarball, so an agent can read them from `node_modules/nif-dni-nie-cif-validation/`. You can paste this into your agent's instructions:
```text
To validate Spanish NIF, DNI, NIE or CIF numbers, use the npm package
nif-dni-nie-cif-validation. Read node_modules/nif-dni-nie-cif-validation/llms.txt
before writing code. Use the isValid* functions for yes/no checks and validate()
when the user needs to know why a value is invalid, and store result.normalized.
For messages in another language, import the locale object from
nif-dni-nie-cif-validation/locales/ and pass it as { locale }.
For test data, use nif-dni-nie-cif-validation/generate instead of real numbers.
Don't write your own check-letter code.
```
An agent that runs shell commands can also call the [command line](#command-line): `npx nif-dni-nie-cif-validation validate --json` prints `validate()`'s result, and the exit code says whether the value is valid.
The rules and their official sources are in [SPEC.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md). Agents working on this repository read [AGENTS.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/AGENTS.md).
## Migrating
### From v1
v2 follows SPEC.md by default. [MIGRATION.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/MIGRATION.md) lists every breaking change, with code before and after. In short: the CIF keys C D F G J U V need a digit control, input is normalized, and TypeScript no longer accepts `ids.filter(isValidNif)`. These options give exactly the v1.0.11 results:
```ts
import { isValidNif } from "nif-dni-nie-cif-validation";
isValidNif("G1234567D"); // false
isValidNif("G1234567D", { normalize: false, cifControl: "lenient" }); // true, as in v1.0.11
```
### From other libraries
| You use | Use instead | Notes |
| --- | --- | --- |
| better-dni `isValid(x)` | `isValidNaturalPersonNif(x)` | Also accepts K/L/M NIFs, the old NIE form and separators. `isValidNif` also accepts a CIF |
| better-dni `isNIF(x)`, `isNIE(x)` | `isValidDni(x)`, `isValidNie(x)` | |
| better-dni `ctrlChar(x)` | `computeControlCharacter(x)` | Pass the number without its control character |
| better-dni `randomNIF()`, `randomNIE()` | `generateDni()`, `generateNie()` | From `/generate`; pass `{ seed }` for fixed values |
| validator.js `isIdentityCard(x, "ES")`, `isTaxID(x, "es-ES")` | `isValidNaturalPersonNif(x)` | validator.js has no CIF check: use `isValidCif(x)` or `isValidNif(x)`. It throws on non-strings; this package returns `false` |
| spain-id `validateSpanishId(x)` | `isValidNif(x)` | Also accepts K/L/M NIFs. The CIF control follows CIF-3 (see below) |
| spain-id `validDNI`, `validNIE`, `validCIF` | `isValidDni`, `isValidNie`, `isValidCif` | |
| spain-id `spainIdType(x)` | `getNifType(x)` | Upper case: `"DNI"`, `"NIE"`, `"CIF"`, `"NIF_KLM"` or `null`. Like `spainIdType`, it reads the format and doesn't check the control character |
Libraries that accept a letter or a digit control for every CIF key accept some numbers that CIF-3 rejects, such as `G1234567D`. If your stored data has them, pass `{ cifControl: "lenient" }` while you clean it up.
## Contributing, security and license
- [CONTRIBUTING.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/CONTRIBUTING.md): setup, commands, commit convention and releases. Rule changes need an official source: see "How to propose a change" in [SPEC.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md#how-to-propose-a-change).
- [SECURITY.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SECURITY.md): report a vulnerability privately.
- [MIT license](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/LICENSE).
If this package saves you time, you can support it:
[Buy Me a Coffee](https://www.buymeacoffee.com/josegoval)
---
Source: API reference and design (docs/api-design.md), https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/docs/api-design.md
# v2 API design
Status: accepted (#56) and released in 2.0.0 (2026-10-02). D13, the command line interface, was released in 2.1.0 (2026-10-03).
This document describes the public API of `nif-dni-nie-cif-validation` 2 and the reasons behind each decision. Every behaviour follows [SPEC.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md), and every error the API reports names the SPEC rule that failed. [MIGRATION.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/MIGRATION.md) lists what changes for v1 users.
## Goals
- Tell the caller **why** a value failed (an error code, a SPEC rule ID and a message in the caller's language), **which** document it is, its **normalized** form to store, and the **expected** control character for "did you mean…?" hints (#56).
- Follow SPEC.md exactly. By default only official rules (tiers T1 to T3) decide validity, with one documented exception: no official text publishes the CIF control arithmetic, so the universal algorithm (a T4 convention, [CIF-4](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md#cif-4)) applies. Every other convention is either input cleanup or opt-in.
- Never throw on untrusted input (#40).
- Keep the boolean validators as fast as in 1.x on canonical input, with no allocations (#47).
- Stay zero-dependency and tree-shakable: the booleans don't pull the messages or the organisation names, and `validate()` only pulls the languages the application imports.
## API at a glance
```ts
type NifType = "DNI" | "NIE" | "CIF" | "NIF_KLM";
type NifErrorCode =
| "NOT_A_STRING" | "EMPTY" | "INVALID_LENGTH" | "INVALID_FORMAT"
| "INVALID_CONTROL_CHARACTER" | "UNSUPPORTED_TYPE" | "PLACEHOLDER";
type CifControlMode = "official" | "lenient";
interface NifLocale { // a language, see D5
code: string; // "en", "es", ...
types: Record; // document type names used in messages
messages: NifMessages; // per error code (and per rule for length/format)
organisations: Record; // CIF-2, 17 keys
}
// English is built in; the other languages are separate entry points:
import { es } from "nif-dni-nie-cif-validation/locales/es";
interface ValidateOptions {
types?: NifType[]; // accept only these types; others -> UNSUPPORTED_TYPE
normalize?: boolean; // default true: NORM-1..4 and NIE-3 canonicalization
cifControl?: CifControlMode; // default "official" (CIF-3)
rejectPlaceholders?: boolean; // default false (POLICY-1)
allowVatPrefix?: boolean; // default false (VAT-1)
locale?: NifLocale; // default: English (anything else -> English)
}
interface ValidationResult {
valid: boolean;
type: NifType | null;
normalized: string | null;
error?: { code: NifErrorCode; message: string; rule: string; expected?: string };
meta?: { orgKey: string; orgDescription: string }; // CIF only
}
validate(value: unknown, opts?: ValidateOptions): ValidationResult
getNifType(value: unknown, opts?: Pick): NifType | null
normalize(value: string): string
format(value: unknown, opts?: { separator?: "-" | " " | "" }): string | null
computeControlCharacter(partial: unknown): string | null
describeCifOrganisation(key: unknown, locale?: NifLocale): string | null // default: English
isValidSpanishVat(value: unknown, opts?: IsValidOptions): boolean
// The v1 booleans, same names and aliases, new optional second argument:
type IsValidOptions = Pick;
isValidNif(value: unknown, opts?: IsValidOptions): boolean
// isValidNaturalPersonNif, isValidDni, isValidNie, isValidLegalEntityNif (isValidCif),
// isValidDniLetter, isValidLegalEntityNifControlCode (isValidCifControlCode): same signature
```
Every v1 export stays: the regex constants, `DNI_CONTROL_LETTERS`, `LEGAL_ENTITY_CONTROL_LETTERS` and the CIF aliases. `replaceNieLetter` stays deprecated and keeps throwing exactly as in v1.
## Decisions
### D1. Defaults follow SPEC.md: `normalize: true`, `cifControl: "official"` (breaking)
- **CIF-3** (AEAT D.I.T. note, T3) says the control of keys A B C D E F G H J U V is a digit and the control of N P Q R S W is a letter. v1 accepted either for C D F G J U V, a convention with no official basis. The maintainer approved following AEAT exactly on 2026-09-30 (#38). `cifControl: "lenient"` brings back "letter or digit" for C D F G J U V, for legacy data. It doesn't change the other keys: A B E H stay digit-only and N P Q R S W letter-only in both modes, as in v1.
- **Normalization** (NORM-1 to NORM-4, NIE-3) is input cleanup: it never makes an invalid document valid, it only lets users type or paste the same document in common ways (`" b-1234567-4 "`, `12.345.678-z`). SPEC's tier rules allow cleanup conventions to be on by default. Making it the default for the booleans too means `isValidNif(x)` and `validate(x).valid` always agree.
- **v1-compatible mode:** `{ normalize: false, cifControl: "lenient" }`. The differential test proves that every boolean gives exactly the v1.0.11 result in that mode, on about 490,000 inputs.
### D2. What `normalize` does, and what `normalize: false` means
`normalize(value)` returns the canonical official form. Steps, in order:
1. Remove every white-space character (as JavaScript's `\s`: spaces, tabs, line breaks, no-break spaces, the BOM, …) and every `.` (NORM-2), `-` and `/` (NORM-3), anywhere in the string. This also trims.
2. Upper-case ASCII letters and `ñ` (NORM-1). Other non-ASCII characters are left alone on purpose, so look-alikes such as `ı` (dotless i) or `ſ` (long s), which `toUpperCase()` maps to `I` and `S`, never turn into valid documents.
3. NIE-3: the old 10-character NIE `X0nnnnnnnL` becomes `XnnnnnnnL`.
4. NORM-4: 1 to 7 digits followed by a letter (a DNI typed without its leading zeros) are left-padded with zeros to 8 digits: `1234567L` becomes `01234567L`.
It returns the input string itself (no copy) when nothing changes. It never throws. Its parameter is typed `string`; any other value returns `""`.
`normalize` does not strip an `ES` VAT prefix: that is `validate`'s `allowVatPrefix` and `isValidSpanishVat`, because `ES…` is a different identifier (VAT-1), not a way of writing a NIF.
`normalize: false` turns off NORM-2, NORM-3 and NORM-4: no trimming, no separators, no padding. Case-insensitivity (NORM-1) and the old NIE form (NIE-3) are still accepted, because the v1 validators already accepted both and the v1-compatible mode must stay identical to v1. `validate(…, { normalize: false })` still returns `normalized` in canonical form (upper case, old NIE collapsed).
### D3. Boolean validators: signature, fast path, never throw
- Signature: `(value: unknown, opts?: IsValidOptions) => boolean` (#40). The options default to `{}`, so `.length` stays 1 as in v1.
- **Fast path:** the raw input is checked first, in one allocation-free pass. A valid canonical input returns `true` right there. When that fails, the validator returns `false` without scanning or copying whenever normalizing provably can't change the verdict: cleanup only removes separators (all below `0` or non-ASCII) and only NORM-4 lengthens a value (a DNI, from a digit), so a failed value of 9 characters or fewer can't be fixed for NIE and CIF, nor a 9-character value starting with a letter, nor a DNI whose 8 digits are all there. Otherwise the separators are removed and the value is checked again, where 1 to 7 digits and their DNI-2 letter count as the padded DNI (NORM-4). This is equivalent to "normalize, then check", because the raw check is already case-insensitive and accepts the old NIE form, and leading zeros don't change a DNI's number; so the booleans never call `normalize()`, which keeps their bundles small (CONTRIBUTING.md, "Size budgets"). With `rejectPlaceholders`, POLICY-1 reads the number of the document that passed. The benchmark checks the budget: on canonical input, at most 10% slower than PR #76.
- They never throw, whatever the arguments: a non-string returns `false`, and `opts` may be missing, `null` or even a number (as when a validator is passed straight to `array.filter`).
- `isValidNaturalPersonNif`, `isValidDni` (DNI and K/L/M), `isValidNie` and `isValidCif` keep their v1 scope.
- `isValidDniLetter` and `isValidCifControlCode` keep their v1 "control only, no format check" semantics. With `normalize` on they check `normalize(value)`. With `cifControl: "official"`, `isValidCifControlCode` uses CIF-3 for the key, and returns `false` when the first character is not an organisation key (there is no official control type without a key); v1 accepted a letter or a digit there, which `"lenient"` keeps.
- `rejectPlaceholders` (POLICY-1) applies to every boolean. Placeholders are DNIs and NIEs, so it never affects `isValidCif`.
### D4. `validate()`: result, error codes and the rule of each error
`validate` normalizes (unless `normalize: false`), then checks the steps below in order and stops at the first failure. `type` and `normalized` are set as soon as the format is recognisable (right first character, right length, digits in the body, a control character of the right class), even if the control character is wrong, so the UI can say "this DNI's letter should be Z". `meta` is set whenever `type` is `"CIF"`.
| Step | Code | `error.rule` | `type` / `normalized` |
|---|---|---|---|
| Not a string | `NOT_A_STRING` | INPUT-1 | null |
| Empty (or only separators) | `EMPTY` | INPUT-2 | null |
| Starts with `ES` and `allowVatPrefix` is off | `INVALID_FORMAT` | VAT-1 | null |
| `ES` with nothing after it | `INVALID_LENGTH` | VAT-1 | null |
| First character is not a digit, K L M, X Y Z or an organisation key (for example `T`) | `INVALID_FORMAT` | NIF-1 | null |
| Not 9 characters | `INVALID_LENGTH` | DNI-1, KLM-1, NIE-1 (NIE-3 for 10 characters), CIF-1 | null |
| Non-digits in the body | `INVALID_FORMAT` | DNI-1, KLM-3, NIE-1, CIF-1 | null |
| Control position: not a letter (DNI, K/L/M, NIE), not a letter or digit (CIF) | `INVALID_FORMAT` | DNI-1, KLM-1, NIE-1, CIF-1 | null |
| `types` given and the type is not in it | `UNSUPPORTED_TYPE` | POLICY-2 | set |
| Wrong check letter | `INVALID_CONTROL_CHARACTER` + `expected` | DNI-3 for I, Ñ, O, U; else DNI-2, KLM-2, NIE-2 | set |
| CIF control of the wrong class for the key (letter for a digit key or vice versa) | `INVALID_CONTROL_CHARACTER` + `expected` | CIF-3 | set |
| CIF control of the right class but wrong value | `INVALID_CONTROL_CHARACTER` + `expected` | CIF-4 | set |
| Placeholder and `rejectPlaceholders` | `PLACEHOLDER` | POLICY-1 | set |
- `UNSUPPORTED_TYPE` wins over a wrong control character: if a form only accepts DNIs, "we don't accept CIFs here" is the useful message.
- `expected` is the correct control character. For a lenient-mode key (C D F G J U V) it is in the class the user typed (letter or digit).
- `normalized` never keeps the `ES` prefix: it is always the NIF, so `validate(result.normalized)` with default options gives the same verdict (a property test checks this).
- `error.rule` is always a rule ID defined in SPEC.md (tested).
**New SPEC rule IDs.** Some outcomes had no rule ID to cite, so SPEC.md gains five IDs, none of which changes which documents are valid:
- **NIF-1**: the first character selects exactly one format (a digit, K L M, X Y Z or an organisation key); anything else, such as `T`, is not a NIF. It follows from DNI-1, KLM-1, NIE-1 and CIF-2.
- **INPUT-1** and **INPUT-2**: only strings are validated, never converted, and an empty input is reported as empty. Library contract (#40), no source.
- **POLICY-1**: the placeholder list, valid by default, rejected with `rejectPlaceholders`. Convention (ESNIC), already listed in SPEC's "Explicitly NOT implemented" table.
- **POLICY-2**: the caller's `types` restriction.
### D5. Messages, organisation names and languages
- Messages exist for every error code and include the expected character when there is one: `The control character is not correct: for this DNI it should be "Z".` / `El carácter de control no es correcto: para este DNI debería ser «Z».` `INVALID_LENGTH` and `INVALID_FORMAT` messages depend on the rule, so they describe the right document.
- `describeCifOrganisation(key, locale)` takes one organisation key, in either case, and returns its description from Orden EHA/451/2008 arts. 3 to 5 (art. 3 as amended by Orden HAP/5/2016), in the singular: `"B"` → `"Limited liability company"`, or `"Sociedad de responsabilidad limitada"` with `es`. Anything else returns `null`.
- **Languages are objects, imported one by one** (the pattern of date-fns, Zod 4 and Valibot). A locale (`NifLocale`) carries every user-facing text: the messages, the document type names they use, and the 17 organisation descriptions. English (`src/locales/en.ts`) is built in and is the default. Every other language is its own module and its own `exports` entry point, `nif-dni-nie-cif-validation/locales/`, with a named export (`es`) and a default export. `validate(x, { locale: es })` and `describeCifOrganisation(key, es)` use it. The package never selects a language from a runtime string, so a bundler drops every language the application doesn't import: `validate` alone bundles English only, and each language adds only itself (`pnpm size`, `scripts/check-tree-shaking.mjs`).
- The texts live in `src/locales/` and are read through `src/localize.ts`; only `validate()` and `describeCifOrganisation()` import them. An app that only uses the booleans bundles no text at all: the package ships ES modules with `sideEffects: false`, and CI checks it.
- **Anything that is not a locale object gives English, and nothing throws.** That includes a language code string such as `"es"`, which pre-release versions of these docs showed (MIGRATION.md, "Languages"). TypeScript rejects it; plain JavaScript gets English rather than an exception, because `validate()` must stay total (#40). A locale object that lacks a text, or whose message function throws or returns anything but a non-empty string, gives English for that text.
- [translations.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/docs/translations.md) gives the source of every organisation name per language (official translation or our own) and the terminology choices.
### D6. `getNifType`: format-based detection
`getNifType(value, opts)` returns the type `validate` would report, without checking the control character or the `types` restriction: `getNifType("12345678A")` is `"DNI"` although the letter is wrong, and `getNifType("B1234567D")` is `"CIF"`. It returns `null` when the format isn't recognisable (`"T1234567A"`, `"123456789"`, the wrong length). A non-null result therefore does **not** mean the document is valid; use `validate` or a boolean for that. It accepts `normalize` and `allowVatPrefix` because both change what is parsed.
### D7. `format`: grouping
`format(value, { separator })` validates with the default options and returns `null` if the value is invalid. Otherwise it returns the canonical form split into its parts, joined by the separator (default `"-"`):
| Type | Parts | Example |
|---|---|---|
| DNI | 8 digits, check letter | `12345678-Z` |
| NIE | prefix, 7 digits, check letter | `X-1234567-L` |
| K/L/M | prefix, 7 digits, check letter | `K-1234567-L` |
| CIF | organisation key, 7 digits, control | `B-1234567-4` |
The parts follow the definitions in SPEC (DNI-1, KLM-1, NIE-1, CIF-1). No official grouping exists; this one matches how AEAT and Interior describe the parts, and is what most forms display. `separator: ""` returns the canonical form. Any other separator value falls back to `"-"`. `format` never adds an `ES` prefix.
### D8. `computeControlCharacter`
`computeControlCharacter(partial)` returns the control character that completes a document without its last character, or `null`:
- 1 to 8 digits: DNI letter (DNI-2), after padding to 8 digits (NORM-4). `"12345678"` → `"Z"`.
- K, L or M + 7 digits: KLM-2. `"K1234567"` → `"L"`.
- X, Y or Z + 7 digits, or the old `X0` + 7 digits: NIE-2. `"X1234567"` → `"L"`.
- Organisation key + 7 digits: the official control (CIF-3 kind, CIF-4 value). `"B1234567"` → `"4"`, `"P2807900"` → `"B"`. C D F G J U V get the digit, as CIF-3 says.
The input goes through NORM-1 to NORM-3 cleanup first. By construction, `partial + computeControlCharacter(partial)` always validates (a property test checks it).
### D9. VAT numbers
`isValidSpanishVat(value, opts)` returns `true` for `ES` followed by a valid NIF (VAT-1), and `false` without the prefix. It takes the same options as the booleans. It checks the format only: **a valid format doesn't mean the number is registered in VIES**. `validate(value, { allowVatPrefix: true })` accepts a NIF with or without the prefix.
### D10. Out of scope
No official basis, see SPEC.md "Explicitly NOT implemented": the `T` prefix (reported as NIF-1), the CIF "00 needs a letter" rule, and province codes (CIF-5; a test proves no province check happens).
### D11. Test-data generators: `nif-dni-nie-cif-validation/generate` (#57)
Valid fake numbers for tests, so that nobody hard-codes a real person's NIF or writes the algorithm again. It is an opt-in entry point with its own `exports` entry: the main entry point never imports it, so `import { isValidNif }` adds 0 bytes of generator code (`pnpm size`, and `scripts/check-tree-shaking.mjs` proves that no core import bundles any generator module or code).
```ts
generateDni(opts?: { seed?; kind?: "DNI" | "K" | "L" | "M"; format? }): string
generateNie(opts?: { seed?; prefix?: "X" | "Y" | "Z"; format? }): string
generateCif(opts?: { seed?; orgKey?: CifOrganisationKey; control?: "letter" | "digit"; format? }): string
generateNif(opts?: { seed?; types?: NifType[]; format? }): string
generateInvalid(type: NifType, opts?: { seed?; reason?: NifErrorCode }): string
createGenerator(seed: number): { dni, nie, cif, nif, invalid } // the same options, without `seed`
// format?: boolean | { separator?: "-" | " " | "" } (the options of format())
```
- **The values follow SPEC.md.** A DNI is 8 digits, zero-padded (DNI-1), so about one in ten starts with `0`; K, L and M NIFs have the letter of their 7 digits (KLM-2); an NIE is X, Y or Z and 7 digits, always the canonical 9-character form (NIE-1, NIE-2; never the old 10-character one, NIE-3); a CIF has one of the 17 keys (CIF-2) and 7 random digits, with no province code (CIF-5). No value is a placeholder (POLICY-1): the DNI number is drawn from 2 to 99999998, and an X NIE from 1. `generateDni` includes the K, L and M NIFs through `kind`, as `isValidDni` includes them. The key type is `CifOrganisationKey`, the name the package already exports.
- **The control character is the library's own.** Every generator asks `computeControlCharacter` (DNI-2, KLM-2, NIE-2, CIF-3, CIF-4) for it and draws only the digits, so no algorithm is written twice and the generators can't drift from the validators. The tests still check the values with `validate()`, with stdnum and with a separate implementation of the published algorithms.
- **`control` and CIF-3.** CIF-3 gives each organisation key exactly one control type: a letter for N P Q R S W, a digit for the other eleven. So `control` never changes what a key takes. Without `orgKey` it chooses among the keys that take that type; with an `orgKey` that takes the other type it is an impossible request and throws a `RangeError`. The legacy `cifControl: "lenient"` mode (a letter on C D F G J U V) has no official basis and is never generated.
- **Programming errors throw.** An unknown `kind`, `prefix`, `orgKey`, `control`, `types` (or an empty one), `type` or `reason`, a `seed` that is not an integer, and `createGenerator` with no integer seed all throw a `RangeError`. Unlike the validators, whose input is data from users and never throws (#40), a generator's options are written by the developer, and a wrong one would otherwise give a value that silently isn't what they asked for.
- **Seeded and reproducible.** With a `seed` the numbers come from mulberry32 (`src/generate/random.ts`): a 32-bit generator made only of integer operations, plus one division by a power of two, which JavaScript defines exactly, so the same seed gives the same values on every platform, engine and Node version. `Math.random` is never read when a seed is given (a test spies on it). The seed is an integer, reduced modulo 2^32. Without a seed the generators use `Math.random`, and the values differ at every call. A call with a seed always starts from that seed, so `generateDni({ seed: 1 })` returns the same DNI each time; `createGenerator(seed)` is the stream: each call takes the next values, so the DNIs differ and the whole stream is the same on every run. Golden values for several seeds are in the tests: changing them changes every user's fixtures, so do it on purpose.
- **`generateInvalid(type, { reason })`** starts from a valid value of the type and breaks one thing, so it looks like what it stands for. The default reason is `INVALID_CONTROL_CHARACTER`, the most common negative case.
| `reason` | What the value is | Notes |
|---|---|---|
| `INVALID_CONTROL_CHARACTER` | only the control character changes | For a CIF it is wrong with `cifControl: "lenient"` too |
| `INVALID_LENGTH` | digits added after the first character, or removed | A DNI is only made longer: NORM-4 pads a short one. Never the old 10-character NIE (NIE-3), which is valid |
| `INVALID_FORMAT` | a letter instead of a digit, or a control of the wrong class | |
| `EMPTY` | an empty string, or only separators (INPUT-2) | The same for every type |
| `UNSUPPORTED_TYPE` | a valid value | Rejected only when `validate()`'s `types` leaves the type out (POLICY-2) |
| `PLACEHOLDER` | a placeholder number | Rejected only with `rejectPlaceholders` (POLICY-1). DNI and NIE only; the other types throw |
| `NOT_A_STRING` | not possible | Throws: the result is always a string |
The codes that depend on an option (`UNSUPPORTED_TYPE`, `PLACEHOLDER`) are the only ones where the value alone is not rejected by `validate()` with the default options. The tests check that every other value fails with exactly the requested code, 100,000 times per type.
- **Output.** Canonical (upper case, no separators) by default. `format: true` or `format: { separator }` uses `format()`. `generateInvalid` ignores it: an invalid value has no display form.
- **Synthetic data.** The JSDoc of every generator says it: the numbers are made up, may match a real person or company by chance, and are for tests only.
### D12. Schema adapters: `/zod`, `/valibot`, `/yup` (#58)
Most forms validate through Zod, Valibot or Yup (React Hook Form, tRPC, server actions), so each library gets ready-made schemas as its own entry point. Each library is an **optional peer dependency** (`peerDependenciesMeta.optional`; `zod@^4`, `valibot@^1`, `yup@^1`): the package still has 0 dependencies, and nothing is installed unless the application uses that adapter. Importing the core never loads an adapter or a schema library (`scripts/check-tree-shaking.mjs` checks it on the tarball, with the libraries external), and each adapter's size budget leaves the library out (`pnpm size`).
```ts
// One set per library, the same names and the same rules:
zNif(opts?: ValidateOptions) vNif(...) yNif(...) // any NIF; `types` restricts it
zDni(opts?: Omit) vDni(...) yDni(...) // DNI and K/L/M, like isValidDni
zNie(opts?: Omit) vNie(...) yNie(...)
zCif(opts?: Omit) vCif(...) yCif(...)
zSpanishVat(opts?: Omit) vSpanishVat(...) ySpanishVat(...)
// Every schema: string in, the normalized string out.
```
- **One rule set.** The three adapters are a thin mapping onto `src/adapter.ts`, which calls `validate()`: a schema accepts exactly what `validate()` accepts with the same options (`types`, `normalize`, `cifControl`, `rejectPlaceholders`, `allowVatPrefix`, `locale`), and a property test compares them on arbitrary strings for each library. `locale` is a locale object, as everywhere else: `zNif({ locale: es })`.
- **The output is the normalized value**: `" 12.345.678-z "` gives `"12345678Z"`, `X01234567L` gives `X1234567L`, `1234567L` gives `01234567L`. Input and output are both `string` (`z.input` and `z.output`, `InferInput` and `InferOutput`, `InferType`).
- **The message, the code and the rule.** A refused value gives one issue whose message is `validate().error.message`, in the locale. The code and the SPEC.md rule go where the library allows it:
| Library | Where | Shape |
|---|---|---|
| Zod | the issue's `params` (a `custom` issue) | `{ code, rule, expected? }` (`NifIssueParams`) |
| Valibot | properties of the issue (`NifIssue`, `type: "nif"`) | `issue.code`, `issue.rule`, and `issue.expected` (the right control character, else `null`, Valibot's own property) |
| Yup | the `ValidationError`'s `params`, and `type: "nif"` | `{ code, rule, expected? }` (`NifErrorParams`) |
Valibot's `addIssue` (in `rawTransform` and `rawCheck`) takes no extra properties and its `_addIssue` is internal, so the Valibot adapter is a small custom transformation that builds its issue the way Valibot's own actions do. That relies on `"~run"`, the protocol of Valibot's actions, which its types mark `@internal` (it has been stable through all of v1, hence the `valibot@^1` peer range); the tests, `scripts/check-adapters.mjs` and the oldest supported version (1.0.0) would catch a change.
- **Not a string.** INPUT-1 applies: a number is never read as a string. Zod and Valibot report their own invalid-type issue with the `NOT_A_STRING` message of the locale. Yup's `string()` would turn `12345678` into `"12345678"`, so the adapter refuses the original value instead, and the schema is required: `undefined`, `null` and other types give the `NOT_A_STRING` message, `""` gives `EMPTY`, and `.optional()` or `.nullable()` make the field optional. (Zod and Valibot are required by default; Yup's default is optional.)
- **`zDni`, `zNie`, `zCif`** are `zNif` with `types` fixed (POLICY-2), so another valid document gives `UNSUPPORTED_TYPE` with its localized message. `zDni` takes a DNI and a K/L/M NIF, as `isValidDni` does (the library's `"DNI"` type alone would refuse a K NIF). Their `opts` type has no `types`, and a `types` passed from plain JavaScript is ignored.
- **`zSpanishVat`** follows `isValidSpanishVat`: the `ES` prefix is **required** (VAT-1), the NIF after it must be valid (with its own error), and the output keeps the prefix: `ES` and the normalized NIF, so the output is a complete VAT number and validates again. A value without the prefix gives the VAT-1 length message of the locale ("A Spanish VAT number is ES followed by a 9-character NIF."), which is the message `validate("ES", { allowVatPrefix: true })` gives. `zNif({ allowVatPrefix: true })` is the schema that accepts both forms and outputs the NIF.
- **Zod 4 only.** Zod 4 is the current major. The two majors have different types (`ZodEffects` in Zod 3, `ZodPipe` in Zod 4) and Zod 3.25 ships both APIs, so one implementation can't return a correct type for both; two would double the surface to maintain for a version that is being left behind. With Zod 3, `validate()` fits in one `transform`:
```ts
import { z } from "zod"; // Zod 3
import { validate } from "nif-dni-nie-cif-validation";
const nif = z.string().transform((value, ctx) => {
const result = validate(value);
if (!result.valid) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: result.error?.message });
return z.NEVER;
}
return result.normalized as string;
});
```
- **Both module formats.** The adapters import their library by name (`import { string } from "zod"`, `require("zod")` in the CommonJS build). Yup has no `exports` map and is CommonJS, and Node can still find its named exports from an ES module, so the same code works in both; `scripts/check-adapters.mjs` runs each adapter from `import` and from `require` against the tarball and the real libraries, which the dependency-free smoke tests can't do. It also passes with the oldest supported versions (zod 4.0.0, valibot 1.0.0, yup 1.0.0).
- **React Hook Form.** Input and output are both strings, so the resolvers work as they are (`@hookform/resolvers`), and `handleSubmit` receives the normalized value:
```tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { es } from "nif-dni-nie-cif-validation/locales/es";
import { zNif } from "nif-dni-nie-cif-validation/zod";
const schema = z.object({ nif: zNif({ types: ["DNI", "NIE"], locale: es }) });
export function NifForm({ save }: { save: (nif: string) => void }) {
const { register, handleSubmit, formState: { errors } } = useForm<
z.input, unknown, z.output
>({ resolver: zodResolver(schema) });
return (
);
}
```
With Valibot, `import { valibotResolver } from "@hookform/resolvers/valibot"`, `v.object({ nif: vNif({ locale: es }) })` and `resolver: valibotResolver(schema)`; with Yup, `yupResolver(object({ nif: yNif({ locale: es }) }))`. `errors.nif.message` is the localized message in all three.
### D13. Command line interface: `npx nif-dni-nie-cif-validation` (#61)
The package has a `bin` with its own name, for shell scripts, data pipelines and the tool calls of AI agents. It has no dependencies (arguments are parsed with `node:util`'s `parseArgs`) and runs on Node.js 20 or newer. It calls only the public API, so it applies the same rules and reports the same rule IDs as the library.
| Command | What it prints | Library function |
| --- | --- | --- |
| `validate ` | One line per value: valid, with the type and the canonical form, or invalid, with the SPEC rule, the error code and the message | `validate()` |
| `type ` | The type from the format only, or a message on stderr when the format is not recognised | `getNifType()` |
| `normalize ` | The canonical form; it does not validate | `normalize()` |
| `generate ` | Valid test values, one per line: `dni`, `nie`, `cif` or `nif` (any of the three); `--count ` (up to 1,000,000), `--seed `, `--format` | `createGenerator()` |
| `check --file --column ` | A line for each invalid row, with its row number, and a summary | `validate()` |
```sh
npx nif-dni-nie-cif-validation validate 12345678Z " x-0123456-7l " b-1234567-4 12345678A
# 12345678Z: valid DNI 12345678Z
# " x-0123456-7l ": valid NIE X1234567L
# b-1234567-4: valid CIF B12345674 (Limited liability company)
# 12345678A: invalid [DNI-2 INVALID_CONTROL_CHARACTER] The control character is not correct: for this DNI it should be "Z".
```
- **The options of `validate()` are flags** of `validate` and `check`: `--types DNI,NIE` (comma-separated `NifType`s), `--cif-control lenient`, `--reject-placeholders`, `--allow-vat-prefix` and `--locale es` (`en`, `es`, `ca`, `eu` or `gl`: the language of the messages and of the organisation names). `type` takes `--allow-vat-prefix`. Their defaults are the library's, so the CLI follows SPEC.md by default. `normalize: false` has no flag: it exists for v1 compatibility in code.
- **Exit codes.** 0 when everything is valid, 1 when something is invalid (a value, a CSV row, a format that `type` doesn't recognise), 2 for a usage error: an unknown command or option, a bad option value, a file that can't be read or isn't CSV, a column that doesn't exist. A usage error writes a message to stderr and nothing to stdout. `normalize` and `generate` always exit with 0 when they run.
- **`--json` prints one JSON document** on stdout, indented with two spaces. The shapes are stable: fields may be added in a minor release, never removed or renamed.
| Command | JSON document |
| --- | --- |
| `validate` | An array with one object per value, in order: `{ input, ...ValidationResult }`, the value as given plus `validate()`'s result (`valid`, `type`, `normalized`, `error`, `meta`). Always an array, also for one value, so a script reads every call the same way |
| `type` | `{ input, type }`, where `type` is a `NifType` or `null` |
| `normalize` | `{ input, normalized }` |
| `generate` | `{ seed, values }`: the seed, also when it was random, reproduces the values with `--seed` |
| `check` | `{ file, column, rows, valid, invalid, errors }`: the counts, and `errors`, one `{ row, input, ...ValidationResult }` per invalid row |
```sh
npx nif-dni-nie-cif-validation validate 12345678A --json
# [
# {
# "input": "12345678A",
# "valid": false,
# "type": "DNI",
# "normalized": "12345678A",
# "error": {
# "code": "INVALID_CONTROL_CHARACTER",
# "message": "The control character is not correct: for this DNI it should be \"Z\".",
# "rule": "DNI-2",
# "expected": "Z"
# }
# }
# ]
```
- **`generate`** uses `createGenerator(seed)`, so `--count` gives different values and the same seed gives the same list on every platform. Without `--seed` the seed is random (and `--json` reports it). The values are synthetic, for tests only, as the generators' are (D11).
- **`check`** reads a UTF-8 CSV file (RFC 4180) whose first row has the column names: fields in double quotes may contain the delimiter, line breaks and doubled quotes; line breaks are CRLF, LF or CR; a byte order mark is ignored. `--delimiter ";"` reads the files that spreadsheets in Spanish save. `--column` is matched with the names of the first row, without leading or trailing spaces. Rows are numbered as in a spreadsheet (the first row after the names is row 2), blank lines are skipped, and a missing cell is an empty value (`EMPTY`). Where RFC 4180 has no answer the reader is lenient (a quote inside an unquoted field is kept); a quoted field that is never closed is a usage error that names its row. The whole file is read at once: the CLI is for files that fit in memory, and the library is for the rest ([examples/csv-bulk-validation](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/examples/csv-bulk-validation)).
- **`--help`** documents every command and option (` --help` for one), and `--version` prints the package's version. Text has no colours, so the output is the same in a terminal, a pipe and a log.
- **Not part of the library.** The CLI is built only as ES modules to `dist/esm/cli/` (`bin.mjs` is the executable, with its `#!/usr/bin/env node` line), without type declarations, and has no entry in `exports`: nothing can import it, so it is not API, the size budgets measure the same bytes with or without it, and a bundle never includes it. The executable has a single name, the package's: a short alias could be mistaken for another package on npm.
## Alternatives considered
- **Languages selected by a string (`locale: "es"`)** from a table of all languages, as in the first v2 drafts. Rejected (#56): a bundler can't know which entries of the table are used, so every `validate()` user paid for every language (about 0.6 kB min+gz for Spanish alone), and each new language would have grown every bundle.
- **One package per language** (`nif-dni-nie-cif-validation-es`). Rejected: more packages to publish and version together, for no gain over entry points of the same package.
- **Throwing on a string `locale`.** Rejected: `validate()` never throws (#40), and plain JavaScript that follows the pre-release docs should degrade to English, not break a form.
- **Throwing on non-strings in `normalize`.** Rejected: #40 asks for a total API on untrusted input, and `""` makes `validate` report `EMPTY`/`NOT_A_STRING` consistently.
- **Keeping `normalize: false` as the boolean default.** Rejected: `isValidNif(x)` and `validate(x).valid` would disagree on the same input, which is a trap for users of both.
- **`normalized` keeping the `ES` prefix.** Rejected: `type` is a NIF type, and a normalized value that fails `validate` with default options is surprising. Callers who store VAT numbers prepend `ES`.
- **A `types` restriction on the booleans.** Not needed: each boolean already has a fixed scope.
- **Validation options on `format`.** Left out to keep the agreed signature; a lenient-mode CIF with a letter control for C D F G J U V is therefore not formatted. It can be added later without a breaking change.
- **One adapter for Zod 3 and Zod 4.** Rejected (#58): the return types differ, and Zod 3.25 has both APIs in one package, so `import "zod"` is not enough to know which one the application uses. Zod 3 users get a recipe (D12) instead.
- **Valibot's `rawTransform` or `rawCheck` for the schemas.** Rejected: their `addIssue` accepts no extra properties, so the error code and the SPEC rule would be lost. The code and the rule are the point of `validate()`, and a form library may want them for analytics or for its own messages.
- **The schema libraries as dependencies, or the adapters as separate packages** (`nif-dni-nie-cif-validation-zod`). Rejected: a dependency would break "0 dependencies" for everyone, and separate packages would have to be published and versioned together for no gain over optional peers and entry points of the same package.
- **Adapters that map `validate()`'s result without `transform`** (checking only, leaving the value as typed). Rejected: the normalized value is what should be stored, and a schema that returns the raw input would make every caller call `normalize()` again.
- **One JSON object for a single value in `validate --json`, and an array for several.** Rejected (D13): the shape would depend on how many values a script passes, so `validate "$@" --json` would need two readers. Each element of the array is a `ValidationResult`.
- **A short alias for the command (such as `nif`).** Rejected: it could be mistaken for, or collide with, another package of that name on npm; `npx` needs the package name anyway.
---
Source: Migrating from v1 (MIGRATION.md), https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/MIGRATION.md
# Migrating from v1 to v2
Version 2.0.0 makes the validators follow [SPEC.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md), the official Spanish sources, by default. This page lists every breaking change, with code before and after, and how to get the v1 behaviour back. The website has a shorter, step-by-step version in English, Spanish, Catalan, Basque and Galician: [Migrating from v1](https://josegoval.github.io/nif-dni-nie-cif-validation/migration/from-v1/).
## Quick path: keep the v1 behaviour
Every boolean validator takes an options object as its second argument. These options give exactly the v1.0.11 results (a differential test checks it on about 490,000 inputs):
```ts
import { isValidNif } from "nif-dni-nie-cif-validation";
const V1_COMPATIBLE = { normalize: false, cifControl: "lenient" } as const;
isValidNif(value, V1_COMPATIBLE);
```
Use it as a stopgap for stored data, then move to the defaults.
## Summary
| # | Change | Who is affected | Restore v1 |
|---|---|---|---|
| 1 | CIF keys C D F G J U V need a digit control (CIF-3) | CIFs such as `G1234567D` stop validating | `{ cifControl: "lenient" }` |
| 2 | Input is normalized by default (NORM-2 to NORM-4) | Values with spaces, dots, hyphens or slashes, or a DNI without its leading zeros, start validating | `{ normalize: false }` |
| 3 | TypeScript: validators have a second parameter | `ids.filter(isValidNif)` no longer compiles | Wrap the call: `ids.filter((id) => isValidNif(id))` |
| 4 | Only the package entry points can be imported (`exports` map) | Code that imports files from `dist/`, such as `nif-dni-nie-cif-validation/dist/nif/nif` | Import from `nif-dni-nie-cif-validation` |
Nothing else changes: every v1 export keeps its name, aliases (`isValidCif`, `isValidCifControlCode`, `CIF_REGEX`, `CIF_CONTROL_LETTERS`) and constants; `replaceNieLetter` is still deprecated and still throws exactly as in v1. The package now also ships ES modules (see [4](#4-only-the-package-entry-points-can-be-imported-48)); `require()` keeps working.
## Breaking changes
### 1. CIF keys C, D, F, G, J, U and V need a digit control (#38)
The AEAT D.I.T. note ([SPEC.md CIF-3](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md#cif-3)) says the control character of the NIF of a legal person or entity (CIF) is a digit for A B C D E F G H J U V and a letter for N P Q R S W. v1 accepted a letter or a digit for C D F G J U V, which has no official basis.
Affects `isValidNif`, `isValidLegalEntityNif` / `isValidCif` and `isValidLegalEntityNifControlCode` / `isValidCifControlCode`.
```ts
// v1
isValidCif("G1234567D"); // true
isValidCif("G12345674"); // true
// v2
isValidCif("G1234567D"); // false: G needs a digit
isValidCif("G12345674"); // true
isValidCif("G1234567D", { cifControl: "lenient" }); // true, as in v1
```
`isValidCifControlCode` doesn't check the format, so v1 also accepted a letter or a digit when the first character was not an organisation key at all (`"X1234567D"`). In v2 such a value has no official control type and returns `false`; `cifControl: "lenient"` keeps the v1 result.
**Restore v1:** `{ cifControl: "lenient" }`. Only C D F G J U V are affected: A B E H always need a digit and N P Q R S W a letter, in both modes, as in v1.
### 2. The input is normalized by default
The boolean validators now clean the input before checking it, as [SPEC.md NORM-2 to NORM-4](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md#input-cleanup-never-changes-validity-only-parsing) describe:
- white space (spaces, tabs, no-break spaces, line breaks…) and dots are removed anywhere, which also trims (NORM-2);
- hyphens and slashes are removed (NORM-3);
- a DNI with fewer than 8 digits is left-padded with zeros (NORM-4): `1234567L` is checked as `01234567L`.
Cleanup never makes an invalid document valid: it accepts more ways of writing a valid one. So `true` results don't change; some `false` results become `true`.
```ts
// v1
isValidNif(" 12.345.678-Z "); // false
isValidDni("1234567L"); // false
isValidCif("B-1234567-4"); // false
// v2
isValidNif(" 12.345.678-Z "); // true
isValidDni("1234567L"); // true
isValidCif("B-1234567-4"); // true
isValidNif(" 12.345.678-Z ", { normalize: false }); // false, as in v1
```
Lower case and the old 10-character NIE form (`X01234567L`) were already accepted in v1 and still are, with or without `normalize`.
`isValidDniLetter` and `isValidCifControlCode` don't check the format, and v1 read some separators in odd ways: for example `isValidCifControlCode("A 7727886")` was `true` because a space in a digit position counted as `0`. With normalization, white space is removed instead, so such values can change in either direction.
If you store the value, store the canonical form: `normalize(value)` (new in v2) returns it, for example `"12345678Z"` for `" 12.345.678-z "`.
**Restore v1:** `{ normalize: false }`.
### 3. TypeScript: the validators have a second parameter
Every boolean validator now takes `(value: unknown, opts?: IsValidOptions)`. The first parameter was `string` and is now `unknown`, which accepts more and breaks no call. The new second parameter means TypeScript no longer accepts a validator passed straight to an array method, because the array index is not an options object:
```ts
// v1: compiles
ids.filter(isValidNif);
// v2: TypeScript error ("number" is not assignable to "IsValidOptions")
ids.filter(isValidNif);
// v2: write the callback, and pass options if you need them
ids.filter((id) => isValidNif(id));
```
At runtime nothing changes: a number (or `null`) as options is ignored, so plain JavaScript code keeps working.
**Restore v1:** not needed at runtime; in TypeScript, wrap the call as above.
### 4. Only the package entry points can be imported (#48)
v2 ships ES modules and CommonJS, each with its own type declarations, and declares them in an `exports` map. The map allows the package itself (`nif-dni-nie-cif-validation`), its languages (`nif-dni-nie-cif-validation/locales/`, see [Languages](#languages)) and `nif-dni-nie-cif-validation/package.json`. Any other path is blocked, so code that reached into the v1 build output stops working:
```ts
// v1: worked, because the files of dist/ were importable
const { isValidNif } = require("nif-dni-nie-cif-validation/dist/nif/nif");
import { isValidNif } from "nif-dni-nie-cif-validation/dist/index";
// v2: Node throws ERR_PACKAGE_PATH_NOT_EXPORTED, bundlers report the path as
// not exported, and TypeScript (node16, nodenext and bundler resolution)
// reports "Cannot find module"
const { isValidNif } = require("nif-dni-nie-cif-validation");
import { isValidNif } from "nif-dni-nie-cif-validation";
```
Everything the package exports is available from the package itself, so the fix is to import from there. The file layout of `dist/` is an implementation detail and changes between versions (v2 has `dist/esm/*.mjs` and `dist/cjs/*.cjs`).
What else changes for consumers, none of it breaking:
- `import` and `require()` both work on Node 20 and newer and in every bundler. TypeScript finds the right declarations for each (`.d.mts` for `import`, `.d.cts` for `require`). Old tools that ignore `exports` still resolve `main`, `module` and `types`.
- The package declares `"sideEffects": false`, so bundlers drop what you don't import. `import { isValidDni }` adds about 0.6 kB minified and gzipped and does not bundle the error messages or the organisation names.
- The emitted code is ES2016, as in v1, so it runs in every current browser without transpiling.
**Restore v1:** not possible for deep imports; import from the package root.
## New in v2 (not breaking)
All of these are additions; see the JSDoc of each function and [docs/api-design.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/docs/api-design.md).
- `validate(value, opts)` returns `{ valid, type, normalized, error?, meta? }`: the document type, its canonical form, and for invalid values an error code, the [SPEC.md](https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md) rule that failed, a message in the language you pass as `locale` (English by default, see [Languages](#languages)), and the expected control character.
```ts
validate("12345678A");
// { valid: false, type: "DNI", normalized: "12345678A",
// error: { code: "INVALID_CONTROL_CHARACTER", rule: "DNI-2", expected: "Z",
// message: 'The control character is not correct: for this DNI it should be "Z".' } }
```
- `getNifType(value)`: the document type from its format, without checking the control character.
- `normalize(value)`: the canonical form to store, for example `"12345678Z"` for `" 12.345.678-z "`.
- `format(value, { separator })`: `"12345678-Z"`, `"X-1234567-L"`, `"B-1234567-4"`, or `null` if invalid.
- `computeControlCharacter(partial)`: `"Z"` for `"12345678"`, `"4"` for `"B1234567"`.
- `describeCifOrganisation(key, locale)`: `"Limited liability company"` for `"B"`, `"Sociedad de responsabilidad limitada"` with `es`.
- `isValidSpanishVat(value)`: `ES` + a valid NIF. It checks the format only, not whether the number is registered in VIES.
- Options: `rejectPlaceholders` (reject `00000000T`, `00000001R`, `99999999R`, `X0000000T`), and for `validate` also `types`, `allowVatPrefix` and `locale`.
- Locales: `nif-dni-nie-cif-validation/locales/es` (and `/locales/en`), see [Languages](#languages).
- Types: `NifType`, `NifErrorCode`, `NifLocale`, `NifMessages`, `NifLengthRule`, `NifFormatRule`, `CifOrganisationKey`, `CifControlMode`, `ValidateOptions`, `IsValidOptions`, `GetNifTypeOptions`, `ValidationResult`, `NifValidationError`, `CifOrganisationMeta`, `FormatOptions`.
## Languages
`validate()` and `describeCifOrganisation()` answer in English unless you pass a locale object. Each language is a separate entry point of the package, so a bundle only contains the languages it imports (English is built in):
```ts
import { validate, describeCifOrganisation } from "nif-dni-nie-cif-validation";
import { es } from "nif-dni-nie-cif-validation/locales/es";
validate("12345678A", { locale: es }).error?.message;
// "El carácter de control no es correcto: para este DNI debería ser «Z»."
describeCifOrganisation("B", es); // "Sociedad de responsabilidad limitada"
```
| Language | Entry point | Object |
| --- | --- | --- |
| English (default) | `nif-dni-nie-cif-validation/locales/en` | `en` |
| Spanish | `nif-dni-nie-cif-validation/locales/es` | `es` |
| Catalan, also for Valencian | `nif-dni-nie-cif-validation/locales/ca` | `ca` |
| Basque | `nif-dni-nie-cif-validation/locales/eu` | `eu` |
| Galician | `nif-dni-nie-cif-validation/locales/gl` | `gl` |
Each entry point also has a default export, and works with `require()`: `const { es } = require("nif-dni-nie-cif-validation/locales/es")`.
**Changed during the v2 pre-release.** Earlier drafts of the v2 docs showed `locale: "es"` (a language code) and `describeCifOrganisation(key, "es")`. That was never released. Pass the imported object instead:
```ts
// v2 pre-release docs
validate(value, { locale: "es" });
describeCifOrganisation("B", "es");
// v2
import { es } from "nif-dni-nie-cif-validation/locales/es";
validate(value, { locale: es });
describeCifOrganisation("B", es);
```
A string is still accepted at runtime, so plain JavaScript that follows the old docs doesn't break, but it is ignored: the messages are in English. TypeScript reports it as an error. Nothing throws: anything that is not a locale object gives English, and a locale object that lacks a text gives English for that text.
## Checklist
1. Upgrade, and run your tests.
2. If stored CIFs with a letter control for C D F G J U V must stay valid, pass `{ cifControl: "lenient" }` where you validate them, and plan to fix the data (the AEAT assigns a digit to those keys).
3. If some code relied on `false` for values with separators (for example to force users to type the canonical form), pass `{ normalize: false }`, or better, store `normalize(value)`.
4. In TypeScript, replace `array.filter(isValidX)` with an arrow function.
5. If you import files from `nif-dni-nie-cif-validation/dist/...`, import from `nif-dni-nie-cif-validation` instead.
6. If you want messages in Spanish, import the locale object (`nif-dni-nie-cif-validation/locales/es`) and pass it as `locale`.
---
Source: Validation rules and their sources (SPEC.md), https://github.com/josegoval/nif-dni-nie-cif-validation/blob/master/SPEC.md
# Validation rules specification
This document is the authoritative specification of what `nif-dni-nie-cif-validation` accepts and rejects, and why. It is the library's own document, not a government one: every rule has an ID, a source tier and a citation, so any behaviour of the library can be traced back to the official text that justifies it (or be labelled as convention when no official text exists).
Last verified: 2026-09-30
## Contents
- [How to use rule IDs](#how-to-use-rule-ids)
- [How to propose a change](#how-to-propose-a-change)
- [Source tiers](#source-tiers)
- [Rules](#rules)
- [Every NIF](#every-nif)
- [Natural persons: DNI](#natural-persons-dni)
- [Natural persons: K / L / M NIF](#natural-persons-k--l--m-nif)
- [Natural persons: NIE](#natural-persons-nie)
- [Legal persons and entities: NIF (formerly CIF)](#legal-persons-and-entities-nif-formerly-cif)
- [VAT (intra-EU)](#vat-intra-eu)
- [Input cleanup](#input-cleanup-never-changes-validity-only-parsing)
- [Input contract](#input-contract-library-behaviour-no-official-content)
- [Opt-in policies](#opt-in-policies-off-by-default)
- [Explicitly NOT implemented](#explicitly-not-implemented-no-official-basis)
- [Decisions](#decisions)
- [How input cleanup works](#how-input-cleanup-works-settled-by-the-v2-api-2026-09-30)
- [Type detection is format-based](#type-detection-is-format-based-getniftype-settled-2026-09-30)
- [Display format](#display-format-format-settled-2026-09-30)
- [Single-character substitutions](#single-character-substitutions-checked-by-the-property-tests)
- [Known conflicts between sources](#known-conflicts-between-sources)
- [Open questions](#open-questions)
- [Differences from other libraries](#differences-from-other-libraries)
- [Test values](#test-values-all-computed-and-checked)
- [Source URLs](#source-urls)
## How to use rule IDs
- Each rule below has an ID made of a family and a number: `DNI-1`, `KLM-2`, `NIE-3`, `CIF-3`, `VAT-1`, `NORM-1`.
- Code comments and test names reference these IDs, for example `// CIF-3 (AEAT D.I.T. 2008)`. Every validation branch in `src/` should cite the rule it implements, and every ID used in the code must exist in this file with the same meaning.
- Each ID has an anchor, so it can be linked directly: `SPEC.md#cif-3`, `SPEC.md#nie-3`.
- IDs are stable. If a rule changes, update its text and keep the ID. If a rule is dropped, keep the ID retired in this file rather than reusing the number.
## How to propose a change
1. Open an issue that names the rule ID (or proposes a new one) and cites the source: the law (article), the official page, or the document. Conventions with no official text are accepted only as documented T4 rules (see the tiers below).
2. Update this file in the same pull request as the code change, bump the "Last verified" date, and keep the code comments and test names in sync with the IDs.
3. A change that makes the library accept fewer or more inputs by default is a breaking change (major version).
## Source tiers
| Tier | Meaning | Sources |
|---|---|---|
| **T1: Law (BOE)** | Published legal text in force | RD 1065/2007 (RGAT) arts. 19–22, 24, 25 · Orden EHA/451/2008 (as amended by Orden HAP/5/2016) · Orden INT/2058/2008 (in Orden 7/2/1997, apartado sexto.a) · RD 255/2025 (DNI; replaced RD 1553/2005 on 2025-04-02) · RD 1155/2024 art. 205 (NIE; replaced RD 557/2011 on 2025-05-20) |
| **T2: Official government page** | Published by the responsible administration | Ministerio del Interior, "Cálculo del dígito de control del NIF/NIE" · AEAT Sede, "Composición del NIF" (personas físicas / personas jurídicas) |
| **T3: Semi-official** | Internal AEAT technical document | AEAT D.I.T. note "Número de Identificación Fiscal (N.I.F.)", updated 2008-04-15, hosted by CAIB (see [Source URLs](#source-urls)) |
| **T4: Convention** | Industry practice with no official text | Documented. Never on by default unless it only affects input cleanup, with one documented exception: no official text publishes the CIF control arithmetic, so the universal algorithm applies by default ([CIF-4](#cif-4)). |
## Rules
### Every NIF
| ID | Rule | Tier | Source |
|---|---|---|---|
| NIF-1 | The first character selects exactly one format: a digit (DNI-1), K L M (KLM-1), X Y Z (NIE-1) or an organisation key (CIF-2). These sets never overlap. Any other first character (for example `T`, see [Explicitly NOT implemented](#explicitly-not-implemented-no-official-basis)) is not a NIF | follows from DNI-1, KLM-1, NIE-1 and CIF-2 | RD 1065/2007 arts. 19–22 |
### Natural persons: DNI
| ID | Rule | Tier | Source |
|---|---|---|---|
| DNI-1 | 8 digits (leading zeros allowed) followed by one upper-case check letter | T1 + T2 | RD 1065/2007 art. 19.1 ("una letra mayúscula"); AEAT Sede: "ocho dígitos (teniendo en cuenta que los primeros pueden ser ceros) más una letra de control" |
| DNI-2 | Check letter = `TRWAGMYFPDXBNJZSQVHLCKE`[number mod 23] | T2 + T3 | Interior: "Se divide el número entre 23 y el resto se sustituye por una letra…"; AEAT D.I.T. note |
| DNI-3 | I, Ñ, O and U are never valid check letters (they aren't in the table) | follows from DNI-2 | — |
The mod-23 algorithm is **not** in any BOE text. T2 (Interior) is its most authoritative source.
### Natural persons: K / L / M NIF
| ID | Rule | Tier | Source |
|---|---|---|---|
| KLM-1 | K (Spaniards under 14 living in Spain, without DNI), L (Spaniards resident abroad, without DNI), M (foreigners without NIE) + 7 characters + an alphabetic check character | T1 | RD 1065/2007 arts. 19.2 and 20.2 |
| KLM-2 | The check letter uses DNI-2 over **the 7 digits only** (the prefix is not converted to a digit) | T3 | AEAT D.I.T.: "…los siete dígitos siguientes a la letra inicial en el resto de los casos" |
| KLM-3 | The 7 characters are digits | T3 | The AEAT D.I.T. note says "siete dígitos numéricos". **Conflict:** the BOE says "siete caracteres alfanuméricos". We follow T3 because the check algorithm needs digits. See [Known conflicts between sources](#known-conflicts-between-sources). |
An M NIF can be temporary (AEAT: "válido por tres meses" while the NIE is pending). That is a registry state, not a format rule.
### Natural persons: NIE
| ID | Rule | Tier | Source |
|---|---|---|---|
| NIE-1 | X, Y or Z + 7 digits + alphabetic check character. After the X series runs out, the next letter in alphabetical order is used | T1 + T2 | Orden INT/2058/2008; AEAT: NIEs "que comiencen por X, Y o por Z" |
| NIE-2 | For the check, X→0, Y→1, Z→2, then apply DNI-2 | T2 + T3 | Interior: "se sustituye: X → 0 Y → 1 Z → 2 y se aplica el mismo algoritmo que para el NIF"; AEAT D.I.T. note |
| NIE-3 | **Old 10-character NIEs** (X + 8 digits + letter) stay valid. The canonical form drops the zero right after the X: `X0nnnnnnnL` → `XnnnnnnnL` | T1 + T2 | Orden INT/2058/2008, transitional provision ("seguirán teniendo validez"); AEAT Sede ("omitiendo el primer cero que figuraba después de la X") |
### Legal persons and entities: NIF (formerly CIF)
| ID | Rule | Tier | Source |
|---|---|---|---|
| CIF-1 | 9 characters: organisation key + 7 digits + a control character | T1 | Orden EHA/451/2008 art. 2; RD 1065/2007 art. 22.1 |
| CIF-2 | Valid keys: A B C D E F G H J N P Q R S U V W. No key has ever been removed; K, L, M, X, Y and Z are natural-person prefixes, never entities | T1 | Orden EHA/451/2008 arts. 3–5 (art. 3 as amended by Orden HAP/5/2016, in force 2016-01-16) |
| CIF-3 | Control type: **digit** for A B C D E F G H J U V (Spanish entities); **letter** for N P Q R S W | T3 | AEAT D.I.T.: "numérico (0 a 9) para ent. jurídicas nacionales. Alfabético (A…J) para entidades jurídicas extranjeras, corporaciones locales, organismos públicos y congregaciones religiosas y órganos de la Administración" |
| CIF-4 | Control arithmetic: add the digits in even positions (2, 4, 6); for the digits in odd positions (1, 3, 5, 7), double each one and add the digits of each result; control = (10 − total mod 10) mod 10; letter = `JABCDEFGHI`[control] | **T4** | No official text defines it (checked: Decreto 2423/1975, the Orders of 1980/85/89/98, RD 338/1990, RD 1065/2007, Orden EHA/451/2008). Universal industry consensus, checked against public-body NIFs P2807900B (Ayuntamiento de Madrid) and Q2826000H (AEAT). See [Known conflicts between sources](#known-conflicts-between-sources). |
| CIF-5 | The 7 digits are random. **Do not** validate province codes | T1 | Orden EHA/451/2008 art. 2.b "número aleatorio"; preamble: "desaparece… la información relativa a la provincia" |
### VAT (intra-EU)
| ID | Rule | Tier | Source |
|---|---|---|---|
| VAT-1 | Spanish VAT number format = `ES` + NIF. A valid format does **not** mean the number is registered in VIES | T1 | RD 1065/2007 art. 25.1 |
### Input cleanup (never changes validity, only parsing)
| ID | Rule | Tier | Source |
|---|---|---|---|
| NORM-1 | The canonical form is upper case. Accepting lower-case input is convention | T1 (canonical) / T4 (accepting) | RD 1065/2007 art. 19.1 |
| NORM-2 | Spaces and dots are ignored | T2 (EU Commission VIES page, not a Spanish source) | VIES help: "Spaces and dots between blocks of digits should be ignored" |
| NORM-3 | Hyphens and slashes are ignored | T4 | — |
| NORM-4 | A DNI entered with fewer than 8 digits is left-padded with zeros to its canonical form | T2 (canonical) / T4 (padding input) | AEAT Sede: "los primeros pueden ser ceros" |
### Input contract (library behaviour, no official content)
These rules don't decide which documents are valid. They say how the library treats input that is not a document at all, so that every error can cite a rule.
| ID | Rule | Tier | Source |
|---|---|---|---|
| INPUT-1 | Only strings are validated. Any other value (`null`, numbers, objects) is rejected without being converted to a string, and no validator throws | Library contract | #40 |
| INPUT-2 | A value that is empty, or only separators (NORM-2, NORM-3), is reported as empty | Library contract | #56 |
### Opt-in policies (off by default)
These rules are not in any official source. They never apply unless the caller asks for them, so they never change the default result, which follows only the rules above (official rules and the documented T4 conventions: input cleanup, and the CIF control arithmetic of [CIF-4](#cif-4)).
| ID | Rule | Tier | Source |
|---|---|---|---|
| POLICY-1 | Placeholder numbers `00000000T`, `00000001R`, `99999999R` and `X0000000T` are valid documents by default. With `rejectPlaceholders: true` they are rejected (error `PLACEHOLDER`), in any accepted form (lower case, old NIE form `X00000000T`, with separators) | T4 | ESNIC (.es registry) filters them as obviously fake; no law forbids them. See [Explicitly NOT implemented](#explicitly-not-implemented-no-official-basis) |
| POLICY-2 | The caller may accept only some document types (option `types`). A document of another type is rejected (error `UNSUPPORTED_TYPE`), even when it is valid | Library option | #56 |
## Explicitly NOT implemented (no official basis)
| Folklore rule | Finding | Decision |
|---|---|---|
| "A CIF number starting with `00` must have a letter control" | No official list has ever had province code 00. It traces back to an uncited Wikipedia table | **Do not implement.** Remove the dead code; don't turn it on |
| "C D F G J U V accept either letter or digit" | Contradicts CIF-3 (T3) | Off by default. Opt-in `cifControl: "lenient"` for legacy data (**decision approved 2026-09-30**; breaking change in v2) |
| NIE / NIF with a `T` prefix | No official source | **Not supported** (rejected as [NIF-1](#nif-1)) |
| Province-code validation for CIFs | Repealed; random since 2008 | **Not implemented** |
| Rejecting 00000000T, 00000001R, 99999999R, X0000000T | No law forbids them; ESNIC (.es registry) filters them as obviously fake | Valid by default. Opt-in `rejectPlaceholders: true` ([POLICY-1](#policy-1)) |
## Decisions
### CIF keys C, D, F, G, J, U and V: digit-only by default from v2 (approved 2026-09-30)
- From v2, organisation keys C, D, F, G, J, U and V require a **digit** control, as CIF-3 states. This is a breaking change.
- An opt-in option `cifControl: "lenient"` keeps the legacy behaviour (either a letter or a digit) for these keys, for old data.
- v1.x kept accepting either a letter or a digit for C, D, F, G, J, U and V, so existing users were not broken. 2.0.0 implements the decision: `cifControl` defaults to `"official"`, and `{ cifControl: "lenient" }` restores the v1 behaviour (see MIGRATION.md).
- The keys A, B, E and H (digit) and N, P, Q, R, S and W (letter) already follow CIF-3 in v1.
### How input cleanup works (settled by the v2 API, 2026-09-30)
NORM-1 to NORM-4 say what is ignored; the implementation (`normalize()`) settles the details:
- **White space** is exactly JavaScript's `\s` (spaces, tabs, line breaks, no-break spaces, the BOM…), removed anywhere, which also trims. Dots, hyphen-minus (`-`) and slashes are removed anywhere. Other punctuation (`_`, `,`, the en dash `–`) is kept, so the value stays invalid.
- **Case**: only ASCII letters and `ñ` are upper-cased. Non-ASCII look-alikes that `toUpperCase()` maps to ASCII letters (`ı` → `I`, `ſ` → `S`) are kept, so they never become valid.
- **Order**: separators and case, then NIE-3 (`X0nnnnnnnL` → `XnnnnnnnL`), then NORM-4 (1 to 7 digits and a letter are left-padded to 8 digits).
- **`normalize: false`** turns off NORM-2 to NORM-4. Lower case (NORM-1) and the old NIE form (NIE-3) are still accepted, as in v1.
- **`ES` is not cleanup**: an `ES` prefix makes a VAT number (VAT-1), accepted only with `allowVatPrefix` or by `isValidSpanishVat`.
### Type detection is format-based (`getNifType()`, settled 2026-09-30)
`getNifType()` returns the type that `validate()` reports: the one selected by the first character (NIF-1), once the length, the digits and the class of the control character (a letter for DNI, K/L/M and NIE; a letter or a digit for CIF) match that type's format. It does **not** check the control character: `12345678A` is a `"DNI"` and `B1234567D` is a `"CIF"`, although both are invalid. So a non-null type never means "valid".
### Display format (`format()`, settled 2026-09-30)
No official source defines a display grouping. `format()` splits the canonical form into the parts the rules define, joined by a separator (`-` by default, a space, or nothing):
| Type | Parts | Rules | Example |
|---|---|---|---|
| DNI | 8 digits · letter | DNI-1 | `12345678-Z` |
| NIE | prefix · 7 digits · letter | NIE-1 | `X-1234567-L` |
| K/L/M NIF | prefix · 7 digits · letter | KLM-1 | `K-1234567-L` |
| NIF of a legal person or entity | key · 7 digits · control | CIF-1 | `B-1234567-4` |
Only valid documents are formatted (default options); `format()` returns `null` otherwise and never adds an `ES` prefix.
### Single-character substitutions (checked by the property tests)
Replacing one character of a valid document (positions 2 to 9) always gives an invalid one: DNI-2 changes with every digit (10^k mod 23 is never 0), CIF-4 too (doubling a digit and adding the digits of the result is a permutation), a letter in the number breaks the format, and another control character is wrong. Replacing the **first** character can give another valid document, because the prefix counts for little or nothing in the control:
- K, L and M are interchangeable (KLM-2 ignores the prefix);
- organisation keys with the same control class are interchangeable (CIF-4 ignores the key);
- across types: a DNI starting with 0, 1 or 2 has the same letter as the NIE X, Y or Z (NIE-2) and, for 0, the K/L/M NIF with the same digits; other swaps can hit a control that happens to match.
With `cifControl: "lenient"`, the digit and the letter of the same control value are both valid for C D F G J U V.
## Known conflicts between sources
### KLM-3: "alfanuméricos" (BOE) vs "dígitos" (AEAT)
- RD 1065/2007 arts. 19.2 and 20.2 (T1) describe the K/L/M NIF as a letter, "siete caracteres alfanuméricos" and a check character.
- The AEAT D.I.T. note (T3) says "siete dígitos numéricos".
- Decision: we follow the AEAT D.I.T. note, because the check algorithm (KLM-2) is computed over the 7 digits and is undefined for arbitrary characters. If an official source ever publishes a check algorithm for non-digit characters, KLM-3 must be revisited.
### CIF-4: the control arithmetic is not published anywhere official
- No official text defines the control arithmetic for legal-entity NIFs (see the list of texts checked in CIF-4). It is a T4 convention.
- It is the only T4 rule that decides validity by default: without it, the control character of a legal-entity NIF could not be checked at all.
- It is the universal industry algorithm, and it was checked against the real public-body NIFs P2807900B (Ayuntamiento de Madrid) and Q2826000H (AEAT).
- Because it is a T4 convention, an official publication of the algorithm would take precedence over this entry.
## Open questions
- **A 1975 circular we could not find.** A circular of the Subsecretaría de Hacienda from 1975 might mention a historical use of "00". We could not find it. Until it is found, the "CIF starting with `00` needs a letter" rule stays in [Explicitly NOT implemented](#explicitly-not-implemented-no-official-basis): no official list we checked has ever had province code 00.
- **A written question to AEAT.** Option: ask the AEAT in writing to confirm (a) the control type per organisation key (CIF-3) and (b) the control arithmetic (CIF-4), since both come only from an internal technical note and from convention. Not done yet.
## Differences from other libraries
`src/__tests__/stdnum.test.ts` compares `validate()` (default options) with [stdnum](https://www.npmjs.com/package/stdnum) (the JavaScript port of python-stdnum, `stdnum.ES.nif`) on about 100,000 generated inputs. Every difference must be one of these decisions; any other difference fails the test.
| Difference | stdnum | This library | Rule |
|---|---|---|---|
| Control of C D F G J U V (and every other key) | Accepts a letter or a digit for every organisation key | Digit for A B C D E F G H J U V, letter for N P Q R S W; `cifControl: "lenient"` accepts either for C D F G J U V only | [CIF-3](#cif-3) |
| Old NIE form `X0nnnnnnnL` | Rejected | Valid, canonical `XnnnnnnnL` | [NIE-3](#nie-3) |
| DNI with fewer than 8 digits (`1234567L`) | Rejected | Left-padded to `01234567L` | [NORM-4](#norm-4) |
| White space other than a space (tab, no-break space, …) | Kept, so the value is invalid | Removed, like spaces | [NORM-2](#norm-2) |
| Non-ASCII look-alikes (`0`, `ſ`, `ı`, …) | Folded to ASCII | Kept, so the value is invalid: the canonical form is ASCII upper case | [NORM-1](#norm-1) |
| `ES` prefix | Always stripped | Only with `allowVatPrefix`, or `isValidSpanishVat` | [VAT-1](#vat-1) |
| K/L/M NIF with non-digits after the prefix | Not checked (the number is read with `parseInt`) | Invalid | [KLM-3](#klm-3) |
| Placeholders (`00000000T`, …) | Valid | Valid by default; rejected with `rejectPlaceholders` | [POLICY-1](#policy-1) |
## Test values (all computed and checked)
Valid: `12345678Z` · `01234567L` · `00000008P` · `X1234567L` · `Y1234567X` · `Z1234567R` · `X01234567L` (old form → `X1234567L`) · `K1234567L` · `L1234567L` · `M1234567L` · `A58818501` · `B12345674` · `B00123455` · `P2807900B` · `Q2826000H` · `N1234567D` · `W1234567D` · `ES12345678Z` (VAT format)
Invalid:
- `12345678A` (wrong letter)
- `12345678I` / `O` / `U` (letter not in the table)
- `Y1234567L` (the prefix changes the letter)
- `X11234567L` (only a leading 0 may be dropped)
- `K1234567A` (wrong letter)
- `T12345678` (no official prefix)
- `123456789` (the check character must be a letter)
- `B1234567D` (B needs a digit, CIF-3; valid only with `cifControl: "lenient"`)
- `G1234567D` (G needs a digit, CIF-3; valid only in lenient mode)
- `Q12345674` / `N12345674` (these keys need a letter)
- `I1234567D` / `K1234567D` / `X1234567D` (not entity keys; K and X are natural persons with their own rules)
- `B1234567`, `B123456745` (wrong length)
- `B12345675` (wrong control)
- `B0012345E` (CIF-3: B needs a digit, and the `00` rule is folklore)
Policy (valid by default, rejected with `rejectPlaceholders`): `00000000T` · `00000001R` · `99999999R` · `X0000000T`
## Source URLs
Live status checked on 2026-09-30 with `curl -sI -L`. Every source also has an archived copy in the Wayback Machine, so the sources survive URL changes. The archived link is the closest snapshot to 2026-10-02 (the number after `/web/` in each link is its date and time, UTC), found through `https://archive.org/wayback/available?url=`, and each one answered HTTP 200 on 2026-10-02. No capture had to be requested, except for the VIES help text (added on 2026-10-03, when it had no snapshot). The oldest is the CAIB copy of the AEAT D.I.T. note (2020-10-11), which is the only snapshot there is. To check another date, look a page up at `https://web.archive.org/web/*/`.
The T1 and T2 sources are watched for changes. `spec-sources.json`, in the repository, records for each BOE text the date of its last update (and of the articles cited here) and whether it was repealed, and for each T2 page a hash of the text of its relevant section. A monthly workflow compares them with the sources and opens an issue labelled `spec-change` when one changes (CONTRIBUTING.md, "Official sources").
- RD 1065/2007: ([archived](https://web.archive.org/web/20260930063029/https://www.boe.es/buscar/act.php?id=BOE-A-2007-15984))
- Orden EHA/451/2008 (consolidated): ([archived](https://web.archive.org/web/20260307034313/https://www.boe.es/buscar/act.php?id=BOE-A-2008-3580))
- Orden HAP/5/2016: ([archived](https://web.archive.org/web/20241111020505/https://www.boe.es/buscar/doc.php?id=BOE-A-2016-358))
- Orden INT/2058/2008: ([archived](https://web.archive.org/web/20250427044404/https://www.boe.es/buscar/doc.php?id=BOE-A-2008-12050))
- Orden 7/2/1997 (consolidated): ([archived](https://web.archive.org/web/20260518072554/https://www.boe.es/buscar/act.php?id=BOE-A-1997-3364))
- RD 255/2025 (DNI): ([archived](https://web.archive.org/web/20260716095158/https://www.boe.es/buscar/act.php?id=BOE-A-2025-6601))
- RD 1155/2024 (extranjería): ([archived](https://web.archive.org/web/20260921102515/https://www.boe.es/buscar/act.php?id=BOE-A-2024-24099))
- Decreto 2423/1975 (repealed): ([archived](https://web.archive.org/web/20260711114508/https://www.boe.es/buscar/doc.php?id=BOE-A-1975-21698))
- Interior, check-letter calculation: (live: HTTP 403 to automated requests, probably bot blocking; [archived](https://web.archive.org/web/20260228172549/https://interior.gob.es/opencms/es/servicios-al-ciudadano/tramites-y-gestiones/dni/calculo-del-digito-de-control-del-nif-nie/))
- AEAT, NIF of natural persons: ([archived](https://web.archive.org/web/20251126010123/https://sede.agenciatributaria.gob.es/Sede/ayuda/manuales-videos-folletos/manuales-practicos/guia-practica-cumplimentacion-modelo-censal-036/anexos/anexo-01-solicitud-nif-documentacion-aportar/informacion-sobre-numero-identificacion-fiscal/composicion-nif/personas-fisicas.html))
- AEAT, NIF of legal persons and entities: ([archived](https://web.archive.org/web/20260423071917/https://sede.agenciatributaria.gob.es/Sede/ayuda/manuales-videos-folletos/manuales-practicos/guia-practica-cumplimentacion-modelo-censal-036/anexos/anexo-01-solicitud-nif-documentacion-aportar/informacion-sobre-numero-identificacion-fiscal/composicion-nif/personas-juridicas-entidades.html))
- AEAT D.I.T. note (CAIB copy): ([archived](https://web.archive.org/web/20201011095754/http://www.caib.es/sites/civitasmanualsisuport/f/41519))
- AEAT, checking the NIF of third parties (census check, not format): ([archived](https://web.archive.org/web/20260518130212/https://sede.agenciatributaria.gob.es/Sede/ayuda/consultas-informaticas/presentacion-declaraciones-ayuda-tecnica/modelo-030/comprobacion-nif-terceros-efectos-censales.html))
- EU Commission, VIES help (NORM-2; live and archived copy checked on 2026-10-03): . The page loads the quoted text from (key `help_txt_para00`), which is what the archived link captures ([archived](https://web.archive.org/web/20261003150619/https://ec.europa.eu/taxation_customs/vies/assets/i18n/en.json))
- ESNIC placeholder examples (secondary): ([archived](https://web.archive.org/web/20260314051503/https://www.openprovider.com/es/blog/comunicado-esnic))