Home > other >  Typescript genericized Omit?
Typescript genericized Omit?

Time:11-22

I am trying to create a genericised wrapper around a prisma database model. The model is simply a typed object representing the database table row being returned. You can think of it something like this:

type User = {
  user_id: bigint;
  password: string;
  email_address: string;
}

The wrapper provides a bunch of utility functions around these models and looks something like this:

    export default class Entity<T extends {}> {
    private readonly cleanModel: T;
    private model: Partial<T>| T;

    constructor(
        model: T,
        guardedProps: string[],
    ) {
        this.cleanModel = model;

        // By default, hide guarded props. Guarded props are only accessible
        // through methods which acknowledge guarded status
        const withoutSensitive: Partial<T> = _.omit(model, guardedProps);
        this.model = withoutSensitive;
    }

    /**
     * Returns the value of the provided key from the model.
     * @param key 
     */
    prop(key: keyof T): any {
        if (key in this.model) {
            return this.model[key];
        }

        throw TypeError(`Key ${String(key)} does not exist on entity Model`);
    }

    guardedProp(key: keyof T): any {
        if (key in this.cleanModel) {
            return this.cleanModel[key];
        }

        throw TypeError(`Key ${String(key)} does not exist on entity Model`);
    }

    /**
     * Picks just the requested keys and returns a new object with those keys.
     * To grab guarded properties, the boolean withGuarded can be passed in.
     * @param props 
     * @param withGuarded 
     * @returns 
     */
    pick(props: (keyof T)[], withGuarded: boolean = false): Partial<T> {
        let picked: Partial<T>  = _.pick(withGuarded ? this.cleanModel : this.model, props);
        return picked;
    }

    toString(): string {
        return this.model.toString();
    }

    toJSON(): Partial<T> | T {
        return this.model;
    }

}

Notice both model and guardedProps are Partial type. What I would prefer to do instead, is to have both model and guardedProps be Omit types so that I don't have to deal with the optional nature of Partial. This would improve IDE completion and would be useful for making it so sensitive information such as the password of the user is not accidentally revealed in logs or API responses.

However, I can't seem to find a way to generically provide the key union to Entity. I am willing to define types for each union per-model, but I can't find a way to genericize that either.

Is there any way I can define a property on a class that is typed as a union of keys and would be accepted as a parameter in Omit like Omit<T, T["protectedProps"]? I've tried protectedProps: (keyof User)[] = ['password', 'user_id'] which resolves fine, but causes an error in Entity as keyof T[] is not assignable to type keyof T when I try the previously mentioned Omit syntax.

CodePudding user response:

I think you are looking for this.

class Entity<T, Garded extends string> {
    private readonly cleanModel: T;
    private model: _._Omit<T, Garded>;

    constructor(model: T, guardedProps: Garded[]) {
        this.cleanModel = model;

        // By default, hide guarded props. Guarded props are only accessible
        // through methods which acknowledge guarded status
        const withoutSensitive = _.omit(model, guardedProps);
        this.model = withoutSensitive;
    }

    /**
     * Returns the value of the provided key from the model.
     * @param key 
     */
    prop<K extends keyof _._Omit<T, Garded>>(key: K): _._Omit<T, Garded>[K] {
        if (key in this.model) {
            return this.model[key];
        }

        throw TypeError(`Key ${String(key)} does not exist on entity Model`);
    }

    guardedProp<K extends keyof T>(key: K): T[K] {
        if (key in this.cleanModel) {
            return this.cleanModel[key];
        }

        throw TypeError(`Key ${String(key)} does not exist on entity Model`);
    }

    /**
     * Picks just the requested keys and returns a new object with those keys.
     * To grab guarded properties, the boolean withGuarded can be passed in.
     * @param props 
     * @param withGuarded 
     * @returns 
     */
    pick<K extends keyof T & string>(props: K[], withGuarded: true): _._Pick<T, K>
    pick<K extends keyof T & string>(props: K[], withGuarded?: false): _._Pick<_._Omit<T, Garded>, K>
    pick<K extends keyof T & string>(props: K[], withGuarded: boolean = false) {
        return _.pick(withGuarded ? this.cleanModel : this.model, props);
    }

    toString(): string {
        return this.model.toString();
    }

    toJSON(): _._Omit<T, Garded> {
        return this.model;
    }

}

underscore has its own _Pick and _Omit types. I didn't bother figuring out the difference, but they don't seem to be compatible with the standard utility types, so you are kind of forced to use them.

I noticed that one of the return types of pick has Partial in it. I suppose you can safely turn that off since Entity is exhaustive by construction.

CodePudding user response:

I played around with this for a number of hours after seeing geoffrey's answer. While adding the omitted keys to the class was useful for typing, it only got me the types I needed and wasn't enough information at run or compile time to help me narrow the types down.

Ideally, I also did not want to pass in a slew of types every time I needed an instance of an Entity. So here's what I ended up with, in case anyone wants to do something like this on their own.

User model

type User = {
  user_id: bigint;
  password: string;
  email_address: string;
}

Model class (replacing Entity), as a base class to be extended.

export class Model<T extends {}, GuardedT extends {}, TGuarded extends {}> {
    private cleanModel: T;

    model: GuardedT;
    guarded: TGuarded;

    constructor(model: T, guardedProps: (keyof T)[]) {
        // Create a clean version of the prisma model as it is now. This will help
        // determine if changes are made later. 
        this.cleanModel = _.cloneDeep(model);

        // Separate the guarded props from the rest.
        // Any is used here because we will gradually build up to the expected
        // types.
        const props: any = {};
        const guarded: any = {};

        for (const prop in model) {
            if (guardedProps.includes(prop)) {
                guarded[prop] = model[prop];
            } else {
                props[prop] = model[prop];
            }
        }

        this.model = props as GuardedT;
        this.guarded = guarded as TGuarded;
    }

    toJSON(): GuardedT {
        return this.model;
    }

    toString(): string {
        return this.model.toString();
    }

    /**
     * Picks just the requested keys and returns a new object with those keys.
     * Should be used where possible for network transmission of model data.
     * @param props 
     * @returns 
     */
    pick(props: (keyof T)[]): Partial<T> {
        return _.pick(this.cleanModel, props);
    }
    
}

To use it, a new class is defined representing the base model, like so. I also define types that represent the guarded model, and just the guarded props.

export type UserGuard = Omit<User, "password">;
export type UserGuarded = Pick<User, "password">;

const guarded: (keyof User)[] = ['password'];

export class UserModel extends Model<User, UserGuard, UserGuarded>{
    constructor(model: User) {
        super(model, guarded);
        this.model = model;
    }
}

Now I can simply class new UserModel(returnedModelFromPrisma).

  • Related