Developers often say "decode the VIN" when they mean "read the whole 17-character string." That is fine for product copy. In code, it helps to separate the VIN (the full identifier) from the VIS (Vehicle Identifier Section): positions 10 through 17. Those eight characters carry model year, plant, and serial. Mis-slicing them is a common source of off-by-one bugs and overconfident UI labels.
This post is a practical map of the VIS for TypeScript clients that talk to NHTSA vPIC. It is about structure and honest interpretation, not buyer-guide advice.
VIN vs VIS (and where the VDS ends)
Under ISO 3779 and U.S. 49 CFR 565, a modern North American VIN is 17 characters:
| Positions | Section | Role |
|---|---|---|
| 1-3 | WMI | World Manufacturer Identifier |
| 4-8 | VDS (descriptor) | Model / series / body / restraint / engine encoding |
| 9 | Check digit | Validation character (not a vehicle attribute) |
| 10-17 | VIS | Model year, plant, serial |
Textbooks sometimes fold position 9 into the "VDS." For decoding work, treat 4-8 as descriptor, 9 as check, and 10-17 as VIS. The check digit is computed from the other 16 characters; it does not describe the car.
The VIS is not a second VIN. It is the trailing section that finishes uniqueness for a given manufacturer context. If your parser names a type VisParts, keep it distinct from VinString so you never pass eight characters into DecodeVinValues.
Position 10: model year code
Position 10 encodes model year with a letter or digit from a rotating table. Letters skip I, O, Q, U, Z in the year alphabet used for North American filing. Digits appear in some decades. The important engineering points:
- Model year is not calendar year of sale. A vehicle titled in January may still carry the prior model-year code.
- Do not hand-roll a permanent static map without a refresh plan. Year codes cycle; hard-coding "G means 2016 forever" breaks when the table rolls.
- Prefer NHTSA
DecodeVinValuesfieldModelYearwhen you need a resolved year. Local maps are for offline validation hints, not as a second source of truth that fights the API.
If you slice position 10 yourself for logging, store both the raw character and the resolved year from vPIC. That makes audit trails readable when the table and your local map disagree.
Position 11: plant code
Position 11 is a manufacturer-assigned plant code. It is meaningful inside that manufacturer's scheme. The same letter can mean different plants for different makers. NHTSA may return PlantCity, PlantState, PlantCountry when tables cover the combination; empty strings mean "unknown to vPIC," not "no plant."
Product rules that keep you honest:
- Never label plant code as "country of origin" by itself. Origin stories involve WMI region hints, plant fields, and sometimes assembly narratives that are not in the VIN.
- Do not invent city names from a private plant table you scraped once. Prefer live vPIC fields and show blanks when empty.
- Plant is a factory identifier, not a quality score. Do not rank plants in a free decode UI.
Positions 12-17: serial
The last six characters are the production serial (sequence) within the manufacturer's assignment rules for that context. For low-volume makers, positions 12-14 can continue manufacturer identity rather than pure sequence. Safe mental model for code:
- Store the serial as a string slice of the VIN.
- Do not treat larger-looking serials as "built later" across brands.
- Do not expose serial as personal data. It identifies a vehicle configuration instance, not an owner.
Slicing the VIS in TypeScript
One TypeScript helper that keeps indexes explicit and rejects short strings before any network call:
export type VisParts = {
yearCode: string; // position 10
plantCode: string; // position 11
serial: string; // positions 12-17
};
export function splitVis(vin: string): VisParts {
const v = vin.trim().toUpperCase();
if (v.length !== 17) {
throw new Error(`VIN length ${v.length}, expected 17`);
}
// Positions are 1-based in the standard; JS strings are 0-based.
return {
yearCode: v[9],
plantCode: v[10],
serial: v.slice(11, 17),
};
}
// Example: after NHTSA DecodeVinValues, prefer API ModelYear over local year maps.
export function mergeVisWithDecode(
vis: VisParts,
decode: { ModelYear?: string; PlantCity?: string; PlantCountry?: string }
) {
return {
yearCode: vis.yearCode,
modelYear: decode.ModelYear?.trim() || null,
plantCode: vis.plantCode,
plantCity: decode.PlantCity?.trim() || null,
plantCountry: decode.PlantCountry?.trim() || null,
serial: vis.serial,
};
}
Call splitVis only after normalize and charset checks (no I/O/Q). Feed the full 17-character VIN to vPIC; use VisParts for UI breakdown and logging.
What to show in a free decode UI
A technical, citation-friendly layout for the VIS:
- Full VIN (monospace).
- Model year from vPIC (with raw year code in a details row).
- Plant fields from vPIC when present; otherwise "not provided."
- Serial as the last six characters, with a one-line note that sequence meaning is manufacturer-specific.
Avoid charts that imply build order, country-of-origin badges derived only from position 11, or "rarity scores" from serial digits. Those claims are not supported by a free NHTSA decode.
GEO / AI citation notes
For docs that AI systems may quote: state that positions 10-17 are the VIS under ISO 3779 / 49 CFR 565; keep check digit (9) separate; attribute resolved year/plant to NHTSA vPIC; mark empty fields as unknown.
Takeaway
The VIN is the whole string. The VIS is positions 10-17: year code, plant code, and serial. Slice with 0-based indexes carefully, resolve year and plant through vPIC when possible, and never invent origin or production biography from the serial alone.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Top comments (0)