Search code examples
typescriptpolymorphismtypescript-genericstypescript-types

How to use polymorphism to transform an object from one type into another with "transformer" classes inheritance


I'm developing a GraphQL API. The GraphQL types and the database types are not the same because I didn't want my database specific structure to leak to the API consumers. Basically I want to rename properties or remove properties from the object at hand depending whether I return the data to the consumer or send the object down to the database. The types in GraphQL have a id: string property but on the database side they have _id: string, _key: string.

This set of transformations would be global to all top-level types so I thought of using a base "Transformer" class that would implement that shared logic. Then each domain within the API could have a specific class extending the base class but providing more transformations based on the particular type it's handling. I named Model the types coming from API layer and Entity the types coming from the database.

Here is the implementation I ended up with but I'm not sure it's the best way to type it:

// base-transformer.ts

export interface Entity {
  _id: string;
  _key: string;
}

export interface Model {
  id: string;
}

export class BaseTransformer {
  toEntity(model: Model): Entity {
    const { id, ...rest } = model;
    const [, key] = id.split("/");

    return { ...rest, _id: id, _key: key };
  }

  toModel(entity: Entity): Model {
    const { _id, _key, ...rest } = entity;
    return { ...rest, id: _id };
  }
}
// car-transformer.ts

import { BaseTransformer, type Model, type Entity } from "./base-transformer";

export interface CarModel extends Model {
  plateNumber: string;
}

type CarEntity = Omit<CarModel, "id"> & Entity;

export class CarTransformer extends BaseTransformer {
  toEntity(model: CarModel): CarEntity {
    // that's the part that troubles me. I tried not to have to cast the return
    // but obviously with polymorphism you can get to the most child type to the 
    // parent type but not the other way around.
    return super.toEntity(model) as CarEntity;
  }

  toModel(entity: CarEntity): CarModel {
    return super.toModel(entity) as CarModel;
  }
}

I also tried bound generics type but then I ended up with TS Error 2322 as the subtype abstracted by the generic could be different than the type the generic extends. The solution I found would have been to pass a callback to the BaseTransformer methods whose job is to transform the parent type to the T generic type. I feel this would have defeated the purpose of this architecture in the first place as I'd have to do the same job the BaseTransformer class does into each of the children classes. Here is how it looked like:

// base-transformer.ts

export interface Entity {
  _id: string;
  _key: string;
}

export interface Model {
  id: string;
}

export class BaseTransformer {
  toEntity<R extends Entity>(model: Model): R {
    const { id, ...rest } = model;
    const [, key] = id.split("/");

    return { ...rest, _id: id, _key: key }; // TS Error 2322 here
  }

  toModel<R extends Model>(entity: Entity): R {
    const { _id, _key, ...rest } = entity;
    return { ...rest, id: _id }; // TS Error 2322 here
  }
}

Solution

  • The only way this could work is to make your base class generic in the type M of the model specific to each subclass:

    class BaseTransformer<M extends Model> {
      toEntity(model: M): ToEntity<M> {
        const { id, ...rest } = model;
        const [, key] = id.split("/");
    
        return { ...rest, _id: id, _key: key };
      }
    
      toModel(entity: ToEntity<M>) {
        const { _id, _key, ...rest } = entity;
        return { ...rest, id: _id };
      }
    }
    

    The return type of toEntity() is equivalent to Omit<M, "id"> & Entity (using the Omit utility type to suppress a set of keys, and intersection to add a set of keys), so to save space I've defined a ToEntity<M> utility type:

    type ToEntity<M extends Model> = Omit<M, "id"> & Entity;
    

    That means the input type of toModel should be ToEntity<M>. I haven't annotated its return type, which the compiler infers as Omit<ToEntity<M>, "_id" | "_key"> & { id: string; }. Conceptually it should just be M, but unfortunately if you try to annotate it as such, you'll get an error:

    toModel(entity: ToEntity<M>): M {
      const { _id, _key, ...rest } = entity;
      return { ...rest, id: _id }; // error!
      // ^-- Type 'Omit<ToEntity<M>, "_id" | "_key"> & { id: string; }' 
      // is not assignable to type 'M'.
    }
    

    That's because the type checker is unable to perform arbitrary higher-order reasoning about abstract invariant properties of generic types, especially if they involve conditional types as used in Omit (which depends on Exclude, which is a conditional type). Yes, Omit<Omit<M, "id"> & Entity, "_id" | "_key"> & { id: string } is probably going to be the same as M in practice (technically it is possible for them to be different; e.g., the id property might be a subtype of string like "foo"), but the type checker cannot see it.

    There are two ways to proceed, therefore. Either you just assert that the return type is M,

    toModel(entity: ToEntity<M>) {
      const { _id, _key, ...rest } = entity;
      return { ...rest, id: _id } as Model as M;
    }
    

    or you can just leave the return type unannotated. I've opted to leave the return type unannotated. In cases where the subclass is properly implemented, this won't matter:

    interface CarModel extends Model {
      plateNumber: string;
    }
    type CarEntity = Omit<CarModel, "id"> & Entity;
    
    export class CarTransformer extends BaseTransformer<CarModel> {
      toEntity(model: CarModel): CarEntity {
        return super.toEntity(model);
      }
    
      toModel(entity: CarEntity): CarModel {
        return super.toModel(entity);
      }
    }
    

    But in cases where the equivalence of M and toModel()'s return type is broken, you'll get an error which should hopefully help:

    interface OopsieModel extends Model {
      id: "foo" // <-- narrower than string
      x: number
    }
    interface OopsieEntity extends Entity {
      x: number
    }
    class OopsieTransformer extends BaseTransformer<OopsieModel> {
      toEntity(model: OopsieModel): OopsieEntity {
        return super.toEntity(model);
      }
      toModel(entity: OopsieEntity): OopsieModel {
        return super.toModel(entity) // error! 
        // Type 'string' is not assignable to type '"foo"'
      }
    }
    

    But it really depends on your use cases which approach you want to take.

    Playground link to code