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
}
}
The only way this could work is to make your base class generic in the type
M
of the model specific to each subclass:The return type of
toEntity()
is equivalent toOmit<M, "id"> & Entity
(using theOmit
utility type to suppress a set of keys, and intersection to add a set of keys), so to save space I've defined aToEntity<M>
utility type:That means the input type of
toModel
should beToEntity<M>
. I haven't annotated its return type, which the compiler infers asOmit<ToEntity<M>, "_id" | "_key"> & { id: string; }
. Conceptually it should just beM
, but unfortunately if you try to annotate it as such, you'll get an error: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 onExclude
, which is a conditional type). Yes,Omit<Omit<M, "id"> & Entity, "_id" | "_key"> & { id: string }
is probably going to be the same asM
in practice (technically it is possible for them to be different; e.g., theid
property might be a subtype ofstring
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
,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:
But in cases where the equivalence of
M
andtoModel()
's return type is broken, you'll get an error which should hopefully help:But it really depends on your use cases which approach you want to take.
Playground link to code