Guards, verdicts, and fail-closed rejection.
A guard is the optional second argument to Define. It runs before the contract and decides if the input may pass.
import { Define } from '@neabyte/typebox'
const signup = Define(
(props: { name: string; age: number }) => ({ id: 1, name: props.name }),
(props) => (props.age >= 18 ? true : 'age must be at least 18')
)
signup({ name: 'neo', age: 30 }) // { id: 1, name: 'neo' }
try {
signup({ name: 'kid', age: 12 })
} catch (error) {
if (error instanceof Error && Array.isArray(error.cause)) {
console.log(error.cause) // ['age must be at least 18']
}
}A guard returns a GuardVerdict. Validation is fail-closed, so a guard can never let bad input pass by mistake.
| Verdict | Meaning |
|---|---|
true |
Pass |
a non-empty string |
Reject with that one reason |
a string[] of reasons |
Reject with all reasons |
an empty array [] |
Reject with a generic reason 'validation failed' |
| anything else | Invalid verdict, rejected with 'validation failed' |
Only true, a string, or an array whose every member is a string is a valid verdict. Anything else, such as false, 0, NaN, a number, a plain object, or a mixed array, is treated as invalid and rejected.
// collect every failure in one pass
const create = Define(
(props: { username: string; password: string }) => props,
(props) => {
const reasons: string[] = []
if (props.username.length < 3) {
reasons.push('username must be at least 3 characters')
}
if (props.password.length < 8) {
reasons.push('password must be at least 8 characters')
}
return reasons.length === 0 ? true : reasons
}
)A guard may be one function or a list. Every guard runs in order, and the first one that returns reasons rejects.
const create = Define(
(props: { name: string }) => props,
[
(props) => (props.name.length > 0 ? true : 'name required'),
(props) => (props.name.length <= 32 ? true : 'name too long')
]
)On failure the reasons are thrown as a TypeError. The message joins the reasons with the separator "; ", and the full reason list is on cause. Read cause instead of parsing the message. Only a guard rejection carries a cause; an error thrown from inside a contract or a step is not wrapped and propagates as is, keeping its original type and full stack trace.
try {
create({ name: '' })
} catch (error) {
if (error instanceof Error && Array.isArray(error.cause)) {
const reasons = error.cause as readonly string[]
for (const reason of reasons) {
console.log('-', reason)
}
}
}A synchronous guard for a contract's input. It receives the same input as the contract and returns a GuardVerdict. See GuardFn for the full generic form.
type GuardFn = (input: ContractInput) => GuardVerdicttype GuardVerdict = true | string | readonly string[]- When the input is an object, each guard receives the original input after it is deep-frozen in place, so a guard can read but cannot mutate it. The same reference is frozen and passed to the guard, so the caller's own value is frozen as well. The prototype and methods of the input are preserved. When the input is not an object, the guard receives it unchanged.
- Guards must be synchronous. Returning a thenable throws
TypeError('async guard unsupported'). - A string input longer than 10000 characters is rejected before any guard runs, with the length reason on
cause. - When an object input is frozen for the guard, any nested string value longer than 10000 characters is rejected with a
TypeError. The message isinput exceeds 10000 charactersand the same reason is oncause. - When an object input is frozen for the guard, nesting that reaches 256 levels deep is rejected with a
TypeError. The message isinput nesting exceeds 256 levelsand the same reason is oncause. - An invalid verdict throws
TypeError('guard returned invalid verdict')withcause: ['validation failed'].