Validation Reference
Use this page when you need the full validator API, lower-level options, or plugin hooks.
validate() Signature
validate<TT = DataType>(value: any, safe?: boolean, context?: unknown): value is TTDataType is inferred from the Atscript model, so the validator narrows data automatically.
Validator Options
Pass options to .validator() or new Validator(type, opts):
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 presenttrue— 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 whenvalidate()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:
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:
Product.validator({
replace: (type, path) => (path === 'status' ? customStatusType : type),
})Built-In Validation Rules
Annotations from .as files are enforced automatically:
| Annotation | Applies to | Validates |
|---|---|---|
@meta.required | string, boolean | String: at least one non-whitespace character. Boolean: must be true; also a missing value |
@expect.minLength | string, array | Minimum length |
@expect.maxLength | string, array | Maximum length |
@expect.min | number | Minimum value |
@expect.max | number | Maximum value |
@expect.exclusiveMin | number | Strictly greater than the value (since 0.1.109) |
@expect.exclusiveMax | number | Strictly less than the value (since 0.1.109) |
@expect.int | number | Must be integer |
@expect.pattern | string | Regex match |
@expect.minKeys | object | At least this many keys (records, objects with optional props; see Key counts) |
@expect.maxKeys | object | At most this many keys |
@expect.array.uniqueItems | array | No duplicate items |
@expect.array.key | array element properties | Marks identity fields for uniqueness and patch operations |
@expect.message | prop, type | Custom 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.requiredonT | nullnow compile, andvalidate()checks them on non-null values. Before, such a field failed to compile (got "group"), and a type built without that check — at runtime, viafromJsonSchema(), or with diagnostics ignored — silently skipped the constraint.buildJsonSchema()puts the constraint on the matchinganyOfmember. nullon an optional@meta.requiredfield is rejected.@meta.required name?: stringaccepts an omitted (orundefined) value but rejectsnullwith the@meta.requiredmessage (Must not be empty/Must be checkedby default). Partial validation keeps accepting an omitted field. Declarename?: string | nullwhennullis 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 validateexpect.int/ the email pattern on the member, as a plainnumber.intfield 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:
interface Order {
@expect.array.uniqueItems "Duplicate line items"
items: OrderItem[]
}
interface OrderItem {
@expect.array.key
productId: number
quantity: number
}@expect.array.keyidentifies the fields that make an item unique@expect.array.uniqueItemsenforces 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:
unknownProps | Counted 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:
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:
| Field | Meaning |
|---|---|
path | Dot path of the value ('' for the root) |
message | The default English message, or the app's own message from an annotation |
code | Stable UPPER_SNAKE code (see Error Codes); CUSTOM for a plugin error reported without a code |
params | The values the message needs: limits, expected type, pattern, allowed values (JSON-safe; input copied into them is cut at 100 characters) |
custom | true when message is app text (an annotation's message argument, @expect.message, a plugin's own message) |
details | The 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:
| Code | Default template | Params |
|---|---|---|
REQUIRED | Must not be empty | |
REQUIRED_CHECKED | Must be checked | |
TYPE | Expected {expected}, got {actual} | expected, actual (type names) |
TYPE_ARRAY | Expected array | actual |
TYPE_OBJECT | Expected object | actual |
TYPE_DECIMAL | Expected string (decimal), got {actual} | actual |
NOT_FINITE | Expected finite number, got {actual} | actual ('NaN', 'Infinity', …) |
DECIMAL_FORMAT | Invalid decimal format: "{value}" | value |
NEVER | This type is impossible, must be an internal problem | |
LITERAL | Expected {expected}, got {actual} | expected (the literal), actual |
UNION | Value does not match any of the allowed types: {expected} | expected |
UNION_DISCRIMINATOR | Expected one of {allowed}, got {actual} | property, allowed (array), actual |
UNEXPECTED_PROPERTY | Unexpected property | property |
TUPLE_LENGTH | Expected array of length {length} | length |
MIN_LENGTH | Expected minimum length of {min} characters, got {actual} characters | min, actual |
MAX_LENGTH | Expected maximum length of {max} characters, got {actual} characters | max, actual |
MIN_ITEMS | Expected minimum length of {min} items, got {actual} items | min, actual |
MAX_ITEMS | Expected maximum length of {max} items, got {actual} items | max, actual |
UNIQUE_ITEMS | Duplicate items are not allowed | (path: the duplicate's index) |
MIN_KEYS | Expected at least {min} properties, got {actual} | min, actual |
MAX_KEYS | Expected at most {max} properties, got {actual} | max, actual |
PATTERN | Value is expected to match pattern "{pattern}" | pattern, flags (when set) |
INT | Expected integer, got {actual} | actual |
MIN / MAX | Expected minimum {min}, got {actual} / Expected maximum {max}, … | min / max, actual |
EXCLUSIVE_MIN | Expected greater than {min}, got {actual} | min, actual |
EXCLUSIVE_MAX | Expected less than {max}, got {actual} | max, actual |
EMAIL, PHONE, DATE, ISO_DATE, UUID, URL, IPV4, IPV6, IP | the 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.
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:
localizeErrors(errors, (id, params) => (i18n.exists(id) ? i18n.t(id, params) : undefined))To get translated errors from one validator directly, pass translate:
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: atranslateoption per locale means one validator per locale. - DON'T match on
message. It changes with custom messages and translation; branch oncodeand readparams.
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:
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_DISCRIMINATORerror at the discriminator (pet.kind),params.allowedlisting 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:
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():
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:
// 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?):
| Argument | Type | Notes |
|---|---|---|
message | string | Required. Becomes the message of a TError entry. |
path | string? | Optional. Defaults to the current dot-separated path being validated. |
details | TError[]? | Optional. Nested error breakdown — useful when an alternative-tested branch fails. |
info | TErrorInfo? | Optional { code, params, custom }. Without it the error has code CUSTOM and custom: true (its message is the translation id) |
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:
ctx.error(`Amount must be positive`, undefined, undefined, {
code: 'AMOUNT_NOT_POSITIVE',
params: { actual: value },
})Manual Validator Construction
For deserialized or programmatically built types:
import { Validator, deserializeAnnotatedType } from '@atscript/typescript/utils'
const type = deserializeAnnotatedType(jsonData)
const validator = new Validator(type)
validator.validate(someValue)Input Coercion: coerceForType()
import { coerceForType } from '@atscript/typescript/utils'
coerceForType(def: TAtscriptAnnotatedType, value: unknown): unknownConverts 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:
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 acceptedboolean —
"true"/"1"→true,"false"/"0"→falseunions — 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', alsokind: 'cat' | nullor 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"to2), and in hand-built or deserialized types, which can hold boolean literals, to a boolean literal ("1"totrue). 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:- its properties or items hold values of their kind: a string that parses for a
numberproperty,nullonly wherenullis allowed. This weighs most, because a value of the wrong kind never passes the validator; - it has no key the member does not declare (
unknownProps: 'strip'or'ignore'lets such keys pass); - it has every required property of the member (
partiallets 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 nokind) takes the first one. A union nested in the union counts as its members, so(A | B) | CandA | B | Cpick 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 therangemember. With{ v: 1; n: number } | { v: '1'; n: boolean },{ v: '1', n: '1' }takes the'1'member, while{ v: '1', n: '3' }takes the1member and becomes{ v: 1, n: 3 }. When no member takes the value, it is returned untouched for the validator to report- its properties or items hold values of their kind: a string that parses for a
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()
import { normalizeForType } from '@atscript/typescript/utils'
normalizeForType<T>(def: TAtscriptAnnotatedType, value: T, opts?: Partial<TValidatorOptions>): TApplies the type's @normalize.* annotations (trim, lowercase, uppercase). The validator never normalizes, so call it on input before validating it:
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.trimonnickname?: 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 | Digitsnormalizes' A@B.CO 'to'a@b.co'(only theEmailresult 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). Withoutopts, 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: trueapplies only to a union that is the value's own type, and apartialcallback,replace,skipListand 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
- Validation Guide — the practical application flow
- Compiled Validators — which options keep a validator on the interpreted path
- Type Definitions — the runtime type system behind validation
- Serialization — validate deserialized types