/** * _Explicitly declare modifications_ to an existing object type via a fluent interface which yields a mapping function * from the original data type to the type derived from the modification commands. * * The value over authoring a simple mapping function directly * * harder to make a mistake due to the explicit nature * * easier to read. * * This is _not_ production quality code but rather a _proof of concept_ gist for applying conditional/mapped * types to mapper generation. A real-world usage might involve also generating the type-guard function * and integrating the entire thing with some kind of Document Database migration system. * * **NOTE**: This code file can be copy/pasted into the [TypeScript Playground](http://www.typescriptlang.org/play/) * * **NOTE**: Type arguments are named descriptively using camelcase; just like any other argument/variable. **/ module FluentMapperBuilder { /** Produce a new type by removing a set of properties from a given an object type. */ export type RemoveProperties = propertiesToRemove extends (infer propertiesToRemoveUnion)[] ? Pick> : never; /** Produce a new object type changing the type of one of the properties. */ export type ChangePropertyType = { [currentProperty in keyof originalObject]: currentProperty extends nameOfPropertyToChange ? newPropertyType : originalObject[currentProperty] }; /** Produce a new object type by renaming a property. */ export type RenameProperty = // Drop the original name Pick> & // Add the new property name with the original type Record; /** A fluent builder for strongly typed object to object transforms. */ export interface IObjectTypeMapperBuilder { removeProperties( removeSpecification: removeProperties ): IObjectTypeMapperBuilder>; addProperty(newPropertyName: Exclude): { withDefaultValue(defaultValue: newPropertyType): IObjectTypeMapperBuilder>; }; renameProperty( existingPropertyName: existingPropertyName, newPropertyName: newPropertyName ): IObjectTypeMapperBuilder>; mapProperty( propertyName: propertyName, map: (val: transformThisObject[propertyName]) => newType ): IObjectTypeMapperBuilder>; getMapper(): (original: originalObjectInPipeline) => transformThisObject; } // Interface is exported but the class is not for encapsulation. class ObjectTypeMapperBuilder< originalObjectInPipeline extends {}, transformThisObject extends {} > implements IObjectTypeMapperBuilder { constructor(private readonly _transform: (original: originalObjectInPipeline) => transformThisObject) { } public addProperty(newPropertyName: Exclude) { const { _transform } = this; return { withDefaultValue } function withDefaultValue(defaultValue: newPropertyType) { return new ObjectTypeMapperBuilder((originalObject: originalObjectInPipeline): transformThisObject & Record => { const thisObject = _transform(originalObject); return { ...thisObject , [newPropertyName]: defaultValue } as any; }); } } public removeProperties( removeProperties: removeProperties ): IObjectTypeMapperBuilder> { return new ObjectTypeMapperBuilder((originalObject: originalObjectInPipeline): RemoveProperties => { const thisObject = this._transform(originalObject); const newObj: { [index: string]: any } = {}; Object.keys(thisObject).forEach((property) => { if (removeProperties.indexOf(property as any) === -1) newObj[property] = (thisObject as any)[property]; }); return newObj as any; }); } public renameProperty( existingPropertyName: existingPropertyName, newPropertyName: newPropertyName ): IObjectTypeMapperBuilder> { return new ObjectTypeMapperBuilder((originalObject: originalObjectInPipeline): RenameProperty => { const thisObject = this._transform(originalObject); const newObj: { [index: string]: any } = {}; Object.keys(thisObject).forEach((property) => { newObj[property === existingPropertyName ? newPropertyName : property] = (thisObject as any)[property]; }); return newObj as any; }); } public mapProperty( propertyName: propertyName, transform: (val: transformThisObject[propertyName]) => newType ): IObjectTypeMapperBuilder> { return new ObjectTypeMapperBuilder((originalObject: originalObjectInPipeline): ChangePropertyType => { const thisObject = this._transform(originalObject); return { ...thisObject , [propertyName]: transform(thisObject[propertyName]) } as any; }); } public getMapper(): (original: originalObjectInPipeline) => transformThisObject { return this._transform; } } /** Start building a mapper that maps from this type. */ export const from = (): IObjectTypeMapperBuilder => new ObjectTypeMapperBuilder(it => it); } //---------------------------------------------------------------------------- // Example Usage //---------------------------------------------------------------------------- module ExampleTypesAndMappers { // Starting data type export type Person = { fName: string; lName: string; dob: Date; selfEsteem: "Low" | "Medium" | "High"; flurbNibble: number; } // An enumerated string data type export type Gender = "Unknown" | "Female" | "Male" | "Other"; // mapper const dateToDateJson = (date: Date) => ({ year: date.getFullYear() , month: date.getMonth() , date: date.getDate() }); // declare a mapper function using fluent interface export const mapToPersonV2 = FluentMapperBuilder.from() .addProperty("weightInLbs").withDefaultValue(null) .addProperty("gender").withDefaultValue("Unknown") // Uncomment the line below to watch the compiler complain because gender cannot be added again //.addProperty("gender").withDefaultValue("Male") .mapProperty("dob", dateToDateJson) .renameProperty("lName", "lastName") .renameProperty("fName", "firstName") .removeProperties(["selfEsteem", "flurbNibble"]) .getMapper(); // get the result type from the mapper export type PersonV2 = ReturnType; } const originalJim: ExampleTypesAndMappers.Person = { fName: "Jim" , lName: "Smith" , dob: new Date(1980, 1, 1) , selfEsteem: "Low" , flurbNibble: 42 }; const jimV2 = ExampleTypesAndMappers.mapToPersonV2(originalJim); // Hover over a field to inspect type/structure. [ jimV2.firstName , jimV2.lastName , jimV2.gender , jimV2.dob , jimV2.weightInLbs //, jimV2.flurbNibble //, jimV2.selfEsteem ]; console.log("Original", originalJim); console.log("Mapped", jimV2); alert("Check the Dev console for output.");