Before You Begin
- Node.js 18+ and TypeScript 5.0+ installed.
- Familiarity with generics, conditional types, and the
inferkeyword (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 remainderThis recursive conditional type does the following:
- Uses template literal type with
inferto match{{something}}. - Extracts
Fieldand checks if it’s a key ofData. - If valid, it reconstructs the string with the placeholder and recursively parses the remaining
Afterpart. - 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
templateparameter must be a string literal type, not a variable. If you have a variable, cast it withas constor usesatisfies. - Runtime rendering: The
rendermethod above assumesthis.datais an array and joins all records. Adjust the logic to match your actual report format (e.g., single record, multiple rows).
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.
Memuat komentar...