'Can't merge generic type in callbacks with Nextjs and TypeScript

I'm currently developing a project in NextJS and implementing callbacks as decorators to enhance Next API handler (join.ts). While developing it I was able to enhance the NextApiRequest interface successfully, combining props from each callback/decorator, in the end, providing the request with additional properties that I may access in the handler. However, while trying the same thing with NextApiResponse, I wasn't able to pass down the response that the handler will provide, only being able to type the first callback response, which ends up like this:

type RoomsRoomIdJoinResponse = RoomsRoomIdJoinErrors | RequestValidationErrors

My files:

import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next';

import { verify } from 'jsonwebtoken';

import {
  ExpiredCredentialsError,
  InvalidCredentialsError,
  MissingCredentialsError,
  UnexpectedError,
} from './pages/api/errors';

type AuthorizationValidationErrors =
  | MissingCredentialsError
  | InvalidCredentialsError
  | ExpiredCredentialsError
  | UnexpectedError;

type ApiHandler<Request, Response> = (
  request: Request & { headers: { authorization: string } },
  response: Response | NextApiResponse<AuthorizationValidationErrors>
) => ReturnType<NextApiHandler<Response>>;

type DecoratedApiHandler<Request, Response> = (
  request: Request,
  response: Response | NextApiResponse<AuthorizationValidationErrors>
) => ReturnType<NextApiHandler<Response>>;

const authorizationValidation = <
  Request extends NextApiRequest,
  Response extends NextApiResponse
>(
  handler: ApiHandler<Request, Response>
) => {
  const decoratedHandler: DecoratedApiHandler<Request, Response> = async (
    request,
    response
  ) => {
    const {
      headers: { authorization },
    } = request;

    let statusCode = 401;
    let json: AuthorizationValidationErrors = new MissingCredentialsError(
      'Missing credentials'
    );

    if (authorization) {
      try {
        const decodedJwt = verify(authorization, process.env.SECRET_KEY);

        if (
          decodedJwt &&
          typeof decodedJwt === 'object' &&
          'roomId' in decodedJwt &&
          'playerId' in decodedJwt
        ) {
          await handler(
            { ...request, ...{ headers: { authorization } } },
            response
          );
        } else {
          statusCode = 401;
          json = new InvalidCredentialsError('Invalid credentials');
        }
      } catch ({ name: errorName }) {
        if (errorName === 'JsonWebTokenError') {
          statusCode = 401;
          json = new InvalidCredentialsError('Invalid credentials');
        } else if (errorName === 'TokenExpiredError') {
          statusCode = 403;
          json = new ExpiredCredentialsError('Expired credentials');
        } else {
          statusCode = 500;
          json = new UnexpectedError(
            'Unexpected error while processing authorization'
          );
        }
      }
    } else {
      response.status(statusCode).json(json);
    }
  };

  return decoratedHandler;
};

export default authorizationValidation;
import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next';

import { decode } from 'jsonwebtoken';

type ApiHandler<Request, Response> = (
  request: Request & { roomId?: string; playerId?: string },
  response: Response
) => ReturnType<NextApiHandler<Response>>;

type DecoratedApiHandler<Request, Response> = (
  request: Request,
  response: Response
) => ReturnType<NextApiHandler<Response>>;

const decodedJwtInjection = <
  Request extends NextApiRequest,
  Response extends NextApiResponse
>(
  handler: ApiHandler<Request, Response>
) => {
  const decoratedHandler: DecoratedApiHandler<Request, Response> = async (
    request,
    response
  ) => {
    const {
      headers: { authorization },
    } = request;

    if (authorization) {
      const decodedJwt = decode(authorization);

      if (
        decodedJwt &&
        typeof decodedJwt === 'object' &&
        'roomId' in decodedJwt &&
        'playerId' in decodedJwt
      ) {
        request = {
          ...request,
          roomId: decodedJwt.roomId,
          playerId: decodedJwt.playerId,
        };
      }
    }

    await handler(request, response);
  };

  return decoratedHandler;
};

export default decodedJwtInjection;
import type { NextApiHandler, NextApiRequest, NextApiResponse } from 'next';

import { ajv } from '.';
import {
  BadRequestError,
  InvalidCredentialsError,
  MethodNotAllowedError,
  MissingCredentialsError,
  UnexpectedError,
} from './pages/api/errors';

type RequestValidationErrors =
  | MissingCredentialsError
  | UnexpectedError
  | MethodNotAllowedError
  | BadRequestError
  | UnexpectedError;

type ApiHandler<Request, Response> = (
  request: Request,
  response: Response | NextApiResponse<RequestValidationErrors>
) => ReturnType<NextApiHandler<Response>>;

type DecoratedApiHandler<Request, Response> = (
  request: Request,
  response: Response | NextApiResponse<RequestValidationErrors>
) => ReturnType<NextApiHandler<Response>>;

const requestValidation = <
  Request extends NextApiRequest,
  Response extends NextApiResponse
>(
  handler: ApiHandler<Request, Response>,
  schemaId: string
) => {
  const decoratedHandler: DecoratedApiHandler<Request, Response> = async (
    request,
    response
  ) => {
    let json: RequestValidationErrors = new UnexpectedError('Invalid schema');
    let statusCode = 500;

    const ajvInstance = ajv.getInstance();

    const validate = ajvInstance.getSchema(schemaId);

    if (validate) {
      const valid = validate(request);

      if (valid) {
        await handler(request, response);
      } else {
        json = new BadRequestError('Bad request');
        statusCode = 400;

        const errors = validate.errors;

        if (errors && errors.length) {
          const error = errors[0];

          if (error.instancePath === '/method') {
            statusCode = 405;
            json = new MethodNotAllowedError('Method not allowed');
          }

          if (error.instancePath === '/headers/authorization') {
            statusCode = 401;
            json = new InvalidCredentialsError('Invalid credentials');
          }

          if (
            error.instancePath === '/headers' &&
            error.params.missingProperty === 'authorization'
          ) {
            statusCode = 401;
            json = new MissingCredentialsError('Missing credentials');
          }

          if (error.message) {
            json.message = `Request ${error.instancePath.slice(1)} ${
              error.message
            }`
              .replace(/\s+/g, ' ')
              .trim();

            if (error.keyword === 'enum') {
              json.message += `: ${error.params.allowedValues.join(', ')}`;
            }
          }
        }

        response.status(statusCode).json(json);
      }
    } else {
      response.status(statusCode).json(json);
    }
  };

  return decoratedHandler;
};

export default requestValidation;
import { verify } from 'jsonwebtoken';
import { NextApiRequest, NextApiResponse } from 'next';

import {
  authorizationValidation,
  connectionFactory,
  decodedJwtInjection,
  requestValidation,
} from '../../../../utils';
import {
  ForbiddenAccessError,
  InvalidCredentialsError,
  NotFoundError,
  UnexpectedError,
} from '../../../../utils/pages/api/errors';

import UnprocessableError from '../../../../utils/pages/api/errors/UnprocessableError';

type RoomsRoomIdJoinErrors = NotFoundError | ForbiddenAccessError | undefined;

type Handler = typeof handler;

export type RoomsRoomIdJoinResponse = Parameters<
  Parameters<Handler>[1]['send']
>[0];

const handler = requestValidation<
  NextApiRequest,
  NextApiResponse<RoomsRoomIdJoinErrors>
>(
  authorizationValidation(
    decodedJwtInjection(async (request, response) => {
      let statusCode;
      let json;

      const redis = connectionFactory().createRedisConnection(
        process.env.REDIS_URL
      );

      const {
        headers: { authorization },
      } = request;

      try {
        const decodedPayload = verify(
          authorization as string,
          process.env.SECRET_KEY
        );

        if (
          decodedPayload &&
          typeof decodedPayload === 'object' &&
          'roomId' in decodedPayload
        ) {
          const { query } = request;

          if (decodedPayload.roomId === query.roomId) {
            const roomExists = await redis.exists(query.roomId as string);

            if (roomExists) {
              const votes = await redis.hget(decodedPayload.roomId, 'votes');

              if (votes) {
                const parsedVotes = JSON.parse(votes);

                const isNewPlayer = !parsedVotes.some(
                  (player: object) => decodedPayload.playerId in player
                );

                if (isNewPlayer) {
                  const newVotes = JSON.stringify([
                    ...parsedVotes,
                    { [decodedPayload.playerId]: '' },
                  ]);

                  await redis.hset(decodedPayload.roomId, 'votes', newVotes);
                }
              } else {
                statusCode = 422;
                json = new UnprocessableError('Malformed room structure');
              }

              statusCode = 204;
              json = undefined;
            } else {
              statusCode = 404;
              json = new NotFoundError('Room not found');
            }
          } else {
            statusCode = 403;
            json = new ForbiddenAccessError('Forbidden access to this room');
          }
        } else {
          statusCode = 401;
          json = new InvalidCredentialsError('Invalid credentials');
        }
      } catch {
        statusCode = 500;
        json = new UnexpectedError('Unexpected error while processing request');
      }

      response.status(statusCode).json(json);
    })
  ),
  'RequestSchema'
);

export default handler;

My goal is to achieve something like this:

type RoomsRoomIdJoinResponse = RoomsRoomIdJoinErrors | RequestValidationErrors | AuthorizationValidationErrors

Any idea how to solve this? :)

Edit #1 - Added code in question to the post as suggested by @juliomalves



Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source