Skip to content

Instantly share code, notes, and snippets.

@brandonbryant12
Last active October 31, 2025 19:04
Show Gist options
  • Save brandonbryant12/7a8804cb77e6cb88d7dfe4b9b8b1617b to your computer and use it in GitHub Desktop.
Save brandonbryant12/7a8804cb77e6cb88d7dfe4b9b8b1617b to your computer and use it in GitHub Desktop.
Absolutely—there’s a clean, idiomatic way to (a) **re‑use start/end date validation across many DTOs** and (b) **run unit + e2e in one Jest invocation with a single coverage report**.
---
## A. Re‑usable date‑range validation for query DTOs
**Goal:** many endpoints accept `start` / `end` query params; we want:
* parse strings → `Date`
* validate each value
* validate the **relationship** (`end >= start`, optional max range, etc.)
* re‑use across DTOs
### 1) Create small, reusable cross‑field validators
```ts
// src/common/validation/is-after.decorator.ts
import {
registerDecorator,
ValidationOptions,
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
type Options = { allowEqual?: boolean };
@ValidatorConstraint({ name: 'IsAfter', async: false })
export class IsAfterConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments) {
const [relatedProp, opts] = args.constraints as [string, Options?];
const other = (args.object as any)?.[relatedProp];
if (!value || !other) return true; // let @IsDate/@IsOptional handle presence/type
const right = new Date(value as any).getTime();
const left = new Date(other as any).getTime();
if (Number.isNaN(left) || Number.isNaN(right)) return false;
return opts?.allowEqual ? right >= left : right > left;
}
defaultMessage(args: ValidationArguments) {
const [relatedProp, opts] = args.constraints as [string, Options?];
return `${args.property} must be ${opts?.allowEqual ? 'on or after' : 'after'} ${relatedProp}`;
}
}
export function IsAfter(relatedProp: string, options?: Options & ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [relatedProp, options],
validator: IsAfterConstraint,
});
};
}
```
Optional: cap the range length (handy for analytics queries):
```ts
// src/common/validation/max-range-days.decorator.ts
import {
registerDecorator, ValidationOptions, ValidationArguments,
ValidatorConstraint, ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ name: 'MaxRangeDays', async: false })
export class MaxRangeDaysConstraint implements ValidatorConstraintInterface {
validate(_: unknown, args: ValidationArguments) {
const [days] = args.constraints as [number];
const obj = args.object as any;
if (!obj?.start || !obj?.end) return true;
const ms = new Date(obj.end).getTime() - new Date(obj.start).getTime();
if (Number.isNaN(ms)) return false;
return ms <= days * 24 * 60 * 60 * 1000;
}
defaultMessage(args: ValidationArguments) {
const [days] = args.constraints as [number];
return `date range must be ${days} days or less`;
}
}
export function MaxRangeDays(days: number, options?: ValidationOptions) {
return (object: Object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
constraints: [days],
options,
validator: MaxRangeDaysConstraint,
});
};
}
```
> This uses **class‑validator**’s custom decorators and constraints. It’s the standard way to express cross‑property rules (the validator receives the whole object via `args.object`). ([GitHub][1])
### 2) A shared `DateRangeQueryDto` you can compose everywhere
```ts
// src/common/dto/date-range.query.dto.ts
import { IsDate, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
import { IsAfter } from '../validation/is-after.decorator';
import { MaxRangeDays } from '../validation/max-range-days.decorator';
export class DateRangeQueryDto {
@IsOptional()
@Type(() => Date) // transform before validation
@IsDate() // validate the transformed value
start?: Date;
@IsOptional()
@Type(() => Date)
@IsDate()
@IsAfter('start', { allowEqual: true, message: 'end must be on/after start' })
@MaxRangeDays(90, { message: 'date range cannot exceed 90 days' })
end?: Date;
}
```
> With `ValidationPipe({ transform: true })`, `class-transformer` runs **before** validation, so you should validate as `@IsDate()` (not `@IsDateString()`), because by the time validation runs these are `Date` objects. If you really need to enforce ISO‑8601 **strings**, validate the raw string first or write a small transformer that rejects non‑ISO values. ([NestJS Documentation][2])
### 3) Compose it into endpoint‑specific DTOs (no duplication)
Use Nest’s *mapped types* to **compose** query DTOs:
```ts
// src/orders/dto/orders.query.dto.ts
import { IntersectionType } from '@nestjs/mapped-types';
import { DateRangeQueryDto } from '../../common/dto/date-range.query.dto';
import { PaginationQueryDto } from '../../common/dto/pagination.query.dto'; // example
export class OrdersQueryDto extends IntersectionType(
DateRangeQueryDto,
PaginationQueryDto,
) {}
```
You can also `extends DateRangeQueryDto` if that fits better. `IntersectionType` is handy when you need to combine multiple DTOs without creating inheritance chains. ([NestJS Documentation][2])
> This pattern gives you one place to maintain the date logic and lets every controller share it via composition.
### 4) (Optional) DI inside validators
If a validator needs a service (e.g., to check “date must be within an account’s billing window”), enable DI in class‑validator:
```ts
// in main.ts after creating the app
import { useContainer } from 'class-validator';
useContainer(app.select(AppModule), { fallbackOnErrors: true });
```
Then mark your constraint with `@Injectable()` and inject services as usual. ([GitHub][1])
---
## B. One Jest run, unified coverage for unit **and** e2e
You don’t need two separate CLI runs. Use **Jest’s multi‑project runner**: define a **root** config that lists your unit and e2e configs in `projects`, then run Jest once with coverage enabled.
### 1) Root config (aggregator)
```ts
// jest.config.ts
import type { Config } from 'jest';
const config: Config = {
// Run both suites in one go
projects: [
'<rootDir>/jest.unit.config.ts',
'<rootDir>/jest.e2e.config.ts',
],
// Put coverage settings at the *root* so the report is unified
collectCoverage: true,
collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/main.ts',
'!src/**/*.module.ts',
],
coverageDirectory: '<rootDir>/coverage',
coverageReporters: ['text', 'lcov', 'html', 'cobertura'],
// Ensure both projects use the same provider to avoid mismatches
coverageProvider: 'v8', // or 'babel' — pick one and use it in both child configs
};
export default config;
```
Jest’s config supports a `projects` array for this exact use case; the CLI also has `--projects` to run multiple configs at once. Putting `collectCoverage*` at the top ensures a **single** coverage directory/output for the combined run. ([jestjs.io][3])
### 2) Unit project
```ts
// jest.unit.config.ts
import type { Config } from 'jest';
const config: Config = {
displayName: 'unit',
testMatch: ['<rootDir>/src/**/*.spec.ts'],
preset: 'ts-jest',
testEnvironment: 'node',
// Keep coverage flags here OFF; inherited from root
// If you transform TS, align coverageProvider with root ('v8' or 'babel')
// globals: { 'ts-jest': { tsconfig: '<rootDir>/tsconfig.spec.json' } },
};
export default config;
```
### 3) E2E project
```ts
// jest.e2e.config.ts
import type { Config } from 'jest';
const config: Config = {
displayName: 'e2e',
testMatch: ['<rootDir>/test/**/*.e2e-spec.ts'],
preset: 'ts-jest',
testEnvironment: 'node',
maxWorkers: 1, // avoid port collisions while spinning up Nest apps
testTimeout: 30000, // e2e often needs more time
// Same note as above on coverageProvider & ts-jest config if needed
};
export default config;
```
### 4) Scripts
```json
{
"scripts": {
"test": "jest --config jest.config.ts",
"test:cov": "jest --config jest.config.ts --coverage",
"test:unit": "jest --config jest.unit.config.ts",
"test:e2e": "jest --config jest.e2e.config.ts"
}
}
```
> Running `npm run test:cov` now executes **both** suites and produces a **single coverage report** in `coverage/`. ([jestjs.io][3])
#### If you must keep totally separate runs
If for CI/scaling reasons you **must** execute unit and e2e in separate steps, output **JSON** coverage from each, then merge them with **nyc**:
```bash
# run separately (each produces coverage/*coverage-final.json)
jest -c jest.unit.config.ts --coverage --coverageReporters=json
jest -c jest.e2e.config.ts --coverage --coverageReporters=json
# merge → create one report
npx nyc merge coverage coverage/coverage-merged.json
# Tell nyc to generate HTML/LCOV from the merged JSON
npx nyc report --temp-dir=coverage --reporter=lcov --reporter=html
```
`nyc merge` is the Istanbul‑JS supported way to combine coverage JSONs from multiple runs into a single report. ([GitHub][4])
---
## Extra tips & gotchas
* **Unknown query params**: keep your global `ValidationPipe` with `{ whitelist: true, forbidNonWhitelisted: true, transform: true }` so extraneous params 400, and date strings become `Date`. ([NestJS Documentation][2])
* **Date parsing**: `@Type(() => Date)` converts *before* validation; validate with `@IsDate()`. If you need strict ISO‑8601, either (a) validate the raw string first, or (b) implement a transform that rejects non‑ISO (using `validator.js`’s `isISO8601`) then returns a `Date`. ([GitHub][1])
* **Composing DTOs**: prefer `IntersectionType` from `@nestjs/mapped-types` to keep DTOs DRY and explicit. ([NestJS Documentation][2])
* **DI in validators**: enable `useContainer()` if a constraint needs services. ([GitHub][1])
---
### TL;DR
* Put `start`/`end` (and cross‑field rules) in a **shared DateRange DTO** + **custom decorators** (`@IsAfter`, `@MaxRangeDays`). Compose it into each endpoint’s DTO with `IntersectionType`. ([GitHub][1])
* Use a **single Jest run** with a **root `projects` config** and **root coverage settings** to get one unified coverage report. If you can’t run once, **merge** separate JSON coverages with `nyc merge`. ([jestjs.io][3])
If you paste a snippet of your current DTOs/Jest configs, I’ll tailor the exact code to your repo structure.
[1]: https://github.com/typestack/class-validator "GitHub - typestack/class-validator: Decorator-based property validation for classes."
[2]: https://docs.nestjs.com/techniques/validation "Validation | NestJS - A progressive Node.js framework"
[3]: https://jestjs.io/docs/configuration "Configuring Jest · Jest"
[4]: https://github.com/istanbuljs/nyc?utm_source=chatgpt.com "istanbuljs/nyc: the Istanbul command line interface"
Great twist—this is a perfect fit for a **reusable, configurable date‑range DTO** that (a) applies smart defaults and (b) still enforces the rules you spelled out.
Below I show a drop‑in pattern that:
* Reuses the same date‑range logic across many endpoints
* Lets each endpoint **pass functions** to compute default `start`/`end`
* Handles your truth table:
* **no `start` & no `end`** → use `defaultStart()` and `defaultEnd()`
* **`start` provided, `end` missing** → default `end` to **today**
* **`end` provided, `start` missing** → **fail validation**
* **both provided** → validate normally
* Still keeps your **`ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })`** flow
* Plays nicely with tests (E2E and “unit‑ish” with `pipe.transform`)
---
## 0) What we’ll build
We’ll create:
1. Two tiny reusable validators:
* `@IsAfter('start', { allowEqual: true })` → checks `end >= start`
* `@IsValidDate()` → ensures it’s a `Date` and not `Invalid Date`
2. A **DTO factory (mixin)** you call with default functions:
```ts
const Last30DaysRangeDto = buildDateRangeQueryDto({
defaultStart: () => subDays(new Date(), 30),
defaultEnd: () => new Date(),
});
```
You then compose this DTO into your per‑route DTOs (or use it directly).
---
## 1) Reusable validators
```ts
// src/common/validation/is-valid-date.decorator.ts
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
export function IsValidDate(options?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'IsValidDate',
target: object.constructor,
propertyName,
options,
validator: {
validate(value: any) {
if (value == null) return true;
return value instanceof Date && !Number.isNaN(value.getTime());
},
defaultMessage(args: ValidationArguments) {
return `${args.property} must be a valid date`;
},
},
});
};
}
```
```ts
// src/common/validation/is-after.decorator.ts
import {
registerDecorator, ValidationOptions, ValidationArguments,
ValidatorConstraint, ValidatorConstraintInterface,
} from 'class-validator';
type Options = { allowEqual?: boolean };
@ValidatorConstraint({ name: 'IsAfter', async: false })
export class IsAfterConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments) {
const [relatedProp, opts] = args.constraints as [string, Options?];
const other = (args.object as any)?.[relatedProp];
if (value == null || other == null) return true;
const right = new Date(value as any).getTime();
const left = new Date(other as any).getTime();
if (Number.isNaN(left) || Number.isNaN(right)) return false;
return opts?.allowEqual ? right >= left : right > left;
}
defaultMessage(args: ValidationArguments) {
const [relatedProp, opts] = args.constraints as [string, Options?];
return `${args.property} must be ${opts?.allowEqual ? 'on or after' : 'after'} ${relatedProp}`;
}
}
export function IsAfter(relatedProp: string, options?: Options & ValidationOptions) {
return (object: Object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [relatedProp, options],
validator: IsAfterConstraint,
});
};
}
```
---
## 2) The DTO factory with defaults
> **Why a factory/mixin?**
> It lets each endpoint **pass functions** for defaults (e.g., last 7 days vs last 30 days), keeps the logic DRY, and still works with Nest’s `ValidationPipe` transformation.
```ts
// src/common/dto/date-range.mixin.ts
import { Transform } from 'class-transformer';
import {
IsDate, IsDefined, IsOptional, ValidateIf,
} from 'class-validator';
import { IsAfter } from '../validation/is-after.decorator';
import { IsValidDate } from '../validation/is-valid-date.decorator';
export type DateRangeDefaults = {
/** Default start date when both start & end are absent */
defaultStart: () => Date;
/** Default end date when both are absent, or when only start is present */
defaultEnd?: (ctx: { start?: Date }) => Date; // defaults to () => new Date()
};
function toDate(value: any): Date | undefined {
if (value == null || value === '') return undefined;
if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
/**
* Build a DTO class that:
* - Uses defaults when neither param is provided
* - Uses today as end when start is provided without end (via defaultEnd)
* - Fails if end is provided without start
* - Validates end >= start
*/
export function buildDateRangeQueryDto(defaults: DateRangeDefaults) {
const defaultEnd = defaults.defaultEnd ?? (() => new Date());
class DateRangeQueryDto {
// START
@Transform(({ value, obj }) => {
const start = toDate(value);
const endRaw = toDate(obj?.end);
// Case 1: both absent -> default start
if (!start && !endRaw) return defaults.defaultStart();
// Case 2: end present but start missing -> leave undefined to FAIL via @IsDefined
if (!start && endRaw) return undefined;
// Case 3: start present -> keep it
return start;
}, { toClassOnly: true })
@IsOptional() // it's optional unless end is present (see ValidateIf+IsDefined below)
@IsDate()
@IsValidDate()
@ValidateIf((o: DateRangeQueryDto) => o.end !== undefined)
@IsDefined({ message: 'start is required when end is provided' })
start?: Date;
// END
@Transform(({ value, obj }) => {
const end = toDate(value);
const startRaw = toDate(obj?.start);
// Case A: explicit end -> keep it
if (end) return end;
// Case B: start present and end missing -> default end (today by default)
if (startRaw) return defaultEnd({ start: startRaw });
// Case C: both missing -> default end alongside default start
return defaultEnd({ start: defaults.defaultStart() });
}, { toClassOnly: true })
@IsOptional()
@IsDate()
@IsValidDate()
@IsAfter('start', { allowEqual: true, message: 'end must be on or after start' })
end?: Date;
}
return DateRangeQueryDto;
}
```
> **Note on “today”:** `defaultEnd` defaults to `() => new Date()` (current moment). If you want *end of day*, you can pass `() => endOfDay(new Date())`. Likewise for *start of day* in `defaultStart`.
---
## 3) Using it in controllers (per‑route defaults)
Pick the default window you want per endpoint:
```ts
// src/orders/dto/orders.query.dto.ts
import { IntersectionType } from '@nestjs/mapped-types';
import { buildDateRangeQueryDto } from '../../common/dto/date-range.mixin';
// import { PaginationQueryDto } from '../../common/dto/pagination.query.dto'; // example
// Example: "last 30 days" through now
const Last30DaysRangeDto = buildDateRangeQueryDto({
defaultStart: () => {
const d = new Date();
d.setDate(d.getDate() - 30);
return d;
},
defaultEnd: () => new Date(), // or endOfDay(new Date())
});
export class OrdersQueryDto extends IntersectionType(
Last30DaysRangeDto,
// PaginationQueryDto,
) {}
```
Controller:
```ts
// src/orders/orders.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { OrdersQueryDto } from './dto/orders.query.dto';
@Controller('orders')
export class OrdersController {
@Get()
list(@Query() query: OrdersQueryDto) {
// query.start and query.end are Dates with defaults applied & validated
return { start: query.start, end: query.end };
}
}
```
You can create multiple “preset” DTOs:
```ts
const Last7DaysRangeDto = buildDateRangeQueryDto({
defaultStart: () => {
const d = new Date();
d.setDate(d.getDate() - 7);
return d;
},
defaultEnd: () => new Date(),
});
```
---
## 4) Behavior, spelled out
* **No `start`, no `end`**
→ `start = defaultStart()`, `end = defaultEnd({ start })`
* **`start` given, `end` missing**
→ `end = defaultEnd({ start })` (today by default)
* **`end` given, `start` missing**
→ `start` remains `undefined` → `@ValidateIf(o => o.end !== undefined) @IsDefined()`
→ **400 Bad Request** (“start is required when end is provided”)
* **Both given**
→ Validated as dates, plus `end >= start` via `@IsAfter('start', { allowEqual: true })`
---
## 5) Tests (quick examples)
### E2E (pipes run; defaults applied; failure on end‑only)
```ts
// test/orders.e2e-spec.ts
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('OrdersController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const modRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = modRef.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
transformOptions: { enableImplicitConversion: true },
}),
);
await app.init();
});
afterAll(async () => app.close());
it('defaults both when absent', async () => {
const res = await request(app.getHttpServer()).get('/orders').expect(200);
// Expect ISO strings; assert they parse and that end is >= start.
expect(new Date(res.body.start).getTime()).toBeLessThanOrEqual(new Date(res.body.end).getTime());
});
it('defaults end to today when only start is provided', async () => {
const res = await request(app.getHttpServer())
.get('/orders?start=2025-10-01')
.expect(200);
expect(res.body.start).toBe('2025-10-01T00:00:00.000Z'); // if you normalize to startOfDay; otherwise just parse
expect(new Date(res.body.end).getTime()).toBeGreaterThanOrEqual(new Date('2025-10-01').getTime());
});
it('fails when end is provided without start', async () => {
const res = await request(app.getHttpServer())
.get('/orders?end=2025-10-30')
.expect(400);
expect(res.body.message).toEqual(expect.arrayContaining(['start is required when end is provided']));
});
});
```
### “Unit‑ish” (call the pipe manually)
```ts
// test/date-range.pipe.spec.ts
import { ValidationPipe, ArgumentMetadata } from '@nestjs/common';
import { buildDateRangeQueryDto } from '../src/common/dto/date-range.mixin';
describe('DateRange defaults via DTO transforms', () => {
const DateRangeDto = buildDateRangeQueryDto({
defaultStart: () => {
const d = new Date(); d.setDate(d.getDate() - 30); return d;
},
defaultEnd: () => new Date(),
});
const pipe = new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
});
const meta: ArgumentMetadata = { type: 'query', metatype: DateRangeDto, data: '' };
it('applies both defaults when none provided', async () => {
const dto = await pipe.transform({}, meta);
expect(dto.start).toBeInstanceOf(Date);
expect(dto.end).toBeInstanceOf(Date);
expect(dto.end.getTime()).toBeGreaterThanOrEqual(dto.start.getTime());
});
it('defaults end when only start provided', async () => {
const dto = await pipe.transform({ start: '2025-10-01' }, meta);
expect(dto.end).toBeInstanceOf(Date);
});
it('throws when end provided without start', async () => {
await expect(pipe.transform({ end: '2025-10-30' }, meta)).rejects.toThrow();
});
});
```
---
## 6) Notes & tweaks
* **Normalization**: If you want *start of day* / *end of day* behavior, encode that in `defaultStart()` / `defaultEnd()` (e.g., set hours to `00:00:00.000` and `23:59:59.999`).
* **Max window**: If you also want “range must be ≤ N days”, add the earlier `@MaxRangeDays(N)` cross‑field validator to the end property.
* **DI in validators**: If any cross‑field check needs a service, enable `useContainer(app.select(AppModule), { fallbackOnErrors: true })` in `main.ts` and mark your constraint `@Injectable()`.
---
### TL;DR
Use a **DTO factory (mixin)** that sets defaults via `@Transform` and validates with small, reusable decorators. Endpoints pass **functions** to define `defaultStart()` and `defaultEnd()`. The rules you asked for are enforced, the logic is shared, and your existing `ValidationPipe` keeps doing the heavy lifting.
If you share one of your actual controllers/DTOs, I can plug this in and tailor the default window & tests to your codebase.
Great twist—this is a perfect fit for a **reusable, configurable date‑range DTO** that (a) applies smart defaults and (b) still enforces the rules you spelled out.
Below I show a drop‑in pattern that:
* Reuses the same date‑range logic across many endpoints
* Lets each endpoint **pass functions** to compute default `start`/`end`
* Handles your truth table:
* **no `start` & no `end`** → use `defaultStart()` and `defaultEnd()`
* **`start` provided, `end` missing** → default `end` to **today**
* **`end` provided, `start` missing** → **fail validation**
* **both provided** → validate normally
* Still keeps your **`ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })`** flow
* Plays nicely with tests (E2E and “unit‑ish” with `pipe.transform`)
---
## 0) What we’ll build
We’ll create:
1. Two tiny reusable validators:
* `@IsAfter('start', { allowEqual: true })` → checks `end >= start`
* `@IsValidDate()` → ensures it’s a `Date` and not `Invalid Date`
2. A **DTO factory (mixin)** you call with default functions:
```ts
const Last30DaysRangeDto = buildDateRangeQueryDto({
defaultStart: () => subDays(new Date(), 30),
defaultEnd: () => new Date(),
});
```
You then compose this DTO into your per‑route DTOs (or use it directly).
---
## 1) Reusable validators
```ts
// src/common/validation/is-valid-date.decorator.ts
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
export function IsValidDate(options?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'IsValidDate',
target: object.constructor,
propertyName,
options,
validator: {
validate(value: any) {
if (value == null) return true;
return value instanceof Date && !Number.isNaN(value.getTime());
},
defaultMessage(args: ValidationArguments) {
return `${args.property} must be a valid date`;
},
},
});
};
}
```
```ts
// src/common/validation/is-after.decorator.ts
import {
registerDecorator, ValidationOptions, ValidationArguments,
ValidatorConstraint, ValidatorConstraintInterface,
} from 'class-validator';
type Options = { allowEqual?: boolean };
@ValidatorConstraint({ name: 'IsAfter', async: false })
export class IsAfterConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments) {
const [relatedProp, opts] = args.constraints as [string, Options?];
const other = (args.object as any)?.[relatedProp];
if (value == null || other == null) return true;
const right = new Date(value as any).getTime();
const left = new Date(other as any).getTime();
if (Number.isNaN(left) || Number.isNaN(right)) return false;
return opts?.allowEqual ? right >= left : right > left;
}
defaultMessage(args: ValidationArguments) {
const [relatedProp, opts] = args.constraints as [string, Options?];
return `${args.property} must be ${opts?.allowEqual ? 'on or after' : 'after'} ${relatedProp}`;
}
}
export function IsAfter(relatedProp: string, options?: Options & ValidationOptions) {
return (object: Object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [relatedProp, options],
validator: IsAfterConstraint,
});
};
}
```
---
## 2) The DTO factory with defaults
> **Why a factory/mixin?**
> It lets each endpoint **pass functions** for defaults (e.g., last 7 days vs last 30 days), keeps the logic DRY, and still works with Nest’s `ValidationPipe` transformation.
```ts
// src/common/dto/date-range.mixin.ts
import { Transform } from 'class-transformer';
import {
IsDate, IsDefined, IsOptional, ValidateIf,
} from 'class-validator';
import { IsAfter } from '../validation/is-after.decorator';
import { IsValidDate } from '../validation/is-valid-date.decorator';
export type DateRangeDefaults = {
/** Default start date when both start & end are absent */
defaultStart: () => Date;
/** Default end date when both are absent, or when only start is present */
defaultEnd?: (ctx: { start?: Date }) => Date; // defaults to () => new Date()
};
function toDate(value: any): Date | undefined {
if (value == null || value === '') return undefined;
if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
/**
* Build a DTO class that:
* - Uses defaults when neither param is provided
* - Uses today as end when start is provided without end (via defaultEnd)
* - Fails if end is provided without start
* - Validates end >= start
*/
export function buildDateRangeQueryDto(defaults: DateRangeDefaults) {
const defaultEnd = defaults.defaultEnd ?? (() => new Date());
class DateRangeQueryDto {
// START
@Transform(({ value, obj }) => {
const start = toDate(value);
const endRaw = toDate(obj?.end);
// Case 1: both absent -> default start
if (!start && !endRaw) return defaults.defaultStart();
// Case 2: end present but start missing -> leave undefined to FAIL via @IsDefined
if (!start && endRaw) return undefined;
// Case 3: start present -> keep it
return start;
}, { toClassOnly: true })
@IsOptional() // it's optional unless end is present (see ValidateIf+IsDefined below)
@IsDate()
@IsValidDate()
@ValidateIf((o: DateRangeQueryDto) => o.end !== undefined)
@IsDefined({ message: 'start is required when end is provided' })
start?: Date;
// END
@Transform(({ value, obj }) => {
const end = toDate(value);
const startRaw = toDate(obj?.start);
// Case A: explicit end -> keep it
if (end) return end;
// Case B: start present and end missing -> default end (today by default)
if (startRaw) return defaultEnd({ start: startRaw });
// Case C: both missing -> default end alongside default start
return defaultEnd({ start: defaults.defaultStart() });
}, { toClassOnly: true })
@IsOptional()
@IsDate()
@IsValidDate()
@IsAfter('start', { allowEqual: true, message: 'end must be on or after start' })
end?: Date;
}
return DateRangeQueryDto;
}
```
> **Note on “today”:** `defaultEnd` defaults to `() => new Date()` (current moment). If you want *end of day*, you can pass `() => endOfDay(new Date())`. Likewise for *start of day* in `defaultStart`.
---
## 3) Using it in controllers (per‑route defaults)
Pick the default window you want per endpoint:
```ts
// src/orders/dto/orders.query.dto.ts
import { IntersectionType } from '@nestjs/mapped-types';
import { buildDateRangeQueryDto } from '../../common/dto/date-range.mixin';
// import { PaginationQueryDto } from '../../common/dto/pagination.query.dto'; // example
// Example: "last 30 days" through now
const Last30DaysRangeDto = buildDateRangeQueryDto({
defaultStart: () => {
const d = new Date();
d.setDate(d.getDate() - 30);
return d;
},
defaultEnd: () => new Date(), // or endOfDay(new Date())
});
export class OrdersQueryDto extends IntersectionType(
Last30DaysRangeDto,
// PaginationQueryDto,
) {}
```
Controller:
```ts
// src/orders/orders.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { OrdersQueryDto } from './dto/orders.query.dto';
@Controller('orders')
export class OrdersController {
@Get()
list(@Query() query: OrdersQueryDto) {
// query.start and query.end are Dates with defaults applied & validated
return { start: query.start, end: query.end };
}
}
```
You can create multiple “preset” DTOs:
```ts
const Last7DaysRangeDto = buildDateRangeQueryDto({
defaultStart: () => {
const d = new Date();
d.setDate(d.getDate() - 7);
return d;
},
defaultEnd: () => new Date(),
});
```
---
## 4) Behavior, spelled out
* **No `start`, no `end`**
→ `start = defaultStart()`, `end = defaultEnd({ start })`
* **`start` given, `end` missing**
→ `end = defaultEnd({ start })` (today by default)
* **`end` given, `start` missing**
→ `start` remains `undefined` → `@ValidateIf(o => o.end !== undefined) @IsDefined()`
→ **400 Bad Request** (“start is required when end is provided”)
* **Both given**
→ Validated as dates, plus `end >= start` via `@IsAfter('start', { allowEqual: true })`
---
## 5) Tests (quick examples)
### E2E (pipes run; defaults applied; failure on end‑only)
```ts
// test/orders.e2e-spec.ts
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('OrdersController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const modRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = modRef.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
transformOptions: { enableImplicitConversion: true },
}),
);
await app.init();
});
afterAll(async () => app.close());
it('defaults both when absent', async () => {
const res = await request(app.getHttpServer()).get('/orders').expect(200);
// Expect ISO strings; assert they parse and that end is >= start.
expect(new Date(res.body.start).getTime()).toBeLessThanOrEqual(new Date(res.body.end).getTime());
});
it('defaults end to today when only start is provided', async () => {
const res = await request(app.getHttpServer())
.get('/orders?start=2025-10-01')
.expect(200);
expect(res.body.start).toBe('2025-10-01T00:00:00.000Z'); // if you normalize to startOfDay; otherwise just parse
expect(new Date(res.body.end).getTime()).toBeGreaterThanOrEqual(new Date('2025-10-01').getTime());
});
it('fails when end is provided without start', async () => {
const res = await request(app.getHttpServer())
.get('/orders?end=2025-10-30')
.expect(400);
expect(res.body.message).toEqual(expect.arrayContaining(['start is required when end is provided']));
});
});
```
### “Unit‑ish” (call the pipe manually)
```ts
// test/date-range.pipe.spec.ts
import { ValidationPipe, ArgumentMetadata } from '@nestjs/common';
import { buildDateRangeQueryDto } from '../src/common/dto/date-range.mixin';
describe('DateRange defaults via DTO transforms', () => {
const DateRangeDto = buildDateRangeQueryDto({
defaultStart: () => {
const d = new Date(); d.setDate(d.getDate() - 30); return d;
},
defaultEnd: () => new Date(),
});
const pipe = new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
});
const meta: ArgumentMetadata = { type: 'query', metatype: DateRangeDto, data: '' };
it('applies both defaults when none provided', async () => {
const dto = await pipe.transform({}, meta);
expect(dto.start).toBeInstanceOf(Date);
expect(dto.end).toBeInstanceOf(Date);
expect(dto.end.getTime()).toBeGreaterThanOrEqual(dto.start.getTime());
});
it('defaults end when only start provided', async () => {
const dto = await pipe.transform({ start: '2025-10-01' }, meta);
expect(dto.end).toBeInstanceOf(Date);
});
it('throws when end provided without start', async () => {
await expect(pipe.transform({ end: '2025-10-30' }, meta)).rejects.toThrow();
});
});
```
---
## 6) Notes & tweaks
* **Normalization**: If you want *start of day* / *end of day* behavior, encode that in `defaultStart()` / `defaultEnd()` (e.g., set hours to `00:00:00.000` and `23:59:59.999`).
* **Max window**: If you also want “range must be ≤ N days”, add the earlier `@MaxRangeDays(N)` cross‑field validator to the end property.
* **DI in validators**: If any cross‑field check needs a service, enable `useContainer(app.select(AppModule), { fallbackOnErrors: true })` in `main.ts` and mark your constraint `@Injectable()`.
---
### TL;DR
Use a **DTO factory (mixin)** that sets defaults via `@Transform` and validates with small, reusable decorators. Endpoints pass **functions** to define `defaultStart()` and `defaultEnd()`. The rules you asked for are enforced, the logic is shared, and your existing `ValidationPipe` keeps doing the heavy lifting.
If you share one of your actual controllers/DTOs, I can plug this in and tailor the default window & tests to your codebase.
Great twist—this is a perfect fit for a **reusable, configurable date‑range DTO** that (a) applies smart defaults and (b) still enforces the rules you spelled out.
Below I show a drop‑in pattern that:
* Reuses the same date‑range logic across many endpoints
* Lets each endpoint **pass functions** to compute default `start`/`end`
* Handles your truth table:
* **no `start` & no `end`** → use `defaultStart()` and `defaultEnd()`
* **`start` provided, `end` missing** → default `end` to **today**
* **`end` provided, `start` missing** → **fail validation**
* **both provided** → validate normally
* Still keeps your **`ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })`** flow
* Plays nicely with tests (E2E and “unit‑ish” with `pipe.transform`)
---
## 0) What we’ll build
We’ll create:
1. Two tiny reusable validators:
* `@IsAfter('start', { allowEqual: true })` → checks `end >= start`
* `@IsValidDate()` → ensures it’s a `Date` and not `Invalid Date`
2. A **DTO factory (mixin)** you call with default functions:
```ts
const Last30DaysRangeDto = buildDateRangeQueryDto({
defaultStart: () => subDays(new Date(), 30),
defaultEnd: () => new Date(),
});
```
You then compose this DTO into your per‑route DTOs (or use it directly).
---
## 1) Reusable validators
```ts
// src/common/validation/is-valid-date.decorator.ts
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
export function IsValidDate(options?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'IsValidDate',
target: object.constructor,
propertyName,
options,
validator: {
validate(value: any) {
if (value == null) return true;
return value instanceof Date && !Number.isNaN(value.getTime());
},
defaultMessage(args: ValidationArguments) {
return `${args.property} must be a valid date`;
},
},
});
};
}
```
```ts
// src/common/validation/is-after.decorator.ts
import {
registerDecorator, ValidationOptions, ValidationArguments,
ValidatorConstraint, ValidatorConstraintInterface,
} from 'class-validator';
type Options = { allowEqual?: boolean };
@ValidatorConstraint({ name: 'IsAfter', async: false })
export class IsAfterConstraint implements ValidatorConstraintInterface {
validate(value: unknown, args: ValidationArguments) {
const [relatedProp, opts] = args.constraints as [string, Options?];
const other = (args.object as any)?.[relatedProp];
if (value == null || other == null) return true;
const right = new Date(value as any).getTime();
const left = new Date(other as any).getTime();
if (Number.isNaN(left) || Number.isNaN(right)) return false;
return opts?.allowEqual ? right >= left : right > left;
}
defaultMessage(args: ValidationArguments) {
const [relatedProp, opts] = args.constraints as [string, Options?];
return `${args.property} must be ${opts?.allowEqual ? 'on or after' : 'after'} ${relatedProp}`;
}
}
export function IsAfter(relatedProp: string, options?: Options & ValidationOptions) {
return (object: Object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [relatedProp, options],
validator: IsAfterConstraint,
});
};
}
```
---
## 2) The DTO factory with defaults
> **Why a factory/mixin?**
> It lets each endpoint **pass functions** for defaults (e.g., last 7 days vs last 30 days), keeps the logic DRY, and still works with Nest’s `ValidationPipe` transformation.
```ts
// src/common/dto/date-range.mixin.ts
import { Transform } from 'class-transformer';
import {
IsDate, IsDefined, IsOptional, ValidateIf,
} from 'class-validator';
import { IsAfter } from '../validation/is-after.decorator';
import { IsValidDate } from '../validation/is-valid-date.decorator';
export type DateRangeDefaults = {
/** Default start date when both start & end are absent */
defaultStart: () => Date;
/** Default end date when both are absent, or when only start is present */
defaultEnd?: (ctx: { start?: Date }) => Date; // defaults to () => new Date()
};
function toDate(value: any): Date | undefined {
if (value == null || value === '') return undefined;
if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? undefined : d;
}
/**
* Build a DTO class that:
* - Uses defaults when neither param is provided
* - Uses today as end when start is provided without end (via defaultEnd)
* - Fails if end is provided without start
* - Validates end >= start
*/
export function buildDateRangeQueryDto(defaults: DateRangeDefaults) {
const defaultEnd = defaults.defaultEnd ?? (() => new Date());
class DateRangeQueryDto {
// START
@Transform(({ value, obj }) => {
const start = toDate(value);
const endRaw = toDate(obj?.end);
// Case 1: both absent -> default start
if (!start && !endRaw) return defaults.defaultStart();
// Case 2: end present but start missing -> leave undefined to FAIL via @IsDefined
if (!start && endRaw) return undefined;
// Case 3: start present -> keep it
return start;
}, { toClassOnly: true })
@IsOptional() // it's optional unless end is present (see ValidateIf+IsDefined below)
@IsDate()
@IsValidDate()
@ValidateIf((o: DateRangeQueryDto) => o.end !== undefined)
@IsDefined({ message: 'start is required when end is provided' })
start?: Date;
// END
@Transform(({ value, obj }) => {
const end = toDate(value);
const startRaw = toDate(obj?.start);
// Case A: explicit end -> keep it
if (end) return end;
// Case B: start present and end missing -> default end (today by default)
if (startRaw) return defaultEnd({ start: startRaw });
// Case C: both missing -> default end alongside default start
return defaultEnd({ start: defaults.defaultStart() });
}, { toClassOnly: true })
@IsOptional()
@IsDate()
@IsValidDate()
@IsAfter('start', { allowEqual: true, message: 'end must be on or after start' })
end?: Date;
}
return DateRangeQueryDto;
}
```
> **Note on “today”:** `defaultEnd` defaults to `() => new Date()` (current moment). If you want *end of day*, you can pass `() => endOfDay(new Date())`. Likewise for *start of day* in `defaultStart`.
---
## 3) Using it in controllers (per‑route defaults)
Pick the default window you want per endpoint:
```ts
// src/orders/dto/orders.query.dto.ts
import { IntersectionType } from '@nestjs/mapped-types';
import { buildDateRangeQueryDto } from '../../common/dto/date-range.mixin';
// import { PaginationQueryDto } from '../../common/dto/pagination.query.dto'; // example
// Example: "last 30 days" through now
const Last30DaysRangeDto = buildDateRangeQueryDto({
defaultStart: () => {
const d = new Date();
d.setDate(d.getDate() - 30);
return d;
},
defaultEnd: () => new Date(), // or endOfDay(new Date())
});
export class OrdersQueryDto extends IntersectionType(
Last30DaysRangeDto,
// PaginationQueryDto,
) {}
```
Controller:
```ts
// src/orders/orders.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { OrdersQueryDto } from './dto/orders.query.dto';
@Controller('orders')
export class OrdersController {
@Get()
list(@Query() query: OrdersQueryDto) {
// query.start and query.end are Dates with defaults applied & validated
return { start: query.start, end: query.end };
}
}
```
You can create multiple “preset” DTOs:
```ts
const Last7DaysRangeDto = buildDateRangeQueryDto({
defaultStart: () => {
const d = new Date();
d.setDate(d.getDate() - 7);
return d;
},
defaultEnd: () => new Date(),
});
```
---
## 4) Behavior, spelled out
* **No `start`, no `end`**
→ `start = defaultStart()`, `end = defaultEnd({ start })`
* **`start` given, `end` missing**
→ `end = defaultEnd({ start })` (today by default)
* **`end` given, `start` missing**
→ `start` remains `undefined` → `@ValidateIf(o => o.end !== undefined) @IsDefined()`
→ **400 Bad Request** (“start is required when end is provided”)
* **Both given**
→ Validated as dates, plus `end >= start` via `@IsAfter('start', { allowEqual: true })`
---
## 5) Tests (quick examples)
### E2E (pipes run; defaults applied; failure on end‑only)
```ts
// test/orders.e2e-spec.ts
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('OrdersController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const modRef = await Test.createTestingModule({ imports: [AppModule] }).compile();
app = modRef.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
transformOptions: { enableImplicitConversion: true },
}),
);
await app.init();
});
afterAll(async () => app.close());
it('defaults both when absent', async () => {
const res = await request(app.getHttpServer()).get('/orders').expect(200);
// Expect ISO strings; assert they parse and that end is >= start.
expect(new Date(res.body.start).getTime()).toBeLessThanOrEqual(new Date(res.body.end).getTime());
});
it('defaults end to today when only start is provided', async () => {
const res = await request(app.getHttpServer())
.get('/orders?start=2025-10-01')
.expect(200);
expect(res.body.start).toBe('2025-10-01T00:00:00.000Z'); // if you normalize to startOfDay; otherwise just parse
expect(new Date(res.body.end).getTime()).toBeGreaterThanOrEqual(new Date('2025-10-01').getTime());
});
it('fails when end is provided without start', async () => {
const res = await request(app.getHttpServer())
.get('/orders?end=2025-10-30')
.expect(400);
expect(res.body.message).toEqual(expect.arrayContaining(['start is required when end is provided']));
});
});
```
### “Unit‑ish” (call the pipe manually)
```ts
// test/date-range.pipe.spec.ts
import { ValidationPipe, ArgumentMetadata } from '@nestjs/common';
import { buildDateRangeQueryDto } from '../src/common/dto/date-range.mixin';
describe('DateRange defaults via DTO transforms', () => {
const DateRangeDto = buildDateRangeQueryDto({
defaultStart: () => {
const d = new Date(); d.setDate(d.getDate() - 30); return d;
},
defaultEnd: () => new Date(),
});
const pipe = new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
});
const meta: ArgumentMetadata = { type: 'query', metatype: DateRangeDto, data: '' };
it('applies both defaults when none provided', async () => {
const dto = await pipe.transform({}, meta);
expect(dto.start).toBeInstanceOf(Date);
expect(dto.end).toBeInstanceOf(Date);
expect(dto.end.getTime()).toBeGreaterThanOrEqual(dto.start.getTime());
});
it('defaults end when only start provided', async () => {
const dto = await pipe.transform({ start: '2025-10-01' }, meta);
expect(dto.end).toBeInstanceOf(Date);
});
it('throws when end provided without start', async () => {
await expect(pipe.transform({ end: '2025-10-30' }, meta)).rejects.toThrow();
});
});
```
---
## 6) Notes & tweaks
* **Normalization**: If you want *start of day* / *end of day* behavior, encode that in `defaultStart()` / `defaultEnd()` (e.g., set hours to `00:00:00.000` and `23:59:59.999`).
* **Max window**: If you also want “range must be ≤ N days”, add the earlier `@MaxRangeDays(N)` cross‑field validator to the end property.
* **DI in validators**: If any cross‑field check needs a service, enable `useContainer(app.select(AppModule), { fallbackOnErrors: true })` in `main.ts` and mark your constraint `@Injectable()`.
---
### TL;DR
Use a **DTO factory (mixin)** that sets defaults via `@Transform` and validates with small, reusable decorators. Endpoints pass **functions** to define `defaultStart()` and `defaultEnd()`. The rules you asked for are enforced, the logic is shared, and your existing `ValidationPipe` keeps doing the heavy lifting.
If you share one of your actual controllers/DTOs, I can plug this in and tailor the default window & tests to your codebase.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment