Farad
Farad

Reputation: 1065

How to use generic type defined in the class property?

I want to create own class controller for handling express requests.

What I have:

Controller:

import { Request, Response } from 'express';
import { transvalidate } from './schema/transform-validate';
import { ClassConstructor } from 'class-transformer';

export abstract class Controller<QuerySchema extends object> {
  querySchema?: ClassConstructor<QuerySchema>;
  query?: QuerySchema;

  constructor(
    protected readonly req: Request,
    protected readonly res: Response,
  ) {
    if (this.querySchema) {
      this.query = transvalidate(this.querySchema, this.req.query);
    }
  }

  getQuery(): QuerySchema {
    if (!this.query) {
      throw new Error(
        `Can't get query. Probably the querySchema is not provided.`,
      );
    }
    return this.query;
  }

  abstract handle(): Promise<unknown>;
}

GetCatsController:

import { Controller } from './controller';
import { GetCatsQuerySchema } from './schema/query.schema';

export class CatsController extends Controller<GetCatsQuerySchema> {
  querySchema = GetCatsQuerySchema;

  handle(): Promise<unknown> {
    // here this.getQuery() returns transformed to GetCatsQuerySchema and validated req.query object
    return Promise.resolve(undefined);
  }
}

GetCatsQuerySchema:

import { IsInt, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';

export class GetCatsQuerySchema {
  @Type(() => Number)
  @IsInt()
  @IsOptional()
  age?: string;
}

transform-validate function:

import 'reflect-metadata';
import { ClassConstructor, plainToClass } from 'class-transformer';
import { validateSync, ValidationError } from 'class-validator';

export const transvalidate = <T extends object>(
  targetClass: ClassConstructor<T>,
  rawData: unknown,
) => {
  const transformedValue = plainToClass(targetClass, rawData);
  const errors = validateSync(transformedValue);
  if (errors.length) {
    const errorMessages = getAllErrorMessages(errors);
    throw new Error(errorMessages[0]);
  }
  return transformedValue;
};

const getAllErrorMessages = (errors: ValidationError[]) => {
  const errorMessages: string[] = [];
  errors.map((error) => {
    if (error.constraints) {
      errorMessages.push(...Object.values(error.constraints));
    }
  });
  return errorMessages;
};

But I don't like that each class should provide QuerySchema when extends Controller:

class CatsController extends Controller<GetCatsQuerySchema> {

Instead of this I want to find a way to define QuerySchema in the class properties:

export class CatsController extends Controller {
  querySchema = GetCatsQuerySchema;

Is it possible?

Upvotes: 0

Views: 1077

Answers (1)

Titian Cernicova-Dragomir
Titian Cernicova-Dragomir

Reputation: 249636

If the class is generic you have to explicitly specify the type argument. And this is the approach I would definitely go for.

Now if the class is not generic, you don't have to specify the generic type parameter. The only issue is that you would need to instead of QuerySchema find a way to say 'use whatever type is in the derived class'. Fortunately typescript supports polymorphic this, which basically means whatever the current type is. We can use the polymorphic this type to extract the actual type of querySchema in the derived type:

type QuerySchema<T extends {querySchema?: ClassConstructor<object>}> = 
    InstanceType<Exclude<T['querySchema'], undefined>>
    
export abstract class Controller {
  querySchema?: ClassConstructor<object>;
  query?: QuerySchema<this>;

  constructor(
    protected readonly req: Request,
    protected readonly res: Response,
  ) {
    if (this.querySchema) {
      this.query = null!;
    }
  }

  getQuery(): QuerySchema<this> {
    // ...
  }

  abstract handle(): Promise<unknown>;
}

export class CatsController extends Controller {
  querySchema = GetCatsQuerySchema;

  handle(): Promise<unknown> {
    this.getQuery().age
    this.getQuery().age1 // error
    return Promise.resolve(undefined);
  }
}


export class GetCatsQuerySchema {
  age?: string;
}

Playground Link

This seems to work, but you will probably run into issues with assignability sooner or later due to the conditional types, so I would stick with the generic approach. So use at your own risk.

Upvotes: 1

Related Questions