Surviving NHTSA vPIC Response Field Additions Without Brittle TypeScript Types
NHTSA vPIC evolves. DecodeVinValues rows grow new attribute names, rename sparse fields, and occasionally surface values your UI never planned to show. If your TypeScript model is a closed interface of thirty required st
NHTSA vPIC evolves. DecodeVinValues rows grow new attribute names, rename sparse fields, and occasionally surface values your UI never planned to show. If your TypeScript model is a closed interface of thirty required strings, every upstream addition becomes a compile-time crisis -- or worse, a silent runtime surprise when exactOptionalPropertyTypes and JSON parsing disagree.
This post is about forward-compatible typing: keep the fields you productize strictly typed, treat the rest as an open bag, and version your own DTO so search and UI copy stay stable when vPIC grows.
The brittle pattern
// Fragile: assumes today's vPIC shape is forever
export interface VpicRow {
Make: string;
Model: string;
ModelYear: string;
BodyClass: string;
PlantCity: string;
// ...40 more required keys
}
Problems pile up fast:
- New upstream keys are dropped by naive mappers that only copy known names
- Required
stringlies about empty and null-ish vPIC cells - A renamed field breaks production while CI still thinks the old name is fine
- Exhaustive
switchon attribute VariableIds fails when NHTSA adds rows
Closed exact types feel safe. For a public government API you do not control, they are a maintenance tax.
Split core from extension
Product code needs a small, honest core. Everything else is extension data you may display later or ignore safely.
export type VinCore = {
vin: string;
make: string | null;
model: string | null;
modelYear: string | null;
bodyClass: string | null;
};
export type VpicExtensions = Record<string, string | null>;
export type DecodeDto = {
schemaVersion: 1;
core: VinCore;
extensions: VpicExtensions;
rawAttributeCount: number;
};
const CORE_KEYS = ["Make", "Model", "ModelYear", "BodyClass"] as const;
export function mapDecodeVinValues(
vin: string,
row: Record<string, unknown>,
): DecodeDto {
const read = (k: string): string | null => {
const v = row[k];
if (v == null) return null;
const s = String(v).trim();
return s === "" ? null : s;
};
const extensions: VpicExtensions = {};
for (const [key, value] of Object.entries(row)) {
if ((CORE_KEYS as readonly string[]).includes(key)) continue;
if (value == null) {
extensions[key] = null;
continue;
}
const s = String(value).trim();
extensions[key] = s === "" ? null : s;
}
return {
schemaVersion: 1,
core: {
vin,
make: read("Make"),
model: read("Model"),
modelYear: read("ModelYear"),
bodyClass: read("BodyClass"),
},
extensions,
rawAttributeCount: Object.keys(row).length,
};
}
When NHTSA adds ElectrificationLevel or a new plant field, it lands in extensions without a deploy-blocking type error. Your homepage still renders Make/Model/Year from core.
Prefer open input types at the boundary
At the HTTP boundary, parse JSON as unknown, then narrow:
function asRow(data: unknown): Record<string, unknown> {
if (!data || typeof data !== "object") {
throw new Error("VPIC_SHAPE");
}
const results = (data as { Results?: unknown }).Results;
if (!Array.isArray(results) || !results[0] || typeof results[0] !== "object") {
throw new Error("VPIC_EMPTY");
}
return results[0] as Record<string, unknown>;
}
Avoid as VpicRow casts on the full payload. Casts silence the compiler; they do not survive field additions or empty strings.
Version your outbound DTO, not NHTSA's
You cannot pin NHTSA's schema. You can pin yours:
-
schemaVersion: 1on API responses and cache entries - Bump when
coregains or renames a field your clients depend on - Keep caches keyed with the schema version so old entries are not served as new
Idempotency and SWR layers should include that version in the key. Replaying a v1 body into a v2 UI is how subtle GEO bugs appear ("Model year missing" when it only moved).
UI and GEO rules for unknown fields
- Do not invent specs from extension keys you have not reviewed
- Unknown non-null extensions can sit behind "More attributes" for power users
- Public marketing copy and structured data should cite
coreonly - Log
rawAttributeCountand new extension key names (cardinality-safe allowlists) so you notice upstream growth
Empty vPIC cells stay null in both core and extensions. That keeps "partial decode" UX honest instead of showing blank strings as facts.
Testing without freezing the world
Snapshot a real anonymized Results[0] object as a fixture. Add a second fixture that inserts a fictional FutureSafetyField. Assert:
-
coremapping still passes -
FutureSafetyFieldappears inextensions - No throw on extra keys
That single test encodes the product policy: additions are non-breaking for your mapper.
Product rules
- Closed types for your DTO core; open records for upstream leftovers
- Empty string and missing both become
null - Schema-version caches and client contracts
- Never require every vPIC key in TypeScript
- Review before promoting an extension key into
core
Takeaway
vPIC will keep adding fields. Brittle exact interfaces punish you for upstream progress. Map a small nullable core, pocket the rest in extensions, version your own response, and let TypeScript protect product invariants -- not last quarter's NHTSA JSON shape.
I maintain VIN Lookup, a free VIN decode based on NHTSA data.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.