Type Assertions Don't Validate JSON. Tests Do.
The ops-audit system stores each audit as a JSON file in content/audits/. The loader is ten lines:
export function getAudit(slug: string): Audit {
const filePath = path.join(AUDITS_DIR, `${slug}.json`);
const raw = fs.readFileSync(filePath, "utf-8");
return JSON.parse(raw) as Audit;
}
That as Audit at the end looks like validation. It isn't. It's a type assertion: a directive that tells the TypeScript compiler to treat the return value as an Audit without checking whether it actually is one. The compiler accepts it. At runtime, JSON.parse returns unknown, and the assertion trusts you that the shape is correct.
The Audit type has this field:
hoursPerWeek: [number, number];
A tuple. Both ends of the range are required. If someone writes a new audit JSON and types "hoursPerWeek": [3] instead of "hoursPerWeek": [3, 5], TypeScript will not catch it. The file parses. The loader returns. The page renders. The dollar range in the audit card breaks, or shows undefined, or crashes in a format function that expects two values. You find out when you open the deliverable and something looks wrong.
The Test That Actually Checks It
it("loads an audit by slug", () => {
const audit = getAudit("sample-business-x0x0");
expect(audit.business).toBe("Sample Business Co.");
expect(audit.workflows.length).toBeGreaterThan(0);
expect(audit.workflows[0].hoursPerWeek).toHaveLength(2);
});
toHaveLength(2) is doing the runtime check the type system can't. If the sample fixture ever gets a malformed hoursPerWeek, this test fails. That failure catches the problem in CI, before the build ships, before any client opens the URL.
The test also validates workflows.length > 0. The TypeScript type says workflows: AuditWorkflow[] — an empty array is valid by that definition. An audit with no workflows would compile and render a technically correct page that contains no useful content. The length check is the guard.
Why Not Just Use Zod?
We could parse the JSON through a Zod schema and get runtime validation on every load. That's the right call for API payloads from external services, where you don't control the data and malformed input is a real operational risk.
For content files we author and commit, the test on the sample fixture covers the same ground at lower cost. If the schema changes and an audit file falls out of sync, either the TypeScript types catch it on the next audit that references the updated interface, or the vitest suite catches it through the fixture. Both paths run in CI.
The rule we've landed on: use Zod for data you receive from outside the codebase. Use tests to validate the shape of data you write yourself. The fixture test is a cheap, fast assertion that the loader and the content format are in sync.
The Loader Pattern
Content loaders for file-based systems (MDX, JSON, YAML) tend to follow the same shape: read file, parse, cast, return. The cast is always a trust statement. For any field where a malformed value would silently corrupt the output — a range that requires exactly two elements, a required string that could be empty, a date that needs a specific format — a test on a representative fixture is the actual protection.
TypeScript tells you the shape you intend. The test tells you the shape you actually have.