Before You Begin

  • Node.js 18+ and TypeScript 5.0+ installed.
  • Familiarity with generics, conditional types, and the infer keyword (as taught in the previous tutorial on TypeScript Generics for a CLI music genre analysis tool).
  • A code editor with TypeScript support.

The Problem: Stringly-Typed Report Templates

You’ve built a solid CLI tool that analyzes music genres using generics. Now you need to generate automated reports from that data. A naive approach uses string templates with placeholders like {{genre}} and {{artist}}. But what if you accidentally type {{genree}}? That slips into production, producing broken reports. You want the compiler to catch these mistakes.

Solution: Compile-Time Template Validation

TypeScript’s template literal types let you pattern-match on string literals at the type level. Combined with recursive conditional types, you can parse a template string, extract every {{field}}, and verify each field exists in your data type. Invalid templates cause a compile error.

We’ll build an AmethystReportGenerator that takes a data source and a template string literal. If the template contains a non-existent field, the code won’t compile.

Step 1: Define the Data Source Type

Assume your music analysis tool produces records like this:

type MusicRecord = {
  genre: string;
  artist: string;
  album: string;
  year: number;
  popularity: number;
};

const data: MusicRecord[] = [
  { genre: "Jazz", artist: "Miles Davis", album: "Kind of Blue", year: 1959, popularity: 87 },
  { genre: "Rock", artist: "Led Zeppelin", album: "IV", year: 1971, popularity: 95 },
];

We’ll use keyof MusicRecord to get the union of valid field names: "genre" | "artist" | "album" | "year" | "popularity".

Step 2: A Recursive Type to Parse Placeholders

We want to define a type ValidateTemplate<Data, Template> that checks if Template is a string literal containing only valid {{field}} placeholders. If valid, it resolves to the original Template; otherwise it resolves to never (or a descriptive error).

Here’s the core parsing type:

type ParsePlaceholders<Data, S extends string> =
  S extends `${infer Before}{{${infer Field}}}${infer After}`
    ? Field extends keyof Data
      ? `${Before}{{${Field}}}${ParsePlaceholders<Data, After>}`
      : never // Field not found in Data
    : S; // No more placeholders, return remainder

This recursive conditional type does the following:

  • Uses template literal type with infer to match {{something}}.
  • Extracts Field and checks if it’s a key of Data.
  • If valid, it reconstructs the string with the placeholder and recursively parses the remaining After part.
  • If invalid, it returns never, causing a compile error.
  • When no more placeholders are found, it returns the rest of the string.

We also need to ensure the entire template is consumed. A final validation type:

type ValidateTemplate<Data, Template extends string> =
  Template extends ParsePlaceholders<Data, Template>
    ? Template
    : never;

If the parse result matches the original template, it’s valid. Otherwise, never.

Step 3: A Type-Safe Report Generator Class

Now we create the generator that uses this type to enforce template correctness.

class AmethystReportGenerator<Data extends Record<string, any>> {
  constructor(private data: Data[]) {}

  generate<Template extends string>(
    template: Template & ValidateTemplate<Data, Template>
  ): string {
    // Runtime replacement (safe because types already validated)
    return this.render(template);
  }

  private render(template: string): string {
    return template.replace(/\{\{(\w+)\}\}/g, (match, field) => {
      // We know field is valid at compile time, but runtime still needs to handle missing keys
      return this.data.map(record => {
        const value = record[field as keyof Data];
        return value !== undefined ? String(value) : `{{${field}}}`;
      }).join(", ");
    });
  }
}

Notice the template parameter type: Template & ValidateTemplate<Data, Template>. The intersection forces TypeScript to evaluate ValidateTemplate and if it returns never, the intersection becomes never, making the assignment impossible.

Step 4: Using the Generator

const reportGen = new AmethystReportGenerator(data);

// Valid template – compiles fine
const valid = reportGen.generate("{{genre}} by {{artist}} ({{year}})");
console.log(valid);
// Output: "Jazz by Miles Davis (1959), Rock by Led Zeppelin (1971)"

// Invalid template – compile error!
// const invalid = reportGen.generate("{{genree}} by {{artst}}");
// Error: Argument of type '"{{genree}} by {{artst}}"' is not assignable to parameter of type 'never'.

The compiler catches the typos genree and artst immediately. No runtime surprises.

Step 5: Adding a Mapped Header Row (CSV Report)

To make the report generator more useful, let’s add a method that produces a CSV from a list of columns. We’ll use a mapped type to generate the header row.

class CSVReportGenerator<Data extends Record<string, any>> extends AmethystReportGenerator<Data> {
  generateCSV<Columns extends (keyof Data)[]>(
    columns: Columns
  ): string {
    const header = columns.join(",");
    const rows = this.data.map(record =>
      columns.map(col => String(record[col])).join(",")
    );
    return [header, ...rows].join("\n");
  }
}

Here, Columns is a tuple of keys, and we use columns.join(",") for the header. The type system ensures you can only pass actual keys of Data.

const csvGen = new CSVReportGenerator(data);
const csv = csvGen.generateCSV(["genre", "artist", "year"]);
// Valid
// csvGen.generateCSV(["genree"]); // Compile error!

Step 6: Advanced – Custom Error Messages with satisfies

TypeScript 5.0’s satisfies operator can be used to give better error messages when a template doesn’t match. However, we can also use a helper type that yields a descriptive error:

type InvalidField<Data, Field extends string> =
  Field extends keyof Data
    ? never
    : `Error: '${Field}' is not a valid field. Valid fields: ${keyof Data & string}`;

type ValidateTemplateWithMsg<Data, Template extends string> =
  Template extends `${infer Before}{{${infer Field}}}${infer After}`
    ? Field extends keyof Data
      ? `${Before}{{${Field}}}${ValidateTemplateWithMsg<Data, After>}`
      : InvalidField<Data, Field>
    : Template;

Now the error message includes the offending field and a list of valid options. (Note: This requires TypeScript 5.0+ and may be verbose in some editors.)

Common Issues

  • Recursion depth: Very long templates with many placeholders may hit TypeScript’s recursion limit (default 50). For production, consider flattening the validation or using a non-recursive approach with a union of all valid placeholders.
  • Template literal types with dynamic strings: The template parameter must be a string literal type, not a variable. If you have a variable, cast it with as const or use satisfies.
  • Runtime rendering: The render method above assumes this.data is an array and joins all records. Adjust the logic to match your actual report format (e.g., single record, multiple rows).
Sponsored Deal

Summary

You’ve learned to apply template literal types and recursive conditional types for compile-time validation of report templates. This technique eliminates an entire class of runtime errors and scales to any data structure. Try extending the generator to support nested placeholders or conditional formatting—the type-level possibilities are vast.