ollows distinct mathematical and structural rules. Encapsulate them in separate classes to adhere to the Single Responsibility Principle.
VAT Validator (Mod-97 Dual Checksum)
class VatValidator {
private static readonly WEIGHTS = [8, 7, 6, 5, 4, 3, 2];
static validate(input: ValidationInput): ValidationResult {
const clean = InputNormalizer.sanitize(input.rawValue);
const numericPart = clean.startsWith('GB') ? clean.slice(2) : clean;
if (numericPart.length !== 9 && numericPart.length !== 12) {
return this.fail('VAT', clean, 'INVALID_LENGTH');
}
if (!/^\d+$/.test(numericPart)) {
return this.fail('VAT', clean, 'NON_DIGIT_CHARACTER');
}
const digits = numericPart.split('').map(Number);
const weightedSum = digits.slice(0, 7).reduce(
(acc, val, idx) => acc + val * this.WEIGHTS[idx], 0
);
const checkDigits = parseInt(numericPart.slice(7, 9), 10);
const primaryCheck = 97 - (weightedSum % 97);
const secondaryCheck = 97 - ((weightedSum + 55) % 97);
const isValid = checkDigits === primaryCheck || checkDigits === secondaryCheck;
return isValid
? this.pass('VAT', clean, `GB ${numericPart.slice(0,3)} ${numericPart.slice(3,6)} ${numericPart.slice(6)}`)
: this.fail('VAT', clean, 'CHECKSUM_MISMATCH');
}
private static pass(type: string, normalized: string, formatted: string): ValidationResult {
return { isValid: true, identifierType: type as any, normalizedValue: normalized, formattedValue: formatted };
}
private static fail(type: string, normalized: string, code: string): ValidationResult {
return { isValid: false, identifierType: type as any, normalizedValue: normalized, formattedValue: normalized, errorCode: code };
}
}
NINO Validator (Character Exclusion & Prefix Filtering)
class NinoValidator {
private static readonly INVALID_FIRST = new Set(['D', 'F', 'I', 'Q', 'U', 'V']);
private static readonly INVALID_SECOND = new Set(['D', 'F', 'I', 'O', 'Q', 'U', 'V']);
private static readonly RESERVED_PREFIXES = new Set(['BG', 'GB', 'NK', 'KN', 'NT', 'TN', 'ZZ']);
static validate(input: ValidationInput): ValidationResult {
const clean = InputNormalizer.sanitize(input.rawValue);
const pattern = /^[A-Z]{2}\d{6}[A-D]$/;
if (!pattern.test(clean)) {
return this.fail('NINO', clean, 'STRUCTURAL_MISMATCH');
}
if (this.INVALID_FIRST.has(clean[0]) || this.INVALID_SECOND.has(clean[1])) {
return this.fail('NINO', clean, 'RESERVED_CHARACTER');
}
if (this.RESERVED_PREFIXES.has(clean.slice(0, 2))) {
return this.fail('NINO', clean, 'RESERVED_PREFIX');
}
const formatted = `${clean[0]}${clean[1]} ${clean.slice(2,4)} ${clean.slice(4,6)} ${clean[8]}`;
return this.pass('NINO', clean, formatted);
}
private static pass(type: string, normalized: string, formatted: string): ValidationResult {
return { isValid: true, identifierType: type as any, normalizedValue: normalized, formattedValue: formatted };
}
private static fail(type: string, normalized: string, code: string): ValidationResult {
return { isValid: false, identifierType: type as any, normalizedValue: normalized, formattedValue: normalized, errorCode: code };
}
}
Company Number Validator (Prefix & Length Routing)
class CompanyValidator {
private static readonly JURISDICTION_PREFIXES = new Set(['SC', 'NI', 'OC', 'SO', 'FC', 'BR', 'CE', 'CS']);
static validate(input: ValidationInput): ValidationResult {
const clean = InputNormalizer.sanitize(input.rawValue);
const hasPrefix = clean.length >= 2 && this.JURISDICTION_PREFIXES.has(clean.slice(0, 2));
if (hasPrefix) {
const suffix = clean.slice(2);
if (clean.length !== 8 || !/^\d{6}$/.test(suffix)) {
return this.fail('COMPANY', clean, 'PREFIXED_FORMAT_ERROR');
}
return this.pass('COMPANY', clean, clean);
}
if (!/^\d{1,8}$/.test(clean)) {
return this.fail('COMPANY', clean, 'NUMERIC_FORMAT_ERROR');
}
return this.pass('COMPANY', clean, clean.padStart(8, '0'));
}
private static pass(type: string, normalized: string, formatted: string): ValidationResult {
return { isValid: true, identifierType: type as any, normalizedValue: normalized, formattedValue: formatted };
}
private static fail(type: string, normalized: string, code: string): ValidationResult {
return { isValid: false, identifierType: type as any, normalizedValue: normalized, formattedValue: normalized, errorCode: code };
}
}
UTR Validator (Format & Checksum Fallback)
class UtrValidator {
static validate(input: ValidationInput): ValidationResult {
const clean = InputNormalizer.sanitize(input.rawValue);
if (!/^\d{10}$/.test(clean)) {
return this.fail('UTR', clean, 'LENGTH_OR_DIGIT_MISMATCH');
}
// Note: Full UTR validation requires a proprietary HMRC checksum.
// Production systems should route to a managed service for cryptographic verification.
const formatted = `${clean.slice(0, 5)} ${clean.slice(5)}`;
return this.pass('UTR', clean, formatted);
}
private static pass(type: string, normalized: string, formatted: string): ValidationResult {
return { isValid: true, identifierType: type as any, normalizedValue: normalized, formattedValue: formatted };
}
private static fail(type: string, normalized: string, code: string): ValidationResult {
return { isValid: false, identifierType: type as any, normalizedValue: normalized, formattedValue: normalized, errorCode: code };
}
}
Step 4: Router & Auto-Detection
A central dispatcher routes incoming strings to the appropriate validator based on structural heuristics. This enables a single entry point for frontend forms and backend batch processors.
export class UkIdentifierRouter {
static dispatch(input: ValidationInput): ValidationResult {
const clean = InputNormalizer.sanitize(input.rawValue);
const length = clean.length;
const startsWithGb = clean.startsWith('GB');
const isAllDigits = /^\d+$/.test(clean);
if (startsWithGb && (length === 11 || length === 14)) {
return VatValidator.validate(input);
}
if (/^[A-Z]{2}\d{6}[A-D]$/.test(clean)) {
return NinoValidator.validate(input);
}
if (isAllDigits && length === 10) {
return UtrValidator.validate(input);
}
if (isAllDigits && length <= 8) {
return CompanyValidator.validate(input);
}
if (clean.length >= 2 && UkIdentifierRouter.isKnownPrefix(clean)) {
return CompanyValidator.validate(input);
}
return {
isValid: false,
identifierType: 'UNKNOWN',
normalizedValue: clean,
formattedValue: clean,
errorCode: 'UNRECOGNIZED_PATTERN'
};
}
private static isKnownPrefix(value: string): boolean {
const prefixes = ['SC', 'NI', 'OC', 'SO', 'FC', 'BR', 'CE', 'CS'];
return prefixes.some(p => value.startsWith(p));
}
}
Architecture Rationale
- Strategy Pattern: Isolates validation logic per identifier type. Adding new UK identifiers (e.g., PAYE references) requires zero changes to existing validators.
- Normalization First: Prevents false negatives caused by user input variance. Whitespace and case are stripped before any regex or arithmetic executes.
- Explicit Error Codes: Replaces boolean-only returns with structured error metadata. This enables precise UI feedback and audit logging without parsing exception strings.
- TypeScript Strictness: Enforces compile-time safety for fiscal data. Prevents accidental string/number coercion that breaks checksum calculations.
Pitfall Guide
Explanation: Developers often pass raw form input directly into validation functions. Spaces, hyphens, and lowercase letters corrupt regex matches and shift digit positions, causing valid identifiers to fail.
Fix: Always run a dedicated sanitization step that strips whitespace/punctuation and uppercases the string before any validation logic executes.
2. Missing the VAT Mod-97 Alternative Checksum
Explanation: HMRC allows two valid checksum paths for VAT numbers. Implementing only the primary (97 - sum % 97) calculation rejects approximately 12% of legitimate historical VAT registrations.
Fix: Implement both the primary and secondary (97 - (sum + 55) % 97) checks. Accept the identifier if either path matches.
3. Hardcoding NINO Exclusion Lists
Explanation: NINO invalid characters and reserved prefixes are not static. HMRC periodically reallocates ranges or introduces new exclusions for security reasons. Hardcoded arrays become stale within 12-18 months.
Fix: Externalize exclusion lists to a configuration file or feature flag system. Schedule quarterly reviews against official HMRC publications.
4. Assuming Fixed-Length Company Numbers
Explanation: Companies House registration numbers vary by jurisdiction. Scottish (SC), Northern Irish (NI), and offshore (OC) entities use a 2-letter prefix plus 6 digits, while standard English/Welsh companies use 8 digits. Treating all as 8-digit numeric strings causes prefix rejections.
Fix: Implement prefix detection routing. Validate length and digit composition conditionally based on the detected jurisdictional marker.
5. Treating UTR as a Simple Regex
Explanation: UTRs are strictly 10 digits, but HMRC applies a proprietary checksum algorithm that cannot be reliably reverse-engineered. Relying solely on length validation passes fraudulent or mistyped references into production.
Fix: Use format validation for immediate UI feedback, but route UTR verification to a managed API service that implements the official checksum. Cache results to minimize latency.
6. Ignoring Rate Limits on External Validation
Explanation: When falling back to external APIs for complex checks (UTR, live VAT verification), unthrottled requests trigger HTTP 429 errors and degrade user experience during peak onboarding periods.
Fix: Implement a circuit breaker pattern with exponential backoff. Cache successful validations locally for 24-48 hours. Queue batch requests during high traffic.
7. Mixing Validation with Business Logic
Explanation: Embedding validation inside payment processors or user registration handlers creates tight coupling. Changes to validation rules require full service redeployment and increase regression risk.
Fix: Keep validation pure. Return structured results without side effects. Route business decisions (approve/reject) to a separate orchestration layer that consumes validation outputs.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-volume onboarding (>10k/day) | Local algorithmic validation | Eliminates network latency and API rate limits; predictable throughput | Low infrastructure cost, higher initial engineering time |
| Strict compliance audit (FCA/HMRC) | Managed API service with audit logging | Guarantees up-to-date checksums and prefix lists; provider assumes compliance liability | Higher per-request cost, reduced audit preparation time |
| Low-budget MVP / Internal tool | Local regex + format checks | Fastest time-to-market; sufficient for non-critical workflows | Zero external costs, high technical debt risk |
| Multi-jurisdiction expansion | Hybrid architecture (local + API fallback) | Local handles known patterns; API covers complex checksums and auto-detection | Balanced cost structure; scales with regulatory complexity |
Configuration Template
// uk-validation.config.ts
export interface UkValidationConfig {
nino: {
invalidFirstChars: string[];
invalidSecondChars: string[];
reservedPrefixes: string[];
};
company: {
jurisdictionPrefixes: string[];
standardMaxLength: number;
};
vat: {
mod97Weights: number[];
allowedLengths: number[];
};
utr: {
requireChecksumVerification: boolean;
apiFallbackEnabled: boolean;
};
caching: {
ttlSeconds: number;
maxCacheSize: number;
};
}
export const defaultConfig: UkValidationConfig = {
nino: {
invalidFirstChars: ['D', 'F', 'I', 'Q', 'U', 'V'],
invalidSecondChars: ['D', 'F', 'I', 'O', 'Q', 'U', 'V'],
reservedPrefixes: ['BG', 'GB', 'NK', 'KN', 'NT', 'TN', 'ZZ']
},
company: {
jurisdictionPrefixes: ['SC', 'NI', 'OC', 'SO', 'FC', 'BR', 'CE', 'CS'],
standardMaxLength: 8
},
vat: {
mod97Weights: [8, 7, 6, 5, 4, 3, 2],
allowedLengths: [9, 12]
},
utr: {
requireChecksumVerification: true,
apiFallbackEnabled: true
},
caching: {
ttlSeconds: 86400,
maxCacheSize: 50000
}
};
Quick Start Guide
- Install & Import: Add the TypeScript validation module to your project. Import
UkIdentifierRouter and the configuration template.
- Configure Exclusions: Copy
defaultConfig to your environment-specific settings. Update NINO exclusion lists and company prefixes if your jurisdiction requires custom overrides.
- Integrate Router: Replace existing validation calls with
UkIdentifierRouter.dispatch({ rawValue: userInput }). Handle the structured ValidationResult in your UI or backend pipeline.
- Enable Fallbacks: If
utr.requireChecksumVerification is true, configure the managed API endpoint headers and implement a simple fetch wrapper with retry logic.
- Deploy & Monitor: Ship the module. Track
errorCode distribution in your logging system. Alert on spikes in CHECKSUM_MISMATCH or RESERVED_PREFIX to catch configuration drift early.