Skip to content
Consultation

TypeScript Types Never Reach Your JSON, So Write Contract Tests

Adityo Guni Waluyo

One missing badge image broke typecheck. A story about type erasure, optional properties, and parametrized contract tests guarding per-locale JSON content.

I had just added the new ISO 45001:2018 certification to frontend/src/data/en/site-data.json and left the image field out because the badge artwork wasn't ready yet. Then I ran npm run typecheck and the terminal threw error TS2741: Property 'image' is missing in type. That was commit 9785b5b on the egperkasa-website repo, but the story starts a bit before that.

We were merging the 2026 company-profile content. The branch added a welfare block (koperasi and CSR), swapped the ISO certification set (ISO 45001:2018 in, OHSAS 18001 out), opened a Serang branch office, and listed new services. Site data lives in per-locale JSON files, read through getSiteData(locale) and checked against TypeScript interfaces in src/types/site-data.ts. Everything looked fine until I introduced a cert with no badge.

My first guess was that getSiteData must be doing strict runtime validation. I thought maybe the loader used a schema library that rejected the missing image. I even dropped a console.log inside the function to see if it stripped keys. It didn't. The error came purely from tsc. The JSON loaded fine in the browser because JavaScript doesn't care about TypeScript interfaces.

That's when I remembered the obvious thing I had ignored: TypeScript types are erased at compile time. The handbook on interfaces shows how to mark optional props, and the five-minute guide for OOP programmers states plainly that types disappear after compilation. The official TS FAQ repeats it: no runtime checks exist for your interfaces. The missing image field didn't break the running site; it only broke the build step.

That one question mark is a schema decision

So I made image optional.

export interface Certification {
  name: string;
  image?: string;
}

export interface WelfareBlock {
  title: string;
  description: string;
  items: string[];
}

export interface WelfareData {
  koperasi: WelfareBlock;
  csr: WelfareBlock;
}

That one question mark in image?: string is a data-schema decision, not just a type annotation. We explicitly said: a certification can exist without a badge. That's a product call, not a TypeScript trick.

The commit also had to absorb the welfare block. I added WelfareBlock and WelfareData to AboutData. The Indonesian and English JSON both needed a welfare section with non-empty items for koperasi and CSR. Types helped me write the shape, but they couldn't tell me if I accidentally put 7 coreServices instead of 8, or forgot the Serang office in the en file. I nearly just cast the JSON to any to shut the compiler up. Bad idea. Instead I wrote contract tests.

Tests that run for every locale

I used vitest's it.each to parametrize one suite over both locale objects. The test loads id/site-data.json and en/site-data.json and asserts the real contents. Not the TypeScript type, the actual parsed JSON.

const locales: Locale[] = ["id", "en"];

it.each(locales)("Certification accepts missing image (%s)", (locale) => {
  const cert: Certification = { name: "ISO 45001:2018" };
  expect(cert.image).toBeUndefined();
});

it.each(locales)("welfare block present with non-empty items (%s)", (locale) => {
  const { about } = getSiteData(locale);
  expect(about.welfare.koperasi.items.length).toBeGreaterThan(0);
  expect(about.welfare.csr.items.length).toBeGreaterThan(0);
});

The assertions are concrete. Welfare block present with non-empty items. coreServices length exactly 8. A Serang office exists in the offices list. values array exactly 4, first being Amanah. Certifications include 45001 and exclude OHSAS. detailSections include PoC and SIAPAkses. And for the new cert, expect(cert.image).toBeUndefined() passes because the field is missing on purpose. Types disappeared. The tests stayed.

I've written before about how a green CI with zero failures can still ship bugs and about contract tests guarding code-block language ids. This is the same pattern for content data.

For human-edited locale files, I firmly prefer contract tests over runtime validators like Zod. A validator adds code to every page load and throws in production if an editor forgets a field. A contract test fails in CI, where a teammate fixes the JSON before merge. Runtime validation has its place for external input, but for internal files we control, the test is enough.

The commit closed with optional image, welfare types, and a small suite that guards both locales. Next time I add a certification without a badge, the build stays green and the test reminds me what the data must look like.

Related articles