Draft
Response

Response Documentation

This document outlines the general approach for handling server responses (actions / queries) in a Next.js application. As client code calls the Server Action over the network, any arguments passed will need to be serializable. Thus includes the response as well.

We will define Separate Response Types (Success and Error) using Discriminated Unions and use custom error codes for detailed error reporting. The getUser() endpoint will serve as an example to illustrate the implementation.

Summary
  • Use discriminated unions to define separate response types for success and error cases, providing a clear and type-safe way to handle API responses.
  • Define custom error codes using enums to ensure that error handling is both readable and maintainable.
  • Document the error codes and their meanings clearly.
  • Ensure that error codes are used consistently throughout the application.

Server code

Response Types

Discriminated unions allow us to define separate types for success and error responses, each distinguished by a success property.

SuccessResponse

interface SuccessResponse<T> {
  success: true;
  data: T;
}

ErrorResponse

interface ErrorResponse {
  success: false;
  error: {
    code: number;
    message: string;
  };
}

OperationResponse Type

type OperationResponse<T> = SuccessResponse<T> | ErrorResponse;
Why don't we have a single Response Type? Click here to learn why

Separating the SuccessResponse and ErrorResponse interfaces allows for a clear distinction between the structure of a successful response and an error response. This can be particularly useful when leveraging TypeScript's type system to enforce that certain code paths only deal with one type of response or the other, providing better type safety and clearer intent in the code.

See the examples and pros / cons of both approches below.

Example of Discriminated Union:

interface SuccessResponse<T> {
  success: true;
  data: T;
}
 
interface ErrorResponse {
  success: false;
  error: {
    code: number;
    message: string;
  };
}
 
type OperationResponse<T> = SuccessResponse<T> | ErrorResponse;

Example of Single Type with Optional Properties:

interface OperationResponse<T> {
  success: boolean;
  data?: T;
  error?: {
    code: number;
    message: string;
  };
}

Separate Types (Discriminated Union):

Pros:

  • Strong Type Safety: Each response type is explicitly defined, reducing the risk of runtime errors.
  • Clear Intent: Code readability is enhanced as each type conveys a specific meaning.
  • Pattern Matching: Facilitates the use of type guards and discriminated unions for precise handling.
  • Extensibility: Easier to add new properties or states without affecting other response types.

Cons:

  • More Types: Increases the number of interfaces, which can add to code verbosity.
  • Complexity: Might require more effort to set up and maintain multiple types.
  • Redundancy: Similar properties must be repeated across different types.

Single Type with Optional Properties:

Pros:

  • Simplicity: A single interface is easier to manage and understand for simple APIs.
  • Uniform Handling: Functions can handle the response uniformly, regardless of success or error.
  • Flexibility: Can be more adaptable to changes where the distinction between success and error is not strict.

Cons:

  • Weaker Type Safety: Optional properties can lead to the need for runtime checks, increasing the risk of errors.
  • Ambiguity: The intent of the response might be less clear, as success and error are not explicitly separated.
  • Error Prone: Developers might forget to check for the presence of data or error, leading to potential issues.

Custom Error Codes

Custom error codes are defined using an enum to provide specific error information.

Error Codes Enum

enum ErrorCode {
  UserNotFound = 2001,
  UnauthorizedAccess = 2002,
  InvalidUserId = 2003,
  // Additional error codes as needed
}

Utility Functions

Utility functions help in preparing success and error responses in a consistent manner.

Success Response Utility

function successResponse<T>(data: T): SuccessResponse<T> {
  return {
    success: true,
    data,
  };
}

Error Response Utility

function errorResponse(code: ErrorCode, message: string): ErrorResponse {
  return {
    success: false,
    error: {
      code,
      message,
    },
  };
}

Example: getUser()

Server-Side Implementation

The server action for getUser() will return an OperationResponse.

export const getUser = (id: number): OperationResponse<UserData> => {
  try {
    const userData = getUserDataById(id);
 
    if (!userData) {
        return errorResponse(ErrorCode.UserNotFound, 'User not found')
    }
 
    return successResponse(userData);
 
  } catch (error) {
    // Log the error and return a generic error response
    console.error(error);
    return errorResponse(ErrorCode.InternalServerError, 'An unexpected error occurred'));
  }
};

Client-Side Usage

import { getUser } from '../server/actions/userActions'; // Import the server action
 
// Example usage of the getUser server action and OperationResponseWrapper
async function displayUser(userId: number) {
    const response = await getUser(userId); // Call getUser as a function
 
    if (!apiResponse.success) {
        return <p>Error: {apiResponse.error.message}</p>
    }
 
    const user = apiResponse.data; 
    ...
}