How we wrote a NIF/CIF/NIE validator in three languages (and why it's open source)

DNI, NIE, and CIF look similar from the outside (nine characters, one control digit) but each uses a completely different checksum algorithm. Here's exactly how we implemented all three in C#, TypeScript, and Python, and why we shipped the whole thing as MIT.

Code validation across multiple programming languages
Photo by Daniil Komov on Unsplash

Why validating NIFs is subtler than it looks

If you're building a Spanish fiscal API — VeriFactu submission, FacturaE generation, invoice extraction — you need to validate NIFs. Not just "is this nine characters and a letter", but actually verify the checksum. A NIF with a wrong control character will get rejected by AEAT or FACe, and the error message won't always tell you why.

The problem is that Spain has three different identifier types that share the same nine-character format but use completely different validation algorithms:

Most implementations get DNI right. NIE they handle badly (missing the prefix conversion). CIF they get wrong about 40% of the time because the control character rule has two branches and the documentation is ambiguous about which entity types use which branch.

We needed this in three languages: C# for the Kreyo API itself, TypeScript for the SDK, Python for the docs examples. Rather than maintain three diverging implementations, we wrote them all at once, drove them with the same test vectors, and put the whole thing on GitHub under MIT.


DNI: modulo 23 over the magic string

The DNI algorithm is the simplest. Take the eight-digit number (no leading zeros lost — it's stored as a string and parsed to integer), compute number % 23, and look up the result in the control letter table TRWAGMYFPDXBNJZSQVHLCKE. If the lookup matches the ninth character, the DNI is valid.

The letter table is fixed by regulation. Its ordering is deliberately non-alphabetical to prevent guessing patterns and to exclude letters that look ambiguous on ID documents (I, O, U are not in the table).

// C#
const string ControlLetters = "TRWAGMYFPDXBNJZSQVHLCKE";

static bool IsValidDni(string dni)
{
    if (dni.Length != 9) return false;
    if (!int.TryParse(dni[..8], out int number)) return false;
    return char.ToUpperInvariant(dni[8]) == ControlLetters[number % 23];
}
// TypeScript
const CONTROL_LETTERS = 'TRWAGMYFPDXBNJZSQVHLCKE';

function isValidDni(dni: string): boolean {
  if (dni.length !== 9) return false;
  const num = parseInt(dni.slice(0, 8), 10);
  if (isNaN(num)) return false;
  return dni[8].toUpperCase() === CONTROL_LETTERS[num % 23];
}
# Python
CONTROL_LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE"

def is_valid_dni(dni: str) -> bool:
    if len(dni) != 9:
        return False
    try:
        num = int(dni[:8])
    except ValueError:
        return False
    return dni[8].upper() == CONTROL_LETTERS[num % 23]

NIE: DNI with a prefix substitution

NIEs follow the same modulo-23 algorithm as DNIs, with one extra step: the initial letter (X, Y, or Z) must be converted to a digit before the algorithm runs. X becomes 0, Y becomes 1, Z becomes 2. After substitution, you validate the resulting nine-character string exactly like a DNI.

// C#
static bool IsValidNie(string nie)
{
    if (nie.Length != 9) return false;
    char prefix = char.ToUpperInvariant(nie[0]);
    char digit = prefix switch
    {
        'X' => '0',
        'Y' => '1',
        'Z' => '2',
        _ => '\0'
    };
    if (digit == '\0') return false;
    return IsValidDni(digit + nie[1..]);
}
// TypeScript
function isValidNie(nie: string): boolean {
  if (nie.length !== 9) return false;
  const prefix = nie[0].toUpperCase();
  const digitMap: Record<string, string> = { X: '0', Y: '1', Z: '2' };
  const digit = digitMap[prefix];
  if (!digit) return false;
  return isValidDni(digit + nie.slice(1));
}
# Python
PREFIX_MAP = {'X': '0', 'Y': '1', 'Z': '2'}

def is_valid_nie(nie: str) -> bool:
    if len(nie) != 9:
        return False
    prefix = nie[0].upper()
    digit = PREFIX_MAP.get(prefix)
    if digit is None:
        return False
    return is_valid_dni(digit + nie[1:])

CIF: the one everyone gets wrong

The CIF algorithm is more complex and has a branch that most implementations miss. The structure is: one letter + seven digits + one control character (which can be a digit or a letter depending on the starting letter of the CIF).

The checksum computation

Iterate over the seven-digit body (positions 1–7, one-indexed). For odd positions (1, 3, 5, 7): double the digit, then add the digit sum of the result (digit 9 becomes 18, which contributes 1+8=9). For even positions (2, 4, 6): add the digit directly. Sum everything, then the control digit is (10 − (total % 10)) % 10.

The control character depends on the starting letter:

// C#
const string CifControlLetters = "JABCDEFGHI";
const string ValidFirstLetters  = "ABCDEFGHJKLMNPQRSUVW";
const string LetterOnlyFirst    = "PQRSWKLMN";

static bool IsValidCif(string cif)
{
    if (cif.Length != 9) return false;
    char first = char.ToUpperInvariant(cif[0]);
    if (!ValidFirstLetters.Contains(first)) return false;

    int oddSum = 0, evenSum = 0;
    for (int i = 1; i <= 7; i++)
    {
        if (!char.IsDigit(cif[i])) return false;
        int d = cif[i] - '0';
        if (i % 2 == 1) // odd position
        {
            int doubled = d * 2;
            oddSum += (doubled / 10) + (doubled % 10);
        }
        else // even position
        {
            evenSum += d;
        }
    }

    int controlDigit = (10 - ((oddSum + evenSum) % 10)) % 10;
    char control = char.ToUpperInvariant(cif[8]);

    if (LetterOnlyFirst.Contains(first))
        return control == CifControlLetters[controlDigit];

    return control == CifControlLetters[controlDigit]
        || control - '0' == controlDigit;
}
// TypeScript
const CIF_CONTROL_LETTERS = 'JABCDEFGHI';
const LETTER_ONLY_FIRST   = new Set([...'PQRSWKLMN']);

function isValidCif(cif: string): boolean {
  if (cif.length !== 9) return false;
  const first = cif[0].toUpperCase();
  if (!/^[ABCDEFGHJKLMNPQRSUVW]$/.test(first)) return false;

  let oddSum = 0, evenSum = 0;
  for (let i = 1; i <= 7; i++) {
    const d = parseInt(cif[i], 10);
    if (isNaN(d)) return false;
    if (i % 2 === 1) {
      const doubled = d * 2;
      oddSum += Math.floor(doubled / 10) + (doubled % 10);
    } else {
      evenSum += d;
    }
  }

  const controlDigit = (10 - ((oddSum + evenSum) % 10)) % 10;
  const control = cif[8].toUpperCase();

  if (LETTER_ONLY_FIRST.has(first))
    return control === CIF_CONTROL_LETTERS[controlDigit];

  return control === CIF_CONTROL_LETTERS[controlDigit]
    || parseInt(control, 10) === controlDigit;
}
# Python
CIF_CONTROL_LETTERS = "JABCDEFGHI"
LETTER_ONLY_FIRST   = set("PQRSWKLMN")

def is_valid_cif(cif: str) -> bool:
    if len(cif) != 9:
        return False
    first = cif[0].upper()
    if first not in set("ABCDEFGHJKLMNPQRSUVW"):
        return False

    odd_sum = even_sum = 0
    for i, ch in enumerate(cif[1:8], start=1):
        if not ch.isdigit():
            return False
        d = int(ch)
        if i % 2 == 1:  # odd position
            doubled = d * 2
            odd_sum += (doubled // 10) + (doubled % 10)
        else:
            even_sum += d

    control_digit = (10 - ((odd_sum + even_sum) % 10)) % 10
    control = cif[8].upper()

    if first in LETTER_ONLY_FIRST:
        return control == CIF_CONTROL_LETTERS[control_digit]

    return (control == CIF_CONTROL_LETTERS[control_digit]
            or (control.isdigit() and int(control) == control_digit))

How we tested

Correctness here isn't just about the algorithm — it's about implementation bugs that only surface on specific digit combinations. We used two classes of test vectors:

Known-good identifiers from public registries

Spanish company CIFs are public record (Registro Mercantil). We used identifiers from several well-known companies as integration tests — if the algorithm rejects a publicly registered CIF, something is definitely wrong. The specific identifiers are in the test suite in the repo.

Algorithmic edge cases

Beyond the happy path, we specifically tested:

The near-miss tests are the most important. A validator that never rejects anything is worse than no validator at all.


Why multi-language, and why open source

The honest answer to "why three languages" is that Kreyo needed it in three languages. C# is the API runtime. TypeScript is the SDK. Python appears in every docs example because it's the most readable for readers who don't know which language they'll actually use.

Once you have three implementations driven by the same test vectors, the package structure becomes obvious: monorepo with one directory per language, shared test fixtures, one README that links to all three. Keeping them in sync is easier than it sounds — the algorithm doesn't change, and any implementation drift shows up immediately in the shared test cases.

We open-sourced it for the same reason we'd want someone else to open-source it: NIF validation is not a competitive advantage. Every fiscal software company in Spain has implemented this. Most of them have subtle bugs in their CIF validator. If we publish a correct implementation under MIT, we save developers the two days of debugging and AEAT rejections that come from rolling their own from the ambiguous AEAT documentation. The goodwill is worth more than whatever moat a correct checksum provides.

The validators in the repo are the same ones that run in Kreyo's fiscal API in production.