Quality & validation
dcupl provides a comprehensive quality and validation system to ensure data integrity, enforce business rules, and catch errors early. Configure validation rules at both the model and property level.
What is quality & validation?
Quality configuration enables:
- Required fields - Ensure critical data is present
- Nullable control - Allow or prevent
nullvalues - Type enforcement - Strict or loose data type checking
- Validators - Built-in email, min/max, pattern, enum, unique and more
- Error tracking - Collect and report validation errors
- Error summary - Error counts per model, attribute and error type
Enabling quality validation
Quality validation has two levels, the instance and the model. Both are enabled by default, and checks run only when both are enabled:
import { Dcupl } from '@dcupl/core';
import type { ModelDefinition } from '@dcupl/common';
const dcupl = new Dcupl({
quality: {
enabled: true, // Default: true. false turns off all quality checks
maxErrorsPerGroup: 1000, // Default: 1000. Stored error records per (model, attribute, error type)
},
});
const userModel: ModelDefinition = {
key: 'User',
quality: { enabled: true }, // Default: true. false skips all checks for this model
properties: [
// ... properties with validation rules
],
};The older errorTracking: { enabled } option is deprecated and can't turn checks off: checks run when either option is enabled, and quality.enabled defaults to true. Use quality: { enabled: false } instead.
Property quality configuration
Quality options
Instance options go into new Dcupl({ quality }):
| Option (default) | Effect |
|---|---|
enabled (true) |
false turns off all quality checks. Required by the TypeScript type when you pass quality |
maxErrorsPerGroup (1000) |
Stored error records per (model, attribute, error type), see Limiting Stored Errors |
Model options go into the model definition's quality:
| Option (default) | Effect |
|---|---|
enabled (true) |
false skips all checks for this model |
attributes |
Defaults for every attribute of the model: all attribute options but validators |
Attribute options go into a property's (or reference's) quality:
| Option (default) | Effect |
|---|---|
required (true) |
A missing value is reported as UndefinedValue |
nullable (false) |
A null value is reported as NullValue |
forceStrictDataType (false) |
No coercion: a value of another type is reported and dropped |
validators |
Validation rules, see Validators |
validatorHandling ('loose') |
A failed validator keeps the value ('loose') or drops it ('strict') |
What happens to the value when a check fails:
| Failure | Error and value on the item |
|---|---|
Value missing (undefined) and required is true |
UndefinedValue; the value is absent |
Value is null and nullable is false |
NullValue; the value is kept as null |
Value of another type and forceStrictDataType is true |
WrongDataType; the value is dropped |
| Value can't be coerced to the declared type | WrongDataType; the value is dropped |
Number coerced with a loss (5.5 on an int) |
WrongDataType; the coerced value is kept |
Validator fails and validatorHandling is 'loose' |
InvalidValidator; the value is kept |
Validator fails and validatorHandling is 'strict' |
InvalidValidator; the value is dropped |
Model-wide defaults
Attributes are required by default: a declared property or reference that is missing on an item is reported as UndefinedValue. Mark optional fields with required: false, or change the default for a whole model with quality.attributes. It accepts required, nullable, forceStrictDataType and validatorHandling; validators are set per attribute only.
const productModel: ModelDefinition = {
key: 'Product',
quality: {
attributes: { required: false }, // every attribute optional unless it says otherwise
},
properties: [
{ key: 'name', type: 'string', quality: { required: true } },
{ key: 'description', type: 'string' }, // optional
],
};Required fields
A required field without a value is reported as UndefinedValue:
const userModel: ModelDefinition = {
key: 'User',
properties: [
{ key: 'email', type: 'string', quality: { required: true } },
{ key: 'bio', type: 'string', quality: { required: false } }, // optional
],
data: [
{ key: 'u1', email: 'alice@example.com' }, // ✅ Valid
{ key: 'u2' }, // ❌ Missing required email
],
};
dcupl.models.set(userModel);
await dcupl.init();
const errors = dcupl.query.execute({
modelKey: '$DcuplErrorTrackingErrors',
queries: [{ attribute: 'model', operator: 'eq', value: 'User' }],
});
console.log(errors.map((e) => `${e.itemKey}.${e.attribute}: ${e.errorType.key}`));
// [ 'u2.email: UndefinedValue' ]Nullable fields
A null on a non-nullable field is reported as NullValue; the value itself is kept on the item.
const productModel: ModelDefinition = {
key: 'Product',
properties: [
{ key: 'name', type: 'string', quality: { nullable: false } }, // the default
{ key: 'description', type: 'string', quality: { required: false, nullable: true } },
],
data: [
{ key: 'p1', name: 'Laptop', description: null }, // ✅ Valid
{ key: 'p2', name: null }, // ❌ name cannot be null (NullValue)
],
};Strict data type checking
By default, values are coerced to the declared type ('5' becomes 5). Values that can't be coerced are dropped and reported as WrongDataType, and so are lossy coercions (5.5 becomes 5 on an int, the coerced value is kept). This happens without any strict setting. A string property accepts any value (42 becomes '42'), and a boolean property accepts the strings 'true'/'false'/'1'/'0' (any case) and the numbers 1/0; any other string, number, object or array is reported as WrongDataType and the value stays undefined. CSV cells follow the same rule, except that an empty cell is no value, not a WrongDataType.
forceStrictDataType: true turns coercion off: every value that isn't already of the declared type is reported as WrongDataType and dropped. date properties ignore the setting.
const orderModel: ModelDefinition = {
key: 'Order',
properties: [{ key: 'quantity', type: 'int', quality: { forceStrictDataType: true } }],
data: [
{ key: 'o1', quantity: 5 }, // ✅ Valid int
{ key: 'o2', quantity: '5' }, // ❌ String not allowed
{ key: 'o3', quantity: 5.5 }, // ❌ Float not allowed
],
};Validators run on string, int and float properties (and their Array<…> types), never on boolean, json or date properties. In SDK 2.0 they are also skipped on int and float properties that use forceStrictDataType: true, and a strict float rejects whole numbers such as 100. unique is the exception: it runs on every type. Don't combine strict mode with validators on numbers.
References
References are required by default too, so an item without a value for a reference is reported as UndefinedValue (error group ReferenceDataError). An absent or empty ('') value counts as missing. An explicit null is kept as null on the item (SDK 2.0; eq null matches it, typeof 'undefined' does not) and is reported as NullValue unless nullable is set, as for a property. Of the other attribute options, forceStrictDataType and validators have no effect on references.
| Situation | Error and reference on the item |
|---|---|
No value and required is true (the default) |
UndefinedValue; the reference is absent |
null and nullable is false (the default) |
NullValue; the reference is kept as null |
Key not in the referenced model, validity is 'loose' (default) |
RemoteReferenceKeyNotFound; kept as { key } |
Key not in the referenced model, validity is 'strict' |
RemoteReferenceKeyNotFound; the reference is dropped |
Wrong shape (an array on a singleValued reference) |
WrongDataType; the reference is absent |
validity is set on the reference itself ({ key: 'category', type: 'singleValued', model: 'Category', validity: 'strict' }). A referenced model with supportsAutoCreation creates the missing item instead of reporting it. See the reference options in the Models API.
Validators
A failed validator is reported as InvalidValidator. With the default validatorHandling: 'loose' the value is kept; with 'strict' it is dropped from the item. Validators take { value }; email and unique take {}. The available validators are email, min, max, minLength, maxLength, pattern, enum, startsWith, endsWith, includes and unique.
Email validator
Validate email format:
const contactModel: ModelDefinition = {
key: 'Contact',
properties: [
{
key: 'email',
type: 'string',
quality: {
required: true,
validators: {
email: {}, // Email format validation
},
},
},
],
data: [
{ key: 'c1', email: 'alice@example.com' }, // ✅ Valid
{ key: 'c2', email: 'invalid-email' }, // ❌ Invalid format
{ key: 'c3', email: 'bob@domain' }, // ❌ Invalid format
],
};
dcupl.models.set(contactModel);
await dcupl.init();
const errors = dcupl.query.execute({
modelKey: '$DcuplErrorTrackingErrors',
queries: [
{ attribute: 'model', operator: 'eq', value: 'Contact' },
{ attribute: 'errorType', operator: 'eq', value: 'InvalidValidator' },
],
});
console.log(errors.map((e) => e.description));
// [
// 'The value "invalid-email" is not a valid email address',
// 'The value "bob@domain" is not a valid email address'
// ]Min/max validators
Validate numeric ranges on int and float properties:
const productModel: ModelDefinition = {
key: 'Product',
properties: [
{
key: 'rating',
type: 'float',
quality: { validators: { min: { value: 1 }, max: { value: 5 } } },
},
],
data: [
{ key: 'p1', rating: 4.5 }, // ✅ Valid
{ key: 'p2', rating: 0 }, // ❌ 'The value "0" is smaller than the minimum value of "1"'
{ key: 'p3', rating: 6 }, // ❌ 'The value "6" is larger than the maximum value of "5"'
],
};Unique validator
Ensure unique values across all items of the model:
const userModel: ModelDefinition = {
key: 'User',
properties: [{ key: 'email', type: 'string', quality: { validators: { unique: {} } } }],
data: [
{ key: 'u1', email: 'alice@example.com' }, // ✅ Valid
{ key: 'u2', email: 'alice@example.com' }, // ❌ Duplicate email
],
};A duplicate is reported as InvalidValidator (title "Validator Error - Unique") on the second and later items with that value. In SDK 2.0, items without a value count as duplicates of each other too (The value "undefined" is not unique). NonUniqueKey is a different error: two items with the same key.
Common validations
The other validators work like min and max: one { value } each, and each failure is its own InvalidValidator record with the description shown. Several validators on one attribute go into the same validators object ({ email: {}, unique: {}, maxLength: { value: 254 } }) and are checked independently.
| Validator | Typical field | Configuration | Fails when (description) |
|---|---|---|---|
minLength/maxLength |
Username, file name | minLength: { value: 3 }, maxLength: { value: 20 } |
The value has fewer / more characters ("is shorter than the minimum length of") |
pattern |
SKU, order number | pattern: { value: '^[A-Z]{3}-[0-9]{4}$' } |
The value doesn't match the regular expression ("does not match the pattern") |
pattern |
Phone number (E.164), US ZIP | pattern: { value: '^\\+?[1-9]\\d{1,14}$' }, '^\\d{5}(-\\d{4})?$' |
As above; escape backslashes in the string |
enum |
Status, priority | enum: { value: ['pending', 'shipped', 'delivered'] } |
The value isn't one of the listed values, exact match ("is not in the enum values") |
startsWith/endsWith |
File name, report prefix | startsWith: { value: 'report_' }, endsWith: { value: '.pdf' } |
The value doesn't start / end with the text ("does not start with") |
includes |
Comma-separated tags | includes: { value: 'approved' } |
The value doesn't contain the text ("does not include") |
min/max |
Price, age, quantity | min: { value: 0 }, max: { value: 120 } |
See Min/max validators |
email, unique |
Email address, SKU | email: {}, unique: {} |
See Email validator and Unique validator |
Attributes are required by default, so required: true is only needed where the model sets quality: { attributes: { required: false } }.
Error handling
Getting errors
dcupl.quality.errors.summary() returns one entry per (model, attribute, error type) group with its count. The individual error records live in the built-in model $DcuplErrorTrackingErrors, so you query them like any other model. With quality: { enabled: false } these models are not created, and summary() lists only loader errors.
Using the User model from Required Fields:
await dcupl.init();
// One entry per (model, attribute, errorType) group
const summary = dcupl.quality.errors.summary();
console.log(summary);
// [
// {
// key: 'User::email::UndefinedValue',
// model: 'User',
// attribute: 'email',
// errorGroup: 'PropertyDataError',
// errorType: 'UndefinedValue',
// type: 'model',
// totalCount: 1,
// storedCount: 1,
// truncated: false
// }
// ]
// Individual error records for one model
const userErrors = dcupl.query.execute({
modelKey: '$DcuplErrorTrackingErrors',
queries: [{ attribute: 'model', operator: 'eq', value: 'User' }],
});
console.log(userErrors);
// [
// {
// key: '4YfyXvsPa2',
// title: 'Undefined Value',
// itemKey: 'u2',
// attribute: 'email',
// description: 'The required string property "email" in the "User" model is undefined (no value)',
// meta: { rawValue: undefined, declaredType: 'string', origin: 'inline' },
// groupTotal: 1,
// truncated: false,
// model: { key: 'User' },
// errorGroup: { key: 'PropertyDataError' },
// errorType: { key: 'UndefinedValue' }
// }
// ]In error records, model, errorGroup and errorType are references: filter them with a plain string (value: 'User') and read them as error.errorType.key. The record key is generated.
Error types
Model errors (type: 'model'):
UndefinedValue- A required field has no value on an itemNullValue- A non-nullable field isnullon an itemWrongDataType- A value can't be coerced to the declared type, or the coercion is lossy. Reported without any strict settingInvalidValidator- A validator failed (includingunique)UndefinedAttribute- A required attribute has no column or field at all in the loaded data (one record without anitemKey)UnknownColumn- A column in a loaded CSV file is not declared on the model; its values are dropped. Only reported for CSV resources, not for JSON or inline dataNonUniqueKey- Two rows loaded together (withdata.set(), a model'sdataor a loader resource) share the same key. The last row wins as a whole, at the first row's position; no values are merged from the replaced rows, and the replaced rows record no further errors. OneNonUniqueKeyis recorded per replaced row, also when the rows arrive in separate containers (data.set()afterinit(), model data plus a stagedupsert).upsertandupdaterows with the same key still merge without an errorMissingKey- An item has no key: nokey, a customkeyPropertywhose value isundefined/null, or a composite key with every part missing (a single missing part keeps its'undefined'/'null'part). The item is dropped. On a model with derived, grouped, resolved or query references, or derived or expression properties, a keyless row currently also makesinit(), or adcupl.update()that rebuilds (for example afterdata.set()), fail: it logs "Initialization failed" or "Update failed" and resolvesfalse. This also happens with quality checks disabled. Remove such rows before loadingRemoteReferenceKeyNotFound- A reference value points to a key that doesn't exist in the referenced modelMissingModel- A reference points to a model that isn't registeredMissingProperty- A derived property'sremotePropertydoesn't exist on the remote modelMissingReference- A derived, resolved or grouped attribute names a reference that doesn't exist (a derived property'slocalReference, a derived reference'slocalReferenceorremoteReference,resolve.reference,groupBy.reference)DuplicatePropertyKey- A model declares the same property key twiceUnknownExpressionVariable- An expression uses a variable that is neither a property nor a referenceInvalidModelDefinition- The model definition is invalid
Loader errors (type: 'loader', recorded even when quality checks are disabled):
InvalidResource- A resource could not be fetched or parsedTransformerError- A transformer threw while processing a resourceScriptingDisabled- Ascript,operatorortransformerresource was skipped because the instance runs withscripting: { enabled: false }InvalidConfig- Typed, but never recorded: a loader configuration that can't be fetched or parsed makesloader.config.fetch()throw insteadEndpointScriptMissing,EndpointDuplicate- Reported by REST API instances, not by the SDK: an endpoint'sscriptKeynames a script that isn't registered, or the same method and path is configured twice
Errors after updates
There is no public method to reset errors. Errors follow the data instead: when you fix the data and apply the update, the errors for those items are removed.
dcupl.data.update([{ key: 'u2', email: 'bob@example.com' }], { model: 'User' });
await dcupl.update();
console.log(dcupl.quality.errors.summary());
// []After dcupl.update(), partial or full, summary(), the $DcuplErrorTrackingErrors and $DcuplErrorTrackingSummary models, and lists on them hold the same records a fresh init() would produce for the current data: the changed rows are re-validated (unique included), the errors of removed rows are gone, and a value of the wrong type in data.update() is recorded as WrongDataType and dropped. The item then has no value for that attribute; its previous value is not kept. One exception: a keyless row in a data.upsert() that a partial update applies is currently dropped without a MissingKey record; init() and a full rebuild record it.
Quality reports
$DcuplErrorTrackingErrors and $DcuplErrorTrackingSummary are built the first time something reads them (query.one/many/execute, fn.* or a list on them) rather than during every init() and update(); they still describe the error set of the last init() or update(), and every read returns the same records. The first read pays for the build. Once a list over an error-tracking model exists (lists.create until it is destroyed; a query.execute or fn.* call on one keeps such a list internally), every init() and update() builds them again right away. Errors whose description does not depend on the row (UndefinedValue, NullValue, rows without a key) share one description string per (model, attribute, error type) group, which keeps the heap lower on error-heavy datasets. Measured on a 23,754-row customer catalog with about 344,000 recorded errors in 887 groups, default options: building them at the first read and sharing the descriptions took the retained heap after init() from 285 MB to 110 MB, and init() from 2763 ms to 2030 ms (2026-10-16).
Error summary
Each summary() entry describes one (model, attribute, error type) group:
| Field | Description |
|---|---|
key |
'Model::attribute::ErrorType' |
model, attribute |
Affected model and attribute (if any) |
errorGroup, errorType |
Error classification |
type |
'model' or 'loader' |
totalCount |
Number of occurrences, also beyond maxErrorsPerGroup |
storedCount |
Records kept in $DcuplErrorTrackingErrors (≤ maxErrorsPerGroup) |
truncated |
true when totalCount is greater than storedCount |
The same totals are available as the built-in model $DcuplErrorTrackingSummary.
Limiting stored errors
quality.maxErrorsPerGroup (default 1000) caps how many error records are stored per group. The totals keep counting:
const dcupl = new Dcupl({
quality: { enabled: true, maxErrorsPerGroup: 2 },
});
dcupl.models.set({
key: 'Product',
properties: [{ key: 'price', type: 'float', quality: { validators: { min: { value: 0 } } } }],
data: [1, 2, 3, 4, 5].map((i) => ({ key: `p${i}`, price: -i })),
});
await dcupl.init();
const [group] = dcupl.quality.errors.summary();
console.log(group.totalCount, group.storedCount, group.truncated);
// 5 2 trueThe stored records of a capped group carry truncated: true and the group's groupTotal.
Failed type coercions are the exception. They are consolidated into one WrongDataType record per attribute, without an itemKey, with the number of affected rows in meta.rowCount and one sample value in meta.rawValue. When more values of one attribute fail than maxErrorsPerGroup, the counts around that consolidation aren't exact in SDK 2.0 (for inline data the consolidated record can even be missing), so keep maxErrorsPerGroup above the number of failures you need to count.
Browsing errors in a list
Errors are a model, so lists, queries and facets work on them, for example to count errors by type:
dcupl.models.set({
key: 'User',
properties: [
{ key: 'email', type: 'string', quality: { validators: { email: {} } } },
{ key: 'age', type: 'int' },
],
data: [
{ key: 'u1', email: 'not-an-email', age: 'old' },
{ key: 'u2', age: 30 },
{ key: 'u3', email: 'also-wrong', age: 41 },
],
});
await dcupl.init();
const errorList = dcupl.lists.create({ modelKey: '$DcuplErrorTrackingErrors' });
const byType = errorList.catalog.fn.facets({ attribute: 'errorType' });
console.log(byType.map((facet) => `${facet.key}: ${facet.size}`));
// [ 'InvalidValidator: 2', 'UndefinedValue: 1', 'WrongDataType: 1' ]
errorList.catalog.query.addCondition({ attribute: 'errorType', operator: 'eq', value: 'InvalidValidator' });
console.log(errorList.catalog.query.items().map((e) => e.itemKey));
// [ 'u1', 'u3' ]In SDK 2.0 the errorType facet leaves out UnknownColumn, MissingKey, DuplicatePropertyKey, MissingProperty, MissingReference, UnknownExpressionVariable, ScriptingDisabled and TransformerError. Filtering by these types works; for complete counts use summary().
Data import validation
Check rows before you accept them, and report every problem at once:
import { Dcupl } from '@dcupl/core';
async function importProducts(rows: Record<string, unknown>[]) {
const dcupl = new Dcupl();
dcupl.models.set({
key: 'Product',
properties: [
{
key: 'sku',
type: 'string',
quality: {
validators: {
unique: {},
pattern: { value: '^[A-Z]{3}-[0-9]{4}$' },
},
},
},
{ key: 'price', type: 'float', quality: { validators: { min: { value: 0 } } } },
{ key: 'description', type: 'string', quality: { required: false } },
],
data: rows,
});
await dcupl.init();
const summary = dcupl.quality.errors.summary();
const records = dcupl.query.execute({
modelKey: '$DcuplErrorTrackingErrors',
queries: [{ attribute: 'model', operator: 'eq', value: 'Product' }],
});
return { success: summary.length === 0, summary, records };
}
const result = await importProducts([
{ key: 'p1', sku: 'LPT-0001', price: 999 },
{ key: 'p2', sku: 'LPT-0001', price: 899 }, // duplicate sku
{ key: 'p3', sku: 'mouse', price: -5 }, // wrong pattern, negative price
{ key: 'p4', price: 'free' }, // no sku, price not a number
]);
console.log(result.success); // false
console.log(result.summary.map((s) => `${s.attribute}: ${s.errorType} (${s.totalCount})`));
// [
// 'sku: InvalidValidator (2)',
// 'price: InvalidValidator (1)',
// 'sku: UndefinedValue (1)',
// 'price: WrongDataType (1)'
// ]
console.log(result.records.map((e) => `${e.itemKey ?? '-'}.${e.attribute}: ${e.description}`));
// [
// 'p3.sku: The value "mouse" does not match the pattern "^[A-Z]{3}-[0-9]{4}$"',
// 'p3.price: The value "-5" is smaller than the minimum value of "0"',
// 'p4.sku: The required string property "sku" in the "Product" model is undefined (no value)',
// '-.price: 1 value(s) in column "price" of model "Product" failed to coerce to the declared type "float" (e.g. "free")',
// 'p2.sku: The value "LPT-0001" is not unique'
// ]The failed coercion is one consolidated WrongDataType record per attribute, without an itemKey. These rows are inline data: the CSV-only checks (UnknownColumn, parsing of true / false cells) apply only to CSV files loaded through the loader.
Form validation integration
Validate a single form entry with a model that describes the form:
import { Dcupl } from '@dcupl/core';
type FieldError = { key: string; description: string };
async function validateUser(formData: Record<string, string>): Promise<FieldError[]> {
// A fresh instance per check: its error records are complete after init()
const dcupl = new Dcupl();
dcupl.models.set({
key: 'User',
properties: [{ key: 'email', type: 'string', quality: { validators: { email: {} } } }],
data: [{ key: 'form', ...formData }], // every item needs a key
});
await dcupl.init();
return dcupl.query.execute({
modelKey: '$DcuplErrorTrackingErrors',
queries: [{ attribute: 'model', operator: 'eq', value: 'User' }],
});
}Show the returned descriptions next to the field and submit only when the list is empty. Key the messages by the error record's key: one field can fail several validators. With a single item, a field that is missing altogether is reported as UndefinedAttribute (The Attribute "email" is not present in the dataset for model "User"), not as UndefinedValue, so map it to your own message.
Troubleshooting
Validation not running
Problem: No validation errors despite invalid data
Solution: Both levels are enabled by default, so check that neither new Dcupl({ quality: { enabled: false } }) nor a model's quality: { enabled: false } switches them off. Validators never run on boolean, json and date properties, and don't run on int and float properties that use forceStrictDataType: true (see Strict Data Type Checking).
Too many errors
Problem: Overwhelming number of validation errors
Solution: Look at the summary first: one entry per group instead of one record per item. Many UndefinedValue errors usually mean optional fields that aren't marked required: false (attributes are required by default). maxErrorsPerGroup limits how many records are stored.
console.table(dcupl.quality.errors.summary());
// Mark optional fields, or make all attributes of a model optional
{ key: 'bio', type: 'string', quality: { required: false } }
{ key: 'LogEntry', quality: { attributes: { required: false } }, properties: [...] }
// Validate critical fields first, add more validation gradually
{ key: 'email', quality: { required: true, validators: { email: {} } } }