import type {
ClassConstructor, FieldDecorator, JsonDeserializer, JsonSerializer,
} from './interfaces.js';
import {
addConstraint, fieldMetadata, propertyModel,
type EachValidationOptions, type PolymorphicInfo, type ValidationArguments,
type ValidationConstraint, type ValidationOptions, type ValidatorConstraintInterface,
} from './metadata.js';
export type {
ValidationArguments, ValidationOptions, EachValidationOptions,
ValidationConstraint, ValidatorConstraintInterface,
} from './metadata.js';
export type { PropertyAccess, PropertyModel, ClassModel } from './metadata.js';
export { defineRule } from './metadata.js';
/** A rule applied to the field itself. */
type One = FieldDecorator;
/** The same rule under `{ each: true }`, which moves it onto the elements of an array. */
type Each = FieldDecorator;
/**
* A rule that can be applied either directly to a field or, with `{ each: true }`, to the
* elements of an array field. The two overloads are what make `@IsString({ each: true })`
* demand a `string[]` while a bare `@IsString()` demands a `string`.
*/
interface Rule {
(options: EachValidationOptions): Each;
(options?: ValidationOptions): One;
}
function decorate(
build: (property: string) => ValidationConstraint,
options?: ValidationOptions
): FieldDecorator {
return ((_target: undefined, context: ClassFieldDecoratorContext) => {
const property = String(context.name);
addConstraint(fieldMetadata(context), property, build(property), options);
}) as FieldDecorator;
}
/** Declares a rule that takes no arguments of its own. */
function rule(
name: string,
check: (value: any, args: ValidationArguments) => boolean | Promise,
message: (property: string) => string
): Rule {
return ((options?: ValidationOptions) =>
decorate(property => ({ name, validate: check, message: message(property) }), options)) as Rule;
}
function pattern(name: string, regex: RegExp, message: (property: string) => string): Rule {
return rule(name, v => typeof v === 'string' && regex.test(v), message);
}
// ============================================================================
// Mapping
// ============================================================================
/**
* Maps this field to a different name in JSON, in both directions.
*
* ```ts
* class User {
* @JsonProperty('first_name')
* firstName!: string; // <-> {"first_name": "Ada"}
* }
* ```
*
* An explicit name always wins over the active naming strategy. Note that renaming stops the
* original name from reaching it: the old name is refused rather than copied onto the instance
* behind the rename's back. Add `@JsonAlias` to keep older clients working.
*/
export function JsonProperty(name: string): FieldDecorator {
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
propertyModel(fieldMetadata(context), String(context.name)).name = name;
}) as FieldDecorator;
}
/**
* Additional names accepted for this field when reading JSON.
*
* Aliases are input-only — output always uses the canonical name — which makes them the tool
* for accepting a renamed field from older clients without emitting it.
*/
export function JsonAlias(...names: string[]): FieldDecorator {
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
const model = propertyModel(fieldMetadata(context), String(context.name));
model.aliases = [...(model.aliases ?? []), ...names];
}) as FieldDecorator;
}
function access(value: 'none' | 'readonly' | 'writeonly'): FieldDecorator {
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
propertyModel(fieldMetadata(context), String(context.name)).access = value;
}) as FieldDecorator;
}
/** Excludes this field from mapping in both directions. */
export const JsonIgnore = (): FieldDecorator => access('none');
/**
* Serialized to JSON, but never populated from incoming JSON.
*
* For server-owned fields — ids, timestamps — that a client must not be able to set.
*/
export const JsonReadOnly = (): FieldDecorator => access('readonly');
/**
* Populated from incoming JSON, but never serialized back out.
*
* For secrets — passwords, tokens — that you accept but must never echo. Their values are
* also withheld from validation errors.
*/
export const JsonWriteOnly = (): FieldDecorator => access('writeonly');
/**
* Custom serializer for this field.
*
* The serializer's input type must match the field: a `JsonSerializer` can only
* be attached to a `Date` field. Skipped when the value is `null`/`undefined`.
*/
export function JsonSerialize(
serializer: ClassConstructor>
): FieldDecorator {
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
propertyModel(fieldMetadata(context), String(context.name)).serializer = serializer;
}) as FieldDecorator;
}
/**
* Custom deserializer for this field.
*
* The deserializer's output type must match the field: a `JsonDeserializer` can
* only be attached to a `Date` field.
*/
export function JsonDeserialize(
deserializer: ClassConstructor>
): FieldDecorator {
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
propertyModel(fieldMetadata(context), String(context.name)).deserializer = deserializer;
}) as FieldDecorator;
}
/**
* Declares the class a nested field maps to, so the mapper produces real instances.
*
* The referenced class must match the field's declared type — `@JsonType(() => Money)` on an
* `Address` field is a compile error. Applies element-wise to arrays.
*/
export function JsonType(
typeFunction: () => ClassConstructor
): FieldDecorator {
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
propertyModel(fieldMetadata(context), String(context.name)).type = typeFunction;
}) as FieldDecorator;
}
export interface PolymorphicOptions {
/**
* What to do when the discriminator value matches no registered subtype.
* - `keep` (default): pass the raw value through untouched.
* - `error`: throw a `JsonMappingError` naming the unknown discriminator value.
*/
onUnknown?: 'keep' | 'error';
/** Subtype to use when the discriminator matches nothing. Takes precedence over `onUnknown`. */
fallback?: ClassConstructor;
}
/**
* Selects the concrete class for a field from a discriminator property in the JSON.
*
* Name the base type explicitly to have the subtypes checked against it:
*
* ```ts
* @JsonPolymorphic('type', [
* { value: Book, name: 'book' },
* { value: Movie, name: 'movie' },
* ])
* items!: Media[];
* ```
*
* `NoInfer` keeps `Base` from being inferred from the first subtype — otherwise a list of
* `[Book, Movie]` would fix `Base` to `Book` and then reject `Movie`.
*/
export function JsonPolymorphic(
discriminator: string,
subTypes: { value: ClassConstructor>; name: string }[],
options?: PolymorphicOptions>
): FieldDecorator {
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
const info: PolymorphicInfo = {
discriminator,
subTypes: subTypes as PolymorphicInfo['subTypes'],
onUnknown: options?.onUnknown ?? 'keep',
...(options?.fallback ? { fallback: options.fallback as ClassConstructor } : {}),
};
propertyModel(fieldMetadata(context), String(context.name)).polymorphic = info;
}) as FieldDecorator;
}
// ============================================================================
// Control flow
// ============================================================================
/** Skips every other rule on this field when the value is `null` or `undefined`. */
export function IsOptional(): FieldDecorator {
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
propertyModel(fieldMetadata(context), String(context.name)).optional = true;
}) as FieldDecorator;
}
/**
* Skips every rule on this field when the condition returns false.
*
* ```ts
* class Payment {
* @IsIn(['card', 'invoice']) method!: string;
* @ValidateIf(p => p.method === 'card') @IsString() cardNumber?: string;
* }
* ```
*/
export function ValidateIf(condition: (object: This) => boolean): FieldDecorator {
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
propertyModel(fieldMetadata(context), String(context.name)).condition = condition as (o: any) => boolean;
}) as FieldDecorator;
}
/**
* Declares a field with no rules of its own.
*
* Useful with `unknownKeys: 'strip'` or `'error'`, where a field has to be declared to survive
* the payload even though nothing about its value needs checking.
*/
export function Allow(): FieldDecorator {
return ((_t: undefined, context: ClassFieldDecoratorContext) => {
propertyModel(fieldMetadata(context), String(context.name));
}) as FieldDecorator;
}
/**
* Recursively validates the value of this field.
*
* `{ each: true }` additionally asserts that the value really is an array.
*/
export function ValidateNested(options?: ValidationOptions): FieldDecorator