Response Mapping

A Consistent Success Envelope

Many teams wrap every success response the same way, so the frontend always reads res.data.

src/common/interceptors/transform.interceptor.ts

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, map } from 'rxjs';

export interface ApiResponse<T> {
  success: true;
  data: T;
  timestamp: string;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
  intercept(_context: ExecutionContext, next: CallHandler<T>): Observable<ApiResponse<T>> {
    return next.handle().pipe(
      map((data) => ({ success: true as const, data, timestamp: new Date().toISOString() })),
    );
  }
}

Example

curl http://localhost:3000/products/7

Example output

{"success":true,"data":{"id":7,"name":"boAt Airdopes 141","price":1299,"stock":120,"category":"audio"},"timestamp":"2026-01-15T10:00:00.000Z"}

Hiding Fields with ClassSerializerInterceptor

Never send password hashes. Mark fields with @Exclude() from class-transformer and enable the built-in ClassSerializerInterceptor. It only works when you return class instances, not plain objects.

import { Exclude, Expose } from 'class-transformer';

export class UserEntity {
  id: number;
  email: string;
  @Exclude() passwordHash: string;
  @Exclude() refreshTokenHash: string | null;

  @Expose()
  get displayName() { return this.email.split('@')[0]; }

  constructor(partial: Partial<UserEntity>) { Object.assign(this, partial); }
}

// main.ts: app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
// controller: return new UserEntity(user);