fluentv
v1.0.0
Published
Selector-based TypeScript validation backed by fluentvalidation-ts
Downloads
319
Maintainers
Readme
fluentv
Selector-based validation API for TypeScript with zero runtime dependencies. The fluentvalidation-ts 5.0.0 engine is stored as a local snapshot and shipped inside the package. Built-in rules, messages, conditions, custom predicates, and child validators are executed by that engine; the fluentv code handles selectors, nested paths, and error assembly.
This is an early, tested foundation, not a claim that this new library is mature. Upstream: https://github.com/AlexJPotter/fluentvalidation-ts (Apache-2.0).
Breaking change in 1.0.0
0.1.x returned the nested error tree directly from validate():
// fluentv 0.1.x
const errors = validator.validate(model);
// { name: 'Value cannot be empty', address: { city: 'City is required' } }1.0.0 changes the shape of that result and adds stable error codes:
| 0.1.x | 1.0.0 |
| --- | --- |
| validate() → nested tree | validate() → flat ValidationResult |
| validateAsync() → nested tree | validateAsync() → flat ValidationResult |
| — | validateTree() / validateTreeAsync() → nested tree (old behavior) |
| — | validateAndThrow() / validateAndThrowAsync() → throws ValidationException |
| — | isValid, hasErrors, ValidationResult, ValidationException, ErrorTree |
Migration is mechanical:
// Keep the old nested output:
const tree = validator.validateTree(model);
// Or adopt the flat result with stable codes:
const result = validator.validate(model);
if (!result.isValid) {
result.errors; // [{ propertyName: 'email', errorCode: 'notEmpty', errorMessage: '...' }]
result.toDictionary(); // { email: ['...'] }
}The flat errorCode values are stable across message changes and translations, so API clients and tests no longer have to depend on human-readable text.
Example
import { Validator } from 'fluentv';
type User = {
name: string;
age: number;
password: string;
confirmation: string;
address?: { city: string } | null;
};
class UserValidator extends Validator<User> {
constructor() {
super();
this.ruleFor(u => u.name).notEmpty().maxLength(100);
this.ruleFor(u => u.age).greaterThanOrEqualTo(18);
this.ruleFor(u => u.address?.city)
.notUndefined()
.notEmpty()
.withMessage('City is required');
this.ruleFor(u => u.confirmation)
.must((value, model) => value === model.password)
.withMessage('Passwords do not match');
}
}
const validator = new UserValidator();
const result = validator.validate({
name: '', age: 20, password: 'secret', confirmation: 'secret',
address: { city: '' },
});
result.isValid; // false
result.errors;
// [
// { propertyName: 'name', errorCode: 'notEmpty', errorMessage: 'Value cannot be empty' },
// { propertyName: 'address.city', errorCode: 'notEmpty', errorMessage: 'City is required' },
// ]
result.toDictionary(); // { name: ['Value cannot be empty'], 'address.city': ['City is required'] }
// The nested shape is still available separately:
validator.validateTree({ name: '', age: 20, password: 'secret', confirmation: 'secret', address: { city: '' } });
// { name: 'Value cannot be empty', address: { city: 'City is required' } }ruleFor(u => u.name) infers string, so numeric rules cannot be used on it. The original model is passed to must, when, unless, and the setValidator factory. Child validators may be a Validator from this package or any other implementation exposing validateTree / validate in the same shape.
Async
import { AsyncValidator } from 'fluentv';
const validator = new AsyncValidator<{ email: string }>();
validator.ruleFor(u => u.email)
.emailAddress()
.mustAsync(async email => email !== '[email protected]')
.withMessage('Email already in use');
const result = await validator.validateAsync({ email: '[email protected]' });
// result.errors === [{ propertyName: 'email', errorCode: 'mustAsync', errorMessage: 'Email already in use' }]Collections
const validator = new Validator<{
tags: readonly string[];
data?: { scores: number[] };
}>();
validator.ruleForEach(m => m.tags).notEmpty().maxLength(20);
validator.ruleForEach(m => m.data?.scores).greaterThanOrEqualTo(0);
validator.validateTree({ tags: ['ok', ''], data: { scores: [10, -1] } });
// {
// tags: [null, 'Value cannot be empty'],
// data: { scores: [null, 'Value must be greater than or equal to 0'] }
// }
validator.validate({ tags: ['ok', ''], data: { scores: [10, -1] } }).errors;
// [
// { propertyName: 'tags[1]', errorCode: 'notEmpty', errorMessage: 'Value cannot be empty' },
// { propertyName: 'data.scores[1]', errorCode: 'greaterThanOrEqualTo', errorMessage: '...', placeholders: { value: 0 } }
// ]Rule types follow the element type, including readonly and nullable arrays. Empty/null/undefined collections are skipped; use ruleFor(...).notUndefined() / .notNull() to make a collection required. Non-array values are rejected at runtime. ruleForEach keeps element positions: valid elements produce null, and a collection with no errors is omitted.
setValidator supports object elements. AsyncValidator.ruleForEach also supports mustAsync and setAsyncValidator. Conditions are checked per element, and callbacks receive the original parent model. Elements are processed in order; several validateAsync calls may run concurrently.
Validation results
validate() (and validateAsync()) return a flat ValidationResult:
interface ValidationFailure {
readonly propertyName: string; // "email", "address.city", "items[0].name", "$" for an object-level error
readonly errorMessage: string;
readonly errorCode: string; // stable code: "notEmpty", "maxLength", "must", "setValidator", ...
readonly placeholders?: Readonly<Record<string, unknown>>; // e.g. { max: 100 }
}
class ValidationResult {
readonly errors: readonly ValidationFailure[];
get isValid(): boolean;
toDictionary(): Record<string, string[]>; // property -> messages
}errorCodeis the rule name and stays stable even when the message is replaced viawithMessageor translated.placeholdersis populated for parameterized rules (maxLength,minLength,length, numeric comparisons,precisionScale,equal/notEqual,matches).- Rules run through the upstream engine. By default
cascade = 'continue', so every failing rule is reported (for examplenotEmpty().minLength(5)on an empty string yields two failures). AValidatorsubclass can setthis.cascade = 'stop'to stop at the first failing rule per chain. - Quick checks:
isValid(resultOrTree)andhasErrors(resultOrTree). For aValidationResult, use theresult.isValidgetter. validateAndThrow(model)/validateAndThrowAsync(model)throw aValidationExceptioncarrying.errorswhen invalid.validateTree(model)/validateTreeAsync(model)still return the nestedValidationErrors<T>shape (leaves are strings,nullfor valid collection elements, arrays for paths that cross arrays).
const result = validator.validate(input);
if (!result.isValid) throw new ValidationException(result.errors);
const byField = result.toDictionary(); // { email: ['Email is required'] }
const codes = result.errors.map(e => e.errorCode);ErrorTree is exported as a general type for the nested structure above; ValidationFailure, ValidationResult, and ValidationException are available as values too.
Selector and error contract
- A selector runs once when the rule is registered, against a Proxy. During validation, values are read from the recorded path. Use a pure selector with a single property access:
u => u.name,u => u.address.city, oru => u.address?.city. - Method calls, computations, the root object, constants, and reads of several paths are rejected. The Proxy is not a JavaScript parser: conditions that read only one path or depend on external variables cannot always be detected. Do not use branching, side effects, or changing keys in a selector.
- A
null/undefinedparent yields the valueundefined. Upstream string/numeric rules generally skip nullish values; addnotUndefined()ornotNull()if the field is required. This still applies when the field type uses a non-null assertion (!). validateTreefollows the upstreamValidationErrors<T>shape: nested objects; paths that cross arrays produce error arrays; numeric keys on an object stay objects. An index selector checks only that index; positions without errors are empty array slots.validate()flattens this into aValidationFailurelist with dotted/bracketedpropertyName.- The first failing message in a chain is used by
validateTree, and byvalidate()withcascade = 'stop'. If several registrations for the same path fail, the last error replaces the previous one, following upstream. Replacement applies to the whole result at that path, including collections. Errors from different paths are merged; anullplaceholder does not erase sibling errors. A parent message wins over a child message when both fail. - Symbol keys and the
__proto__,prototype, andconstructorsegments are rejected. - The API provides
ruleFor,ruleForEach,validate,validateTree,validateAndThrow,validateAsync,validateTreeAsync, andvalidateAndThrowAsync. Explicit transforms are not available yet. - Property getters on the model are still executed when the path is read. Input objects must match the type; this library is not a schema parser for
unknowndata.
Development
Node.js 22.15+ is required for the process-isolation-free test runner.
npm install
npm run checkcheck covers type tests (including usages that must be rejected by the compiler), runtime tests, comparison of flat/collection results against upstream, the TypeScript declaration build, and installation of the tarball into a separate consumer project. The package test installs fluentv's own tarball offline into a temporary directory and asserts that fluentvalidation-ts is not installed. The package is prepared as [email protected] with public access, but has not been published. Development dependencies are only needed for building and testing.
Production usage
- The package currently ships ESM and TypeScript declarations. The build includes source/declaration maps;
npm packruns the build automatically. - Define all rules before a validator is used, then reuse the instance. Do not change rules while validation is running, especially on an async validator.
- The root input must be an object. Input coming from HTTP/JSON still has to be confirmed to match the model type before validation; TypeScript type assertions do not validate runtime data.
- Validation failures are returned as a
ValidationResult(orValidationErrorsviavalidateTree);validateAndThrowthrows aValidationException. Exceptions from a custom predicate, async service, or getter are still thrown; handle them at the application boundary. Timeout/cancellation for external services is the callback's responsibility. - Selectors are for property access only, not computation. The Proxy detection limitations above still apply.
- Child validator results are copied before merging. The validator does not mutate the input model, but callbacks/getters written by the application can.
Local testing covers the package artifact and error-merging regressions. There is no load or long-running production proof yet; run integration tests with your application models and callbacks before rollout.
Distribution and npm release
npm install fluentvThe registry install command above applies once the package is published. To install a local build first:
npm run check
npm pack
npm install /path/to/fluentv-1.0.0.tgzThe upstream runtime snapshot, type declarations, the Apache-2.0 license, and the NOTICE attribution are included in the tarball. The build verifies the snapshot SHA-256 against src/vendor/fluentvalidation-ts/provenance.json. The engine code comes from the official v5.0.0 npm artifact and is stored unchanged; the original upstream TypeScript source is not included. Engine updates are done explicitly and re-tested through parity/regression tests.
To publish the package, use an npm account entitled to the fluentv name:
npm login
npm publish --access publicThe registry name has not been verified as available. publishConfig sets public access. The package has never been published from this workspace.
