Skip to Content
Course content

208: Practice Exercise: Building a JSON Schema to Type Mapper

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably run into this before: your application is driven by a configuration file or a JSON schema coming from a database, and you find yourself wishing TypeScript could just "see" the structure of that schema and give you autocomplete for the data it describes. It's a common gap. The schema says the data is a string, but as far as TypeScript is concerned, that data is just some opaque object.

The temptation of manual casting and any

When I first tackled this, I took the path of least resistance. I wrote a function that validated the JSON schema at runtime and then just returned the result as any or used a type assertion like as UserProfile. It felt fast. In the short term, it works because the code runs, and you've told the compiler to stop complaining.

// The "I'm in a hurry" approach
function parseSchema(schema: any, data: any) {
  // Imagine some runtime validation logic here
  return data as any; 
}

const user = parseSchema(userSchema, rawData);
console.log(user.name.toUpperCase()); // No error here, but no autocomplete either.

The problem is that you've effectively turned off TypeScript. If the schema changes—say, name becomes an object with first and last—your type assertion remains any or stays stuck as the old UserProfile. You've traded a few minutes of setup for a lifetime of runtime TypeError: cannot read property 'toUpperCase' of undefined. You're essentially lying to your future self.

Leveraging recursive conditional types

The better way—and the way we'll implement today—is to treat the schema itself as a type. By using recursive conditional types, we can tell TypeScript: "Look at the type property of this schema. If it's 'string', the resulting value is a string. If it's 'object', look at the properties and repeat this logic for every key."

It looks a bit more intimidating at first, but it's incredibly powerful. We aren't casting; we're mapping.

type JsonSchema = 
  | { type: 'string' }
  | { type: 'number' }
  | { type: 'boolean' }
  | { type: 'object'; properties: Record<string, JsonSchema> };

type SchemaToType<T> = T extends { type: 'string' } 
  ? string 
  : T extends { type: 'number' } 
  ? number 
  : T extends { type: 'boolean' } 
  ? boolean 
  : T extends { type: 'object'; properties: infer P } 
    ? { [K in keyof P]: SchemaToType<P[K]> } 
    : never;

// Now we define the schema as a 'const' so TS preserves the literal values
const userSchema = {
  type: 'object',
  properties: {
    username: { type: 'string' },
    age: { type: 'number' },
    settings: {
      type: 'object',
      properties: {
        notifications: { type: 'boolean' }
      }
    }
  }
} as const;

type User = SchemaToType<typeof userSchema>;
// User is now: { username: string; age: number; settings: { notifications: boolean; } }

Notice the as const on the schema definition. This is the secret sauce. Without it, TypeScript sees type: 'string' as just a general string, and our conditional types won't be able to narrow it down to the specific literal 'string'.

The cost of type-level recursion

Is this approach always right? Not necessarily. You're trading simplicity for strictness. The SchemaToType utility is significantly harder for a junior dev to read than a simple type assertion. Also, if you have a truly massive, deeply nested schema, you can occasionally hit TypeScript's recursion limit, though that's rare for most real-world configs.

But in my experience, the trade-off is almost always worth it. When you change a field in your schema and suddenly five different files in your IDE turn red with errors, that's not a nuisance—that's the type system doing exactly what you hired it to do. It's preventing a production bug before you've even hit 'Save'.




📋 Practical Task

Exercise: Implementing a Recursive Schema Mapper for Product Catalogs

You are building a product management system where the structure of a "Product" is defined by a JSON schema. Your goal is to implement the type mapping logic so that the product data is fully typed based on the schema definition.

Requirements:

  • Define a JsonSchema type that supports 'string', 'number', and 'object' types.
  • Implement a SchemaToType<T> conditional type that recursively maps these schema definitions to actual TypeScript types.
  • Create a productSchema object (using as const) that describes a product with:
    • title (string)
    • price (number)
    • metadata (an object containing sku as a string and weight as a number)
  • Create a type Product using your mapper and a variable myProduct of that type to verify that the nested metadata properties are correctly typed.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.