-
-
Save b2whats/dc571d40c244f11fbbca77ecc828bee2 to your computer and use it in GitHub Desktop.
Explicitly declare modifications to an existing object type via a fluent interface which yields a mapping function to the derived type. Demonstration of TypeScript meta-programming through mapped and conditional types.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* _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<originalObject extends {}, propertiesToRemove extends (keyof originalObject)[]> = | |
propertiesToRemove extends (infer propertiesToRemoveUnion)[] | |
? Pick<originalObject, Exclude<keyof originalObject, propertiesToRemoveUnion>> | |
: never; | |
/** Produce a new object type changing the type of one of the properties. */ | |
export type ChangePropertyType<originalObject extends {}, nameOfPropertyToChange extends keyof originalObject, newPropertyType> = | |
{ | |
[currentProperty in keyof originalObject]: currentProperty extends nameOfPropertyToChange | |
? newPropertyType | |
: originalObject[currentProperty] | |
}; | |
/** Produce a new object type by renaming a property. */ | |
export type RenameProperty<originalObject, originalPropertyName extends keyof originalObject, newPropertyName extends string | symbol> = | |
// Drop the original name | |
Pick<originalObject, Exclude<keyof originalObject, originalPropertyName>> | |
& // Add the new property name with the original type | |
Record<newPropertyName, originalObject[originalPropertyName]>; | |
/** A fluent builder for strongly typed object to object transforms. */ | |
export interface IObjectTypeMapperBuilder<originalObjectInPipeline, transformThisObject> { | |
removeProperties<removeProperties extends (keyof transformThisObject)[]>( | |
removeSpecification: removeProperties | |
): IObjectTypeMapperBuilder<originalObjectInPipeline, RemoveProperties<transformThisObject, removeProperties>>; | |
addProperty<newPropertyName extends string>(newPropertyName: Exclude<newPropertyName, keyof transformThisObject>): { | |
withDefaultValue<newPropertyType>(defaultValue: newPropertyType): IObjectTypeMapperBuilder<originalObjectInPipeline, transformThisObject & Record<newPropertyName, newPropertyType>>; | |
}; | |
renameProperty<existingPropertyName extends keyof transformThisObject, newPropertyName extends string>( | |
existingPropertyName: existingPropertyName, | |
newPropertyName: newPropertyName | |
): IObjectTypeMapperBuilder<originalObjectInPipeline, RenameProperty<transformThisObject, existingPropertyName, newPropertyName>>; | |
mapProperty<newType, propertyName extends keyof transformThisObject>( | |
propertyName: propertyName, | |
map: (val: transformThisObject[propertyName]) => newType | |
): IObjectTypeMapperBuilder<originalObjectInPipeline, ChangePropertyType<transformThisObject, propertyName, newType>>; | |
getMapper(): (original: originalObjectInPipeline) => transformThisObject; | |
} | |
// Interface is exported but the class is not for encapsulation. | |
class ObjectTypeMapperBuilder< | |
originalObjectInPipeline extends {}, | |
transformThisObject extends {} | |
> implements IObjectTypeMapperBuilder<originalObjectInPipeline, transformThisObject> { | |
constructor(private readonly _transform: (original: originalObjectInPipeline) => transformThisObject) { } | |
public addProperty<newPropertyName extends string>(newPropertyName: Exclude<newPropertyName, keyof transformThisObject>) { | |
const { _transform } = this; | |
return { withDefaultValue } | |
function withDefaultValue<newPropertyType>(defaultValue: newPropertyType) { | |
return new ObjectTypeMapperBuilder((originalObject: originalObjectInPipeline): transformThisObject & Record<newPropertyName, newPropertyType> => { | |
const thisObject = _transform(originalObject); | |
return { | |
...thisObject | |
, [newPropertyName]: defaultValue | |
} as any; | |
}); | |
} | |
} | |
public removeProperties<removeProperties extends (keyof transformThisObject)[]>( | |
removeProperties: removeProperties | |
): IObjectTypeMapperBuilder<originalObjectInPipeline, RemoveProperties<transformThisObject, removeProperties>> { | |
return new ObjectTypeMapperBuilder((originalObject: originalObjectInPipeline): RemoveProperties<transformThisObject, 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 extends keyof transformThisObject, newPropertyName extends string>( | |
existingPropertyName: existingPropertyName, | |
newPropertyName: newPropertyName | |
): IObjectTypeMapperBuilder<originalObjectInPipeline, RenameProperty<transformThisObject, existingPropertyName, newPropertyName>> { | |
return new ObjectTypeMapperBuilder((originalObject: originalObjectInPipeline): RenameProperty<transformThisObject, existingPropertyName, newPropertyName> => { | |
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<newType, propertyName extends keyof transformThisObject>( | |
propertyName: propertyName, | |
transform: (val: transformThisObject[propertyName]) => newType | |
): IObjectTypeMapperBuilder<originalObjectInPipeline, ChangePropertyType<transformThisObject, propertyName, newType>> { | |
return new ObjectTypeMapperBuilder((originalObject: originalObjectInPipeline): ChangePropertyType<transformThisObject, propertyName, newType> => { | |
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 = <T>(): IObjectTypeMapperBuilder<T, T> => | |
new ObjectTypeMapperBuilder<T, T>(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<Person>() | |
.addProperty("weightInLbs").withDefaultValue<number | null>(null) | |
.addProperty("gender").withDefaultValue<Gender>("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<typeof mapToPersonV2>; | |
} | |
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."); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment