DEV Community

Vin Lookup
Vin Lookup

Posted on

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 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
}
Enter fullscreen mode Exit fullscreen mode

Problems pile up fast:

  • New upstream keys are dropped by naive mappers that only copy known names
  • Required string lies about empty and null-ish vPIC cells
  • A renamed field breaks production while CI still thinks the old name is fine
  • Exhaustive switch on 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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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: 1 on API responses and cache entries
  • Bump when core gains 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 core only
  • Log rawAttributeCount and 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:

  1. core mapping still passes
  2. FutureSafetyField appears in extensions
  3. 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.

Top comments (0)