Skip to content

Validation Reference ​

Use this page when you need the full validator API, lower-level options, or plugin hooks.

validate() Signature ​

typescript
validate<TT = DataType>(value: any, safe?: boolean, context?: unknown): value is TT

DataType is inferred from the Atscript model, so the validator narrows data automatically.

Validator Options ​

Pass options to .validator() or new Validator(type, opts):

typescript
const validator = Product.validator({
  partial: true,
  unknownProps: 'strip',
  errorLimit: 5,
})

partial ​

Controls whether missing required properties are errors:

  • false (default) — all required properties must be present
  • true — missing properties are allowed at the top level only
  • 'deep' — missing properties are allowed at all levels
  • (type, path) => boolean — custom logic per node

unknownProps ​

How to handle properties not defined in the type:

  • 'error' (default)
  • 'ignore'
  • 'strip' — deletes the unknown keys from the value, only when validate() passes

With 'strip', validation reads the value as you passed it. The keys are deleted after the whole value has passed, so a failed validation leaves the value unchanged. A rejected union branch strips nothing: only the keys unknown to the branch that matched are deleted. The same holds for a plugin that validates a sub-value with ctx.validateAnnotatedType(...): a call that returns false strips nothing, so a plugin trying alternatives (a || b) strips what the union a | b would.

The compiler merges every intersection of object types in .as into one object type, which knows every member's keys. A node of kind 'intersection' built at runtime (defineAnnotatedType('intersection'), fromJsonSchema with allOf) checks each member on its own, like JSON Schema allOf: a key only another member declares is unknown to a member ('error' reports it, 'strip' removes it).

errorLimit ​

Maximum number of errors to collect before stopping. Default: 10. validator.errors never holds more.

skipList ​

  • Type: Set<string>

Skip specific property paths. The set matches dot-separated paths (relative to each object) — entries like 'internalId' skip a top-level prop, 'audit.createdBy' skips a nested prop:

typescript
Product.validator({
  skipList: new Set(['internalId', 'audit.createdBy']),
})

replace ​

  • Type: (type: TAtscriptAnnotatedType, path: string) => TAtscriptAnnotatedType

Replace a type definition dynamically during validation. Useful when one field should validate against a different type per request — for example, swapping a generic unknown-shaped payload for a concrete schema chosen at runtime. The function is called once per encountered type (results are cached), receives the original TAtscriptAnnotatedType and the dot-separated path, and must return either the original type or a replacement annotated type:

typescript
Product.validator({
  replace: (type, path) => (path === 'status' ? customStatusType : type),
})

Built-In Validation Rules ​

Annotations from .as files are enforced automatically:

AnnotationApplies toValidates
@meta.requiredstring, booleanString: at least one non-whitespace character. Boolean: must be true; also a missing value
@expect.minLengthstring, arrayMinimum length
@expect.maxLengthstring, arrayMaximum length
@expect.minnumberMinimum value
@expect.maxnumberMaximum value
@expect.exclusiveMinnumberStrictly greater than the value (since 0.1.109)
@expect.exclusiveMaxnumberStrictly less than the value (since 0.1.109)
@expect.intnumberMust be integer
@expect.patternstringRegex match
@expect.minKeysobjectAt least this many keys (records, objects with optional props; see Key counts)
@expect.maxKeysobjectAt most this many keys
@expect.array.uniqueItemsarrayNo duplicate items
@expect.array.keyarray element propertiesMarks identity fields for uniqueness and patch operations
@expect.messageprop, typeCustom message for a value of the wrong type (see below)

Semantic types like string.email, string.required, and number.positive add validation behavior through their built-in annotation definitions.

Default error messages and the order in which constraints are checked are listed in the Validation Specification.

Every rule above also applies to a nullable field — the target type in a union with null / undefined (number | null, string[] | null, or an alias of such a union): null passes and any other value is checked. Constraints on a literal member of a union ('a' | 'b') are not checked — its value is fixed.

unknown accepts any value and checks nothing inside it. undefined is never a value, though: a required unknown field must be present (a missing one fails with Expected unknown, got undefined, code TYPE), and an unknown[] element can't be undefined. An optional unknown field may be omitted.

Decimal format

Values typed as decimal are stored as strings to preserve precision. The validator enforces the regex /^[+-]?\d+(\.\d+)?$/ — anything else (NaN, scientific notation, leading/trailing whitespace) is rejected with Invalid decimal format.

Non-finite numbers are rejected (since 0.1.100)

Every number (including number.int, number.timestamp, optional fields, union branches, array and tuple items) must be finite. NaN, Infinity and -Infinity fail with Expected finite number, got NaN (or Infinity / -Infinity), before @expect.min / @expect.max / @expect.int run. 0, -0 and fractions stay valid. The message is not overridable by annotation.

Upgrading: data that intentionally stored Infinity (for example an "unlimited" sentinel) now fails write validation — use null, an explicit bound, or a validator plugin that returns true for the value (plugins run before the type check). Validation of already-stored data is unaffected unless you validate it.

Zero bounds are always enforced (since 0.1.102)

A bound of 0 is a real bound: @expect.min 0 rejects -1, @expect.max 0 rejects 1, and @expect.maxLength 0 rejects 'a' and ['a']. Earlier versions ignored a 0 bound when it was set as a bare number at runtime — annotate('expect.min', 0) and other expect.minLength / maxLength / min / max values — and buildJsonSchema() dropped it (no minimum: 0, maxLength: 0, …). Bounds written in .as files were already enforced.

Upgrading: types that set a bare-number 0 bound through annotate() now reject values outside it, and their JSON Schema now includes the bound.

Nullable constraints and optional @meta.required (since 0.1.103)

  • Constraints on nullable fields are enforced. @expect.* and @meta.required on T | null now compile, and validate() checks them on non-null values. Before, such a field failed to compile (got "group"), and a type built without that check — at runtime, via fromJsonSchema(), or with diagnostics ignored — silently skipped the constraint. buildJsonSchema() puts the constraint on the matching anyOf member.
  • null on an optional @meta.required field is rejected. @meta.required name?: string accepts an omitted (or undefined) value but rejects null with the @meta.required message (Must not be empty / Must be checked by default). Partial validation keeps accepting an omitted field. Declare name?: string | null when null is a legal value.
  • Built-in primitive extensions keep their checks inside unions, arrays and tuples. number.int | null, string.email[] and [number.int, string] now validate expect.int / the email pattern on the member, as a plain number.int field always did.

Upgrading: clients that send null for a cleared optional @meta.required field (for example a form that maps an empty input to null) must omit the field or send undefined — or declare the field T | null. Values on nullable fields, union members and array elements that broke a constraint and used to pass now fail.

string.required and boolean.required are enforced on every field (since 0.1.104)

A field typed string.required or boolean.required — directly (name: string.required, agreed?: boolean.required) or through a type alias (type Name = string.required) — carries @meta.required again: '' / whitespace-only strings fail with Must not be empty, false fails with Must be checked, and buildJsonSchema() emits minLength: 1 (with pattern: "\\S", matching the whitespace check; booleans const: true). Versions 0.1.79–0.1.103 dropped the annotation from such fields, so they accepted any string or boolean; union members and array elements (string.required | null, string.required[]) were already checked. A field that references another field (ownerName: User.name) still does not inherit it.

Upgrading: payloads with an empty string or false in these fields now fail validation — fill them, or type the field string / boolean if empty is legal.

@meta.required covers a missing value (since 0.2.10)

A non-optional @meta.required field that is missing, or null where its type does not accept null, fails with code REQUIRED (REQUIRED_CHECKED for a boolean) and the annotation's message (a bare @meta.required uses the field's @expect.message, else the default; the same on an optional field holding null), the same error as an empty string or false. An empty string or false always reports the default message (Must not be empty, Must be checked) unless the annotation has its own text. A union-level annotation (@meta.required 'Pick one' on string | boolean) reports REQUIRED instead of UNION for a missing value. Before, such a field failed with TYPE (Expected string, got undefined, or @expect.message), and the @meta.required message applied only to an empty value.

What stays the same: fields without @meta.required keep TYPE; a value of the wrong type (5 for a string) keeps TYPE and its @expect.message; an optional field may still be omitted, and partial validation still accepts a missing field. Whether a value passes is unchanged.

Upgrading: for fields with @meta.required, the error code for a missing value changes from TYPE to REQUIRED (or REQUIRED_CHECKED), with empty params. Translation catalogs keyed by code render it with the REQUIRED entry; code that matched TYPE with params.actual === 'undefined' for these fields should match REQUIRED. An @expect.message repeated only to cover the missing case can go.

Array Uniqueness ​

@expect.array.uniqueItems and @expect.array.key work together:

atscript
interface Order {
    @expect.array.uniqueItems "Duplicate line items"
    items: OrderItem[]
}

interface OrderItem {
    @expect.array.key
    productId: number

    quantity: number
}
  • @expect.array.key identifies the fields that make an item unique
  • @expect.array.uniqueItems enforces uniqueness during validation
  • multiple key fields form a composite key

For primitive arrays like string[], uniqueness falls back to deep equality.

Key Counts ​

@expect.minKeys / @expect.maxKeys count the keys the validated object keeps, which depends on unknownProps:

unknownPropsCounted keys
'ignore'every own key
'strip' / 'error'the declared props and the keys pattern props match (any other key is removed with 'strip', an error of its own with 'error')

A failed count is reported (MIN_KEYS / MAX_KEYS) and the props are still checked, so their errors are reported too.

Error Handling ​

ValidatorError extends Error and includes structured details:

typescript
import { ValidatorError } from '@atscript/typescript/utils'

try {
  validator.validate(data)
} catch (e) {
  if (e instanceof ValidatorError) {
    for (const err of e.errors) {
      console.log(err.path) // 'address.city'
      console.log(err.code) // 'MIN_LENGTH'
      console.log(err.params) // { min: 3, actual: 1 }
      console.log(err.message) // 'Expected minimum length of 3 characters, got 1 characters'
    }
  }
}

After safe validation, errors are available on validator.errors. Each error (TError) has:

FieldMeaning
pathDot path of the value ('' for the root)
messageThe default English message, or the app's own message from an annotation
codeStable UPPER_SNAKE code (see Error Codes); CUSTOM for a plugin error reported without a code
paramsThe values the message needs: limits, expected type, pattern, allowed values (JSON-safe; input copied into them is cut at 100 characters)
customtrue when message is app text (an annotation's message argument, @expect.message, a plugin's own message)
detailsThe errors of each branch, for a union error

Error Codes ​

The validator is locale-agnostic: it reports codes and params, and its default messages are English. Translate errors after validation with localizeErrors(), or per validator with the translate option. The compiled checks are not affected (they only decide pass / fail; errors always come from the same walk).

Every default message is its code's template from VALIDATOR_MESSAGES, with {name} replaced by params.name:

CodeDefault templateParams
REQUIREDMust not be empty
REQUIRED_CHECKEDMust be checked
TYPEExpected {expected}, got {actual}expected, actual (type names)
TYPE_ARRAYExpected arrayactual
TYPE_OBJECTExpected objectactual
TYPE_DECIMALExpected string (decimal), got {actual}actual
NOT_FINITEExpected finite number, got {actual}actual ('NaN', 'Infinity', …)
DECIMAL_FORMATInvalid decimal format: "{value}"value
NEVERThis type is impossible, must be an internal problem
LITERALExpected {expected}, got {actual}expected (the literal), actual
UNIONValue does not match any of the allowed types: {expected}expected
UNION_DISCRIMINATORExpected one of {allowed}, got {actual}property, allowed (array), actual
UNEXPECTED_PROPERTYUnexpected propertyproperty
TUPLE_LENGTHExpected array of length {length}length
MIN_LENGTHExpected minimum length of {min} characters, got {actual} charactersmin, actual
MAX_LENGTHExpected maximum length of {max} characters, got {actual} charactersmax, actual
MIN_ITEMSExpected minimum length of {min} items, got {actual} itemsmin, actual
MAX_ITEMSExpected maximum length of {max} items, got {actual} itemsmax, actual
UNIQUE_ITEMSDuplicate items are not allowed(path: the duplicate's index)
MIN_KEYSExpected at least {min} properties, got {actual}min, actual
MAX_KEYSExpected at most {max} properties, got {actual}max, actual
PATTERNValue is expected to match pattern "{pattern}"pattern, flags (when set)
INTExpected integer, got {actual}actual
MIN / MAXExpected minimum {min}, got {actual} / Expected maximum {max}, …min / max, actual
EXCLUSIVE_MINExpected greater than {min}, got {actual}min, actual
EXCLUSIVE_MAXExpected less than {max}, got {actual}max, actual
EMAIL, PHONE, DATE, ISO_DATE, UUID, URL, IPV4, IPV6, IPthe string.* primitive's format message (Invalid email format., …)

Import the table and the code names from @atscript/typescript/utils: VALIDATOR_MESSAGES (code → English template), VALIDATOR_ERROR_CODES (code → code) and the TValidatorErrorCode type. Serve the table per locale (translated templates keyed by code) to let clients localise without their own catalog.

Translating errors ​

localizeErrors(errors, translate) returns new errors with translated messages (details included). translate(id, params, error) gets:

  • the code for a default message ('MIN_LENGTH'),
  • the message text for app text (custom: true, or an error without a code) — the text is the message id (gettext style),

and returns the translated message, or undefined to keep the current one.

typescript
import { localizeErrors, messagesTranslator } from '@atscript/typescript/utils'

const de = {
  'MIN_LENGTH': 'Mindestens {min} Zeichen',
  'Choose a valid status': 'Bitte einen gültigen Status wählen',
}

if (!validator.validate(data, true)) {
  const errors = localizeErrors(validator.errors, messagesTranslator(de))
}

messagesTranslator(map) looks the id up in a map of templates and fills {name} placeholders from the params (formatValidatorMessage(template, params) does the filling on its own). Bring your own i18n library with a function instead:

typescript
localizeErrors(errors, (id, params) => (i18n.exists(id) ? i18n.t(id, params) : undefined))

To get translated errors from one validator directly, pass translate:

typescript
const validator = Product.validator({ translate: messagesTranslator(de) })
validator.validate(data) // the thrown ValidatorError and validator.errors carry translated messages
  • DO translate per request with localizeErrors() and keep one validator per type: a translate option per locale means one validator per locale.
  • DON'T match on message. It changes with custom messages and translation; branch on code and read params.

Custom message for a wrong type ​

Constraint annotations take a message argument (@expect.minLength 3, "Too short", @expect.int "Whole numbers only"). For a value of the wrong type, use @expect.message on the field or the type:

atscript
export interface Account {
    @expect.message "Choose a valid status"
    status: 'active' | 'blocked'

    @expect.message "Age must be a number"
    age?: number.int
}

It replaces the message of TYPE, TYPE_ARRAY, TYPE_OBJECT, TYPE_DECIMAL, NOT_FINITE, DECIMAL_FORMAT, LITERAL, UNION, UNION_DISCRIMINATOR and TUPLE_LENGTH errors at that node. The error keeps its code and params with custom: true. Constraint failures keep their own messages. On a @meta.required field, a missing value reports REQUIRED with the @meta.required message (the @expect.message text only when @meta.required has none).

Discriminated unions ​

When every object branch of a union has a required literal-typed property of the same name with a distinct value ({ kind: 'cat', … } | { kind: 'dog', … }, optionally | null), an object that fails the union is reported through its discriminator:

  • the discriminator picks a branch → that branch's errors, at their own paths (pet.lives: Expected maximum 9, got 10), instead of one union error with every branch's errors;
  • the discriminator matches no branch (or is missing) → one UNION_DISCRIMINATOR error at the discriminator (pet.kind), params.allowed listing the values.

With partial validation at the union's depth (partial: 'deep', or true at the top), an object without the discriminator gets the UNION error, with every branch's errors in details. Other unions (no shared literal property, two such properties, an optional discriminator in a branch, a branch that may hold any object) report the UNION error with details as before. The same detection drives the JSON Schema discriminator.

Plugins ​

Plugins can intercept validation and either accept, reject, or fall through:

typescript
import type { TValidatorPlugin } from '@atscript/typescript/utils'

const skipSensitive: TValidatorPlugin = (ctx, def) => {
  if (def.metadata.get('meta.sensitive')) {
    return true
  }
  return undefined
}

const validator = Product.validator({ plugins: [skipSensitive] })

External Context ​

Plugins can receive context through the third argument to validate():

typescript
const roleAware: TValidatorPlugin = ctx => {
  const { context } = ctx
  if (context && (context as { role: string }).role === 'admin') {
    return true
  }
  return undefined
}

Product.validator({ plugins: [roleAware] }).validate(data, true, { role: 'admin' })

The plugin context exposes opts, validateAnnotatedType, error, path, root, and context. ctx.validateAnnotatedType(def, value) validates a sub-value at the current path. With unknownProps: 'strip', a call that returns false drops the keys it recorded, as a rejected union branch does; a call that passes keeps them, and they are deleted once the whole value passes.

The Root Type ​

ctx.root (since 0.1.105) is the type the validator was created for — the same for every node of the walk, as passed to the constructor (before replace). Use it when a rule depends on where the visited node sits in the whole type, together with ctx.path:

typescript
// A custom repeatable annotation (`multiple: true`, `mergeStrategy: 'append'`) on the interface —
// `@app.serverFilled 'createdBy'` `@app.serverFilled 'audit.editor'` — lists the paths
// that may be left out because the server fills them in
const serverFilled: TValidatorPlugin = (ctx, def, value) => {
  const paths = ctx.root.metadata.get('app.serverFilled')
  if (value === undefined && paths?.includes(ctx.path)) {
    return true
  }
  return undefined
}

Reporting Custom Errors from a Plugin ​

Plugins may push their own structured errors into the active validator via ctx.error(message, path?, details?, info?):

ArgumentTypeNotes
messagestringRequired. Becomes the message of a TError entry.
pathstring?Optional. Defaults to the current dot-separated path being validated.
detailsTError[]?Optional. Nested error breakdown — useful when an alternative-tested branch fails.
infoTErrorInfo?Optional { code, params, custom }. Without it the error has code CUSTOM and custom: true (its message is the translation id)
typescript
const requirePositiveAmount: TValidatorPlugin = (ctx, def, value) => {
  if (def.metadata.get('meta.label') === 'Amount' && typeof value === 'number' && value <= 0) {
    ctx.error('Amount must be positive', ctx.path, [{ path: ctx.path, message: `Got ${value}` }])
    return false
  }
  return undefined
}

Give the error a code so apps can translate it by code:

typescript
ctx.error(`Amount must be positive`, undefined, undefined, {
  code: 'AMOUNT_NOT_POSITIVE',
  params: { actual: value },
})

Manual Validator Construction ​

For deserialized or programmatically built types:

typescript
import { Validator, deserializeAnnotatedType } from '@atscript/typescript/utils'

const type = deserializeAnnotatedType(jsonData)
const validator = new Validator(type)

validator.validate(someValue)

Input Coercion: coerceForType() ​

typescript
import { coerceForType } from '@atscript/typescript/utils'

coerceForType(def: TAtscriptAnnotatedType, value: unknown): unknown

Converts string-transport input (route params, query strings) toward the scalar shapes an annotated type expects, so a numeric .as alias can accept "42" from a URL. Pure and non-throwing — when a value can't be coerced it is returned untouched for the validator to report:

typescript
import { coerceForType } from '@atscript/typescript/utils'

import { KafkaOffset } from './types.as' // @expect.int @expect.min 0 → number

coerceForType(KafkaOffset, '42') // → 42
coerceForType(KafkaOffset, 'abc') // → 'abc' (validate afterwards for the proper error)

Coercion rules:

  • number — trimmed, non-empty strings parsed with Number(); only finite results accepted

  • boolean — "true"/"1" → true, "false"/"0" → false

  • unions — one member that can take the value is chosen, and the value is coerced against that member only (linear in the input, no backtracking). A scalar member takes what it parses (a literal member only its literal). An object member takes a plain object, and a tuple member an array of its length, unless a literal property or item (kind: 'cat', also kind: 'cat' | null or an aliased union of literals) holds a value that does not coerce to that literal; a string is compared after the normalizers of the literal and of the unions and aliases it is in (' flag ' takes @normalize.trim kind: 'flag'). A string coerces to a numeric literal it parses to ("2" to 2), and in hand-built or deserialized types, which can hold boolean literals, to a boolean literal ("1" to true). A scalar takes the first member that takes it. An object or array takes the only member that takes it; when several do, it takes the one it fits best, judged from its own keys and items only, by three checks in order of weight:

    1. its properties or items hold values of their kind: a string that parses for a number property, null only where null is allowed. This weighs most, because a value of the wrong kind never passes the validator;
    2. it has no key the member does not declare (unknownProps: 'strip' or 'ignore' lets such keys pass);
    3. it has every required property of the member (partial lets them be missing).

    A member that passes check 1 beats any that fails it, whatever checks 2 and 3 give: { a: '5' } takes { a: number; b: string } over an earlier { a: boolean }. By check 2, { y: 'true' } takes { y: boolean } over an earlier { kind: 'a', x: number }, and a patch { good: 'true' } takes { kind: 'dog'; good: boolean } over an earlier { kind: 'cat'; lives: number }. A patch that both members fit equally ({ from: '1' } with no kind) takes the first one. A union nested in the union counts as its members, so (A | B) | C and A | B | C pick alike. Among equally good members, the one an object's discriminator names by the literal itself wins, then declared order. So { kind: 'range', from: '1' } takes the range member. With { v: 1; n: number } | { v: '1'; n: boolean }, { v: '1', n: '1' } takes the '1' member, while { v: '1', n: '3' } takes the 1 member and becomes { v: 1, n: 3 }. When no member takes the value, it is returned untouched for the validator to report

  • objects — recurses into props when the input is a plain object (returns a new object, never mutates the input)

  • arrays / tuples — items coerced individually

  • string / decimal — string input is never changed

Coercion converts representation only — constraint checks (@expect.int, @expect.min, patterns) remain the validator's job, so the intended flow is always coerceForType → validate. In Moost apps, prefer the ready-made Coercion Pipe over calling this directly.

Input Normalization: normalizeForType() ​

typescript
import { normalizeForType } from '@atscript/typescript/utils'

normalizeForType<T>(def: TAtscriptAnnotatedType, value: T, opts?: Partial<TValidatorOptions>): T

Applies the type's @normalize.* annotations (trim, lowercase, uppercase). The validator never normalizes, so call it on input before validating it:

typescript
import { normalizeForType } from '@atscript/typescript/utils'

import { Signup } from './signup.as'

const input = normalizeForType(Signup, body) // { email: '  Ann@Example.COM ' } → { email: 'ann@example.com' }
Signup.validator().validate(input)

With coercion as well, the order is coerceForType → normalizeForType → validate. In Moost apps the validation pipe does it for you.

Rules:

  • pure — returns a normalized copy and never mutates the input; when nothing changes it returns the input itself, and unchanged nested objects are shared
  • strings only — trim, then lowercase, then uppercase; other values, and strings without normalizers, are returned as they are; unknown properties are kept
  • walks object properties (pattern properties too), arrays, tuples, intersections and unions; normalizers on a union apply to its string members (@normalize.trim on nickname?: string | null)
  • unions — a value is normalized only when the result does not depend on the member that takes it. A union of objects with a discriminator uses the member the discriminator names. Otherwise every member that can take the value must give the same result (a literal member takes a string only when the normalized string is its literal); if they differ, the members whose own validator accepts their result decide, and their result is used when they agree; otherwise the value is returned untouched for the validator to judge. So contact: Email | Digits normalizes ' A@B.CO ' to 'a@b.co' (only the Email result is valid), while two members whose different results are both valid leave the value as it is. Give a union of objects a discriminator when its members normalize a field differently
  • opts — the validator options the members are judged with: pass the options you will validate the result with, so a member is accepted exactly when the validator will accept it (normalizeForType(Patch, body, { partial: true }) for a partial update, where a member missing required properties is still valid). Without opts, the default options: every required property present, no unknown ones. unknownProps: 'strip' judges as 'ignore' (nothing is stripped from the input). Members are judged at the union's position, as in the validator: partial: true applies only to a union that is the value's own type, and a partial callback, replace, skipList and plugins see paths from the union
  • non-plain objects (Date, Buffer, class instances) are returned as they are; an object whose literal property (kind: 'cat') holds another value is left as it is
  • never validates — run the validator on the result. It does not throw on ordinary input; a cyclic value overflows the stack, as it does in the validator
  • cost — a type without normalizers returns the input at once; otherwise each object of the value is normalized once per type, recursive unions included

typeHasNormalizers(def) (same import) answers whether normalizeForType can change a value of def at all — @normalize.* on def or on any type it reaches. It is cached and stays current across runtime annotate() writes; use it to skip your own walks (a field-by-field patch, say) when there is nothing to normalize.

Normalizers are kept by serializeAnnotatedType(), so a client can normalize with a deserialized type too.

Next Steps ​

Released under the MIT License.