Skip to content

Instantly share code, notes, and snippets.

@JSuder-xx
Created January 12, 2019 17:59
Show Gist options
  • Save JSuder-xx/6bba9b80a46d81801ffa3e0ec651c04b to your computer and use it in GitHub Desktop.
Save JSuder-xx/6bba9b80a46d81801ffa3e0ec651c04b 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.
/**
* _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