DEV Community

Vin Lookup
Vin Lookup

Posted on

Normalize VIN Input Before Check Digits and NHTSA Calls

Users paste VINs from emails, PDFs, marketplace listings, and phone photos. What lands in your form is rarely a clean 17-character string. Spaces, hyphens, lowercase letters, and invisible separators are normal. If you feed that raw string into a check-digit routine or into NHTSA DecodeVinValues, you get confusing failures that look like product bugs.

Normalization is the first pipeline stage. It is not "smart autocorrect." It is a deterministic cleanup that either yields a candidate VIN or a clear reject reason before any network call.

What "normalize" should mean

For North American-style 17-character VINs, a practical normalize step does four things:

  1. Trim leading and trailing whitespace.
  2. Uppercase letters.
  3. Strip common separators (spaces, hyphens, dots, underscores) that appear when people copy from stickers or invoices.
  4. Reject illegal characters, especially I, O, and Q, which are not valid in modern VIN charset rules.

It should not:

  • Silently map O to 0 or I to 1. That hides real typos and can invent a different vehicle identity.
  • Pad or truncate to force length 17.
  • Guess missing characters from make/model context.

Those "helpful" transforms turn a data-entry problem into a wrong decode that looks authoritative.

Why order matters

Run normalize before check digit and before NHTSA. The check digit is defined on the normalized 17 characters. Calling vPIC with a spaced or hyphenated string wastes a request and often returns an empty or error-shaped row that your UI may mislabel as "unknown vehicle."

A good gate order:

  1. Normalize (trim, upper, strip separators).
  2. Length and charset validation (exactly 17; no I/O/Q; alphanumeric only after strip).
  3. Check digit.
  4. NHTSA / vPIC decode.

If step 2 fails, stop. Do not call the network.

A TypeScript normalize helper

export type NormalizeResult =
  | { ok: true; vin: string }
  | { ok: false; reason: "empty" | "bad_charset" | "bad_length"; raw: string };

const SEPARATORS = /[\s.\-_]/g;
const ILLEGAL = /[IOQ]/;
const VIN_CHAR = /^[A-HJ-NPR-Z0-9]+$/;

export function normalizeVin(input: string): NormalizeResult {
  const raw = input ?? "";
  let s = raw.trim().toUpperCase().replace(SEPARATORS, "");

  if (!s) return { ok: false, reason: "empty", raw };

  // Reject I/O/Q explicitly so UI copy can mention them.
  if (ILLEGAL.test(s) || !VIN_CHAR.test(s)) {
    return { ok: false, reason: "bad_charset", raw };
  }

  if (s.length !== 17) {
    return { ok: false, reason: "bad_length", raw };
  }

  return { ok: true, vin: s };
}
Enter fullscreen mode Exit fullscreen mode

Keep raw on failure so support can see what the user typed. Store and log the normalized vin on success so cache keys stay stable.

Separators people actually paste

Real inputs look like:

  • 1HGCM82633A004352 (clean)
  • 1hg cm826 33a004352 (spaces + lowercase)
  • 1HG-CM826-33A-004352 (hyphen groups)
  • 1HGCM82633A004352\n (trailing newline from mobile share sheets)
  • Zero-width spaces or non-breaking spaces from rich text (trim alone may not remove mid-string NBSP; include \s in your strip regex, or normalize Unicode spaces first)

If you support OCR from dash photos later, keep OCR correction as a separate stage with its own confidence score. Do not fold OCR guesses into normalizeVin.

UI copy for reject reasons

Map reasons to plain language:

  • empty: "Enter a VIN to decode."
  • bad_length: "A VIN should be 17 characters after removing spaces and dashes. Yours has N."
  • bad_charset: "VINs do not use the letters I, O, or Q. Re-check the plate or door sticker."

Show the character count on the cleaned string while the user types, not only on submit. That reduces "I typed 17" tickets caused by invisible spaces.

Check digit after normalize

Once you have a clean 17-character candidate, run the ISO / 49 CFR style check digit. Failures here are usually typos, not API outages. Surface "check digit failed" separately from "NHTSA timed out" so buyers know whether to retype the VIN or wait and retry.

// Pseudocode: wire your existing checkDigit(vin) after normalize.
function gateForDecode(raw: string) {
  const n = normalizeVin(raw);
  if (!n.ok) return n;
  if (!checkDigitOk(n.vin)) {
    return { ok: false as const, reason: "check_digit" as const, vin: n.vin };
  }
  return { ok: true as const, vin: n.vin };
}
Enter fullscreen mode Exit fullscreen mode

Caching and analytics

Use the normalized VIN as the cache key for DecodeVinValues responses. Otherwise 1HG... and 1hg ... become two cache entries and two upstream calls. For analytics, count:

  • normalize failures by reason
  • check-digit failures after a successful normalize
  • upstream calls (should equal successful gates, modulo batching)

That split tells you whether UX friction is paste quality or API health.

What not to normalize away

Do not strip position-significant characters. Do not reorder. Do not "fix" a 16-character string by inserting a check digit. Manufacturers and regulators define the string; your job is to clean noise around it, not rewrite identity.

Also remember: a normalized, check-digit-valid VIN can still decode to sparse NHTSA fields. That is a data coverage issue, not a normalize bug. Keep those concerns separate in your error model.

Takeaway

Trim, uppercase, strip separators, reject I/O/Q, then validate length and check digit, then call NHTSA. One shared normalizeVin function keeps forms, APIs, and batch jobs honest. Silent letter substitution is not a feature.

I maintain VIN Lookup, a free VIN decode based on NHTSA data.

Top comments (0)