import { Exclude, Expose } from 'class-transformer';
export class User {
id: string;
firstName: string;
lastName: string;
@Expose({ groups: ['admin'] })
email: string;
@Exclude()
password: string;
@Exclude()
twoFactorSecret: string | null;
createdAt: Date;
constructor(partial: Partial<User>) {
Object.assign(this, partial);
}
@Expose()
get fullName(): string {
return `${this.firstName} ${this.lastName}`.trim();
}
}
import {
ClassSerializerInterceptor,
Controller,
Get,
NotFoundException,
Param,
SerializeOptions,
UseInterceptors,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { User } from './user.entity';
@Controller('users')
@UseInterceptors(ClassSerializerInterceptor)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(':id')
async findOne(@Param('id') id: string): Promise<User> {
const user = await this.usersService.findById(id);
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
return new User(user);
}
@Get(':id/admin')
@SerializeOptions({ groups: ['admin'] })
async findForAdmin(@Param('id') id: string): Promise<User> {
const user = await this.usersService.findById(id);
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
return new User(user);
}
}
import { NestFactory, Reflector } from '@nestjs/core';
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
app.useGlobalInterceptors(
new ClassSerializerInterceptor(app.get(Reflector), {
excludeExtraneousValues: false,
enableImplicitConversion: true,
}),
);
await app.listen(3000);
}
bootstrap();
This snippet shows how NestJS serializes outgoing responses through class-transformer, using decorators to strip sensitive data before it leaves the controller. The core idea is that a controller should return a class instance (an entity), and a ClassSerializerInterceptor transforms that instance into a plain object right before the response is written, honoring @Exclude() and @Expose() metadata along the way. This keeps field-hiding declarative and centralized instead of scattering delete user.password calls across handlers.
In user.entity.ts, the User class annotates persistence-only fields like password and twoFactorSecret with @Exclude(), so they never appear in serialized output regardless of which endpoint returns the object. email is wrapped with @Expose({ groups: ['admin'] }), meaning it is only emitted when the serialization context requests the admin group — a common pattern for showing more data to privileged callers. The fullName getter uses @Expose() to add a computed field that has no database column, demonstrating that serialization operates on the runtime instance, not the schema.
Because class-transformer only excludes fields it knows about, the entity constructor uses Object.assign so plain rows from the ORM become real User instances that carry the decorator metadata. Returning a bare object would bypass the interceptor entirely, which is the most common pitfall.
In users.controller.ts, @UseInterceptors(ClassSerializerInterceptor) activates serialization for the controller, and @SerializeOptions({ groups: ['admin'] }) on findForAdmin passes the group into the transform so admin routes see email while the public findOne route does not. The @Get() handlers simply return User instances and let the interceptor do the hiding.
In main.ts, app.useGlobalInterceptors wires the interceptor app-wide using the DI Reflector, and the global ValidationPipe with transform: true and whitelist: true ensures inbound DTOs are also class instances with unknown properties dropped. A key trade-off: excludeExtraneousValues is deliberately not enabled here, so only decorated fields are hidden; teams wanting an allow-list model would flip that on and @Expose() everything intended to ship. This approach is worth reaching for whenever the same entity is returned by many endpoints and consistent, auditable field-hiding matters more than per-handler control.
Related snips
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
#!/usr/bin/env bash
set -euo pipefail
export VAULT_ADDR="https://vault.internal:8200"
export VAULT_TOKEN="${VAULT_TOKEN:?missing VAULT_TOKEN}"
Secrets management with environment isolation and Vault
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
<%# private stream: turbo signs the serialized record name %>
<%= turbo_stream_from current_user %>
<section class="notifications">
<h1>Notifications</h1>
Turbo Streams + authorization: signed per-user stream name
package api
import (
"io"
"net/http"
"os"
Safe multipart uploads using temp files (bounded memory)
Share this code
Here's the card — post it anywhere.