Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save b2whats/dc571d40c244f11fbbca77ecc828bee2 to your computer and use it in GitHub Desktop.
Save b2whats/dc571d40c244f11fbbca77ecc828bee2 to your computer and use it in GitHub Desktop.

Revisions

  1. @JSuder-xx JSuder-xx created this gist Jan 12, 2019.
    199 changes: 199 additions & 0 deletions fluent_mapper_builder_gist.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,199 @@
    /**
    * _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.");