# Mapping VIN Model Year Codes in TypeScript Without Hard-Coding One Decade

Position 10 of a modern 17-character VIN encodes the model year. It looks like a simple lookup table until you ship a decoder that only knows 2010 through 2019. Then a 1995 pickup, a 2031 prototype VIN in a test fixture, or a classic with an older cycle letter all return the wrong year with quiet confidence.

This post builds a full-cycle year-code map in TypeScript, explains the 30-year reuse rule, and shows a `preferRecent` helper that picks a default without pretending ambiguity never exists.

## Model year is not build calendar year

Before the table: position 10 is a **model year** code. Manufacturers designate a model year for sales and compliance. A vehicle assembled in November 2024 can still carry a 2025 model year. Door-jamb labels, invoices, and registration may disagree with a naive "year = calendar year of assembly" assumption.

Product copy should say "model year" (or "decoded model year") and, when you have plant or build data from elsewhere, keep those fields separate. Confusing the two is a common buyer complaint and a common support ticket for VIN tools.

## The 30-year cycle in plain terms

NHTSA / ISO-aligned practice reuses a fixed set of year characters on a roughly 30-year cycle. Digits and letters take turns. Characters that look like digits or each other are skipped so humans and OCR make fewer mistakes: **I, O, Q, U, Z** are not used as year codes in the standard cycle tables most North American tools implement.

That means:

- The same character can map to more than one calendar model year (for example `A` for 1980 and 2010).
- A map from code to a single `number` is wrong by design. You want code to `number[]`, then a policy for which candidate to show.
- Hard-coding only one decade freezes your tool in that decade. When the cycle rolls, silent mislabels appear.

If you only support "recent consumer cars," document that scope. If you claim classic and future coverage, implement the multi-year lists.

## A fuller year-code table sketch

Below is a practical sketch covering common cycles used in teaching and many open decoders. Treat it as a starting point you verify against your target years and against NHTSA vPIC for production. Gaps and edge markets exist; the structure (arrays per code) is the important part.

```ts
/** Position-10 code -> candidate model years (ascending). */
export const YEAR_CODES: Record<string, number[]> = {
  A: [1980, 2010],
  B: [1981, 2011],
  C: [1982, 2012],
  D: [1983, 2013],
  E: [1984, 2014],
  F: [1985, 2015],
  G: [1986, 2016],
  H: [1987, 2017],
  J: [1988, 2018],
  K: [1989, 2019],
  L: [1990, 2020],
  M: [1991, 2021],
  N: [1992, 2022],
  P: [1993, 2023],
  R: [1994, 2024],
  S: [1995, 2025],
  T: [1996, 2026],
  V: [1997, 2027],
  W: [1998, 2028],
  X: [1999, 2029],
  Y: [2000, 2030],
  "1": [2001, 2031],
  "2": [2002, 2032],
  "3": [2003, 2033],
  "4": [2004, 2034],
  "5": [2005, 2035],
  "6": [2006, 2036],
  "7": [2007, 2037],
  "8": [2008, 2038],
  "9": [2009, 2039],
};
```

Notes on the sketch:

1. Letters skip I, O, Q, U, Z.
2. Digits 1-9 appear for the 2001+ / 2031+ band in this layout.
3. Each code holds **all** candidates you care about, sorted ascending, so callers can prefer recent or oldest without another map.
4. When a third cycle becomes relevant for your product, push another year onto the array. You do not rewrite call sites.

Do not invent codes for I, O, or Q "for convenience." Reject them the same way you reject them in the VIN charset.

## Reading position 10 safely

Normalize first, then index.

```ts
const VIN_RE = /^[A-HJ-NPR-Z0-9]{17}$/;

export function yearCodeFromVin(raw: string): string | null {
  const vin = raw.trim().toUpperCase();
  if (!VIN_RE.test(vin)) return null;
  return vin[9]; // 0-based index: position 10
}
```

Position 10 is index 9. Off-by-one bugs here are embarrassing and common. Unit-test with a known VIN and assert the character before you assert the year.

## preferRecent: a default policy, not a fact

When a code maps to two years, consumer UIs usually want the recent one. Fleet and classic tools may want the opposite, or they may want both listed. Encode that as an explicit flag.

```ts
export type YearPick = {
  code: string;
  candidates: number[];
  chosen: number | null;
  ambiguous: boolean;
};

export function modelYearForCode(
  code: string,
  options: { preferRecent?: boolean; asOfYear?: number } = {}
): YearPick {
  const preferRecent = options.preferRecent ?? true;
  const asOf = options.asOfYear ?? new Date().getFullYear();
  const key = code.trim().toUpperCase();
  const candidates = YEAR_CODES[key] ?? [];

  if (!candidates.length) {
    return { code: key, candidates, chosen: null, ambiguous: false };
  }

  // Optional: drop future years beyond a small horizon if you only sell used cars.
  const usable = candidates.filter((y) => y <= asOf + 1);
  const pool = usable.length ? usable : candidates;

  const chosen = preferRecent ? pool[pool.length - 1]! : pool[0]!;
  return {
    code: key,
    candidates: [...candidates],
    chosen,
    ambiguous: candidates.length > 1,
  };
}
```

Why return `ambiguous` and `candidates` instead of only `chosen`:

- You can show "2025 (also used for 1995)" in advanced mode.
- Logs and support can see that the UI applied a policy.
- Downstream heuristics (WMI era, make history, plant closure dates) can override `chosen` without re-parsing the VIN.

A thin wrapper for the common case:

```ts
export function decodeModelYear(
  vin: string,
  preferRecent = true
): number | null {
  const code = yearCodeFromVin(vin);
  if (!code) return null;
  return modelYearForCode(code, { preferRecent }).chosen;
}
```

## When preferRecent is wrong

Defaulting to the newest cycle is fine for marketplace listings of daily drivers. It is wrong when:

- The WMI and make only existed in the older cycle (or only in the newer one).
- The rest of the decode (body, engine, restraint system fields from vPIC) only matches one era.
- The user pasted a VIN from a documented classic and your UI quietly says 2025.

A better product pattern:

1. Compute candidates from position 10.
2. Ask NHTSA vPIC (or your cache) for `ModelYear`.
3. If vPIC and your local map disagree, trust vPIC for display and log the mismatch for table maintenance.
4. If vPIC is unavailable, show `chosen` plus a short note when `ambiguous` is true.

Local maps are for offline validation, demos, and fast UX. They are not a substitute for the authoritative decode when you have network access.

## Tests worth writing

Keep these small and boring:

```ts
// code -> candidates
assert.deepEqual(YEAR_CODES["S"], [1995, 2025]);
assert.deepEqual(YEAR_CODES["1"], [2001, 2031]);

// preferRecent
assert.equal(modelYearForCode("S", { preferRecent: true }).chosen, 2025);
assert.equal(modelYearForCode("S", { preferRecent: false }).chosen, 1995);

// illegal year glyphs never appear as keys you accept from VIN charset helpers
assert.equal(yearCodeFromVin("1HGCM82633A004352")?.length, 1);

// ambiguity flag
assert.equal(modelYearForCode("A").ambiguous, true);
```

Add one fixture VIN per decade you claim to support. Snapshot the character at index 9 and the chosen year under both `preferRecent` values.

## Pitfalls checklist

1. **Single-number maps.** `Record<string, number>` cannot represent the cycle.
2. **Hard-coded decade.** "We only care about 2015-2024" becomes tech debt the week someone pastes a 1998 VIN.
3. **Accepting I/O/Q.** They are not year codes; reject at charset validation.
4. **Calling it "build year" in the UI.** Say model year.
5. **Ignoring vPIC.** Local tables drift; cross-check when online.
6. **Hiding ambiguity.** If you pick a default, keep the candidate list in the API response for power users and auditors.

## How this fits a free decode product

A free NHTSA-backed tool should show model year clearly, avoid overclaiming calendar build date, and stay honest when position 10 alone is ambiguous. The map above is a teaching and offline aid; production displays should still lean on the decode API for the year string users see.

## Disclosure

I maintain [VIN Lookup](https://free-vin-lookup.com/), a free VIN decode based on NHTSA data. Use the year-code patterns above in your own TypeScript; use a full decode and, when money is involved, a proper history report and inspection.

*Originally published on [DEV](https://dev.to/vin_lookup_8dbd4710f77e9e/mapping-vin-model-year-codes-in-typescript-without-hard-coding-one-decade-30jc).*

