Before You Begin
- Node.js 18+ and npm
- TypeScript 5.0+ (uses latest mapped type features)
- Familiarity with basic generics (constraints with
extends, generic functions)
No prior knowledge of conditional types or infer is assumed—we'll build that from scratch.
---
The Problem: Stringly‑Typed Report Transformations
Every codebase eventually needs a report generator. A naive approach uses any or loose objects to define columns and their transformers. The result: runtime crashes when a column name is misspelled, or a transformer returns a value incompatible with the column type.
A common pattern seen in production TypeScript repositories on GitHub is sprinkling as any to suppress errors in report builders. This defeats the purpose of using TypeScript.
We'll build a report engine that guarantees every column name, its input type, and its transformer are checked at compile time—without sacrificing flexibility.
---
Step 1: Defining the Gemstone Data Source with Branded Identifiers
Start with a solid data source. We'll use branded types to avoid accidental confusion between IDs of different entities.
type GemId = string & { __brand: 'GemId' };
type MineId = string & { __brand: 'MineId' };
interface GemstoneRecord {
id: GemId;
name: string;
category: 'Ruby' | 'Sapphire' | 'Emerald' | 'Diamond';
caratWeight: number;
clarity: number; // 1‑10
colorGrade: number; // 1‑10
cutRating: number; // 1‑10
mineId: MineId;
dateMined: Date;
certificateUrl: string | null;
}Branded types prevent passing a GemId where a MineId is expected—a common bug when joining data. They also make generic constraints more meaningful.
---
Step 2: Generic Report Definition Using Mapped Types
A report is a collection of columns. Each column has a name (a key of the data source or a computed key) and a transformer that reads the data source and returns a value of a specific type.
We'll use a mapped type over a union of column descriptors to derive the output row type automatically.
type ColumnDescriptor<Source, Key extends string, Value> = {
key: Key;
label: string;
accessor: (row: Source) => Value;
};
// Derive the output row type from an array of descriptors
type ReportRow<Source, Descriptors extends ColumnDescriptor<Source, any, any>[]> = {
[D in Descriptors[number] as D['key']]: ReturnType<D['accessor']>;
};The mapped type uses as clause to remap keys to the actual column names. This technique is how libraries like zod and prisma derive output types from input schemas.
---
Step 3: Conditional Types for Optional Computed Columns
me columns are computed from the source, but may produce null if data is missing. For example, a valuation column depends on certificateUrl being present.
We can use conditional types to make a column type T | null based on a condition.
type MaybeComputed<
Source,
Key extends string,
Value,
Condition extends boolean
> = ColumnDescriptor<
Source,
Key,
Condition extends true ? Value : Value | null
>;And a helper to test if a source has a non‑null field:
type IsNotNullable<Source, Field extends keyof Source> =
null extends Source[Field] ? false : true;Now we can define a column that only returns a computed value if the field is not null:
const valuationColumn: MaybeComputed<
GemstoneRecord,
'computedValue',
number,
IsNotNullable<GemstoneRecord, 'certificateUrl'>
> = {
key: 'computedValue',
label: 'Estimated Value (USD)',
accessor: (row) => {
// row.certificateUrl is typed as string (not null)
return estimateFromCertificate(row.certificateUrl!); // safe because condition is true
},
};This pattern ensures the transformer receives a narrowed type only when the condition holds. A similar approach is used in the official TypeScript documentation for discriminated union narrowing.
---
Step 4: Distributive Conditional Types for Union Handling
Gemstone categories affect valuation formulas. A Ruby formula differs from an Emerald. We can model this with a union of formulas and distribute over it.
type CategoryValuation<
Cat extends 'Ruby' | 'Sapphire' | 'Emerald' | 'Diamond'
> = Cat extends 'Ruby'
? { quality: number; rarity: number }
: Cat extends 'Diamond'
? { carat: number; cut: number; color: number }
: { grade: number };
// Distribute over a union
type RecordValuations = {
[C in 'Ruby' | 'Sapphire' | 'Emerald' | 'Diamond']: CategoryValuation<C>;
};Distributive conditional types (when the checked type is a bare type parameter) split each union member individually. Many developers on StackOverflow encounter unexpected behavior when the conditional is not distributive because they wrapped the type in a tuple. We'll avoid that by always checking a naked type parameter.
---
Step 5: Infer Keyword to Extract Return Types from Row Transformers
Suppose we want to define a reusable transformer that takes a source and returns a derived column value. We can use infer inside a conditional type to capture the return type of a function.
type TransformResult<T extends (row: any) => any> =
T extends (row: infer R) => infer V ? (source: R) => V : never;
// Example
declare function computePrice(gem: GemstoneRecord): number;
type PriceTransform = TransformResult<typeof computePrice>;
// PriceTransform = (source: GemstoneRecord) => numberThis technique is how libraries like fp-ts and io-ts infer types from runtime validators.
---
Step 6: Building the Report Engine Class
Now assemble everything into a generic ReportBuilder class that takes a source type and an array of column descriptors, then produces typed rows.
class ReportBuilder<Source, Descriptors extends ColumnDescriptor<Source, any, any>[]> {
private descriptors: Descriptors;
constructor(descriptors: Descriptors) {
this.descriptors = descriptors;
}
generate(data: Source[]): ReportRow<Source, Descriptors>[] {
return data.map((row) => {
const result = {} as ReportRow<Source, Descriptors>;
for (const d of this.descriptors) {
(result as any)[d.key] = d.accessor(row);
}
return result;
});
}
}Usage:
const gemReport = new ReportBuilder([
{ key: 'name', label: 'Gem Name', accessor: (r) => r.name },
{ key: 'category', label: 'Category', accessor: (r) => r.category },
{ key: 'price', label: 'Price (USD)', accessor: (r) => computePrice(r) },
]);
const results = gemReport.generate(gemstoneData);
// results is typed as Array<{ name: string; category: string; price: number }>Any misspelled key or mismatched return type is caught by the compiler.
---
Common Issues
- Distributive conditionals not applying – Ensure the type being checked is a bare type parameter, e.g.,
T extends ...notSomeType<T> extends .... A wrapper breaks distribution. inferin non‑conditional positions –inferonly works inside anextends ... ? ... : ...clause. Using it elsewhere causes a parse error.- Branded types and assignment – Branded types require a cast when creating values. Use a helper like
asGemIdto avoidas any. - Circular references in mapped types – When using
askey remapping, ensure the key type is not referencing itself. TypeScript 5.0 improves error messages for this.
---
Summary
You've built a type‑safe report engine that uses advanced generic patterns:
- Branded types for nominal safety
- Mapped types with key remapping to derive row types
- Conditional types for optional columns
- Distributive conditionals for union‑based logic
inferto extract function return types
These patterns appear in production code from companies like Microsoft and in open‑source data validation libraries. By mastering them, you move from basic generics to designing truly flexible, compile‑time verified APIs.
Memuat komentar...