Type Validation
Agency allows users to add an arbitrary validation logic on types. Remember that with schemas, you could validate a type with the bang operator: Person!. You can also write custom validation that runs for any type. This validation also gets triggered when you validate using the bang operator.
@validate
Simple example:
type Person = {
name: string;
@validate(isPositive)
age: number;
}The isPositive function then returns a success or failure.
def isPositive(value: number): Result<number> {
if (value > 0) {
return success(value);
}
return failure("expected ${value} to be > 0")
}If successful, the function returns success with the value. You can also send a new value to modify the value. So for example, instead of having isPositive fail, we could just have it clamp the value to be above zero.
def isPositive(value: number): Result<number> {
if (value > 0) {
return success(value);
}
// always succeeds, modifies value
return success(1);
}In our example, we set the validator right on the key in the Person type. We could also create a new type instead.
@validate(isPositive)
type Age = number;
type Person = {
name: string;
age: Age;
}This is nice because it lets you create a type that has the validation built in that you can now use everywhere. This becomes especially useful combined with the jsonSchema tag. Let's look at that next.
@jsonSchema
Types are also used as JSON schemas to specify a structured output format to an LLM. When adding validation to a type, you may additionally want to tell the LLM about it. For example, if you've just added validation saying that age must be positive, you might want to give the LLM a hint that the number should be greater than zero. You can do this using the jsonSchema tag:
@validate(isPositive)
@jsonSchema({ minimum: 1 })
type Age = number;jsonSchema takes an object, and all of the fields here are simply passed as additional fields to the JSON schema object that is constructed.
Obviously, to do this correctly, you'll need to know the correct fields to pass to the JSON schema. I have some references at the end of this writeup for this.
If you're not sure what JSON schema field to use, you can always just put some information in the description field:
@validate(isPositive)
@jsonSchema({ description: "should be > 0" })
type Age = number;Used together, these two tags let you create new types that have custom validation and also have the right JSON schema hints. The agency standard library already comes with some of these types built in.
Sidebar: Inspecting the JSON schema
If you're not sure what the result in JSON schema is going to look like, you can always print it out in Agency:
const personSchema = schema(Person)
print(personSchema.zodSchema.toJSONSchema())Multiple validators and schemas
You can set multiple validators, and they will all run in order. If you transform the value, the transformed value will get handed to the next validator:
@validate(isPositive, isAdult)
type AdultAge = number;If any of the validators fails, the chain stops and returns a failure.
You can also stack @validate tags:
@validate(isPositive)
@validate(isAdult)
type AdultAge = number;The same behavior also works for the jsonSchema tag, but obviously, while you can have multiple validators, in the end, you're only going to produce a single JSON schema tag. So if you specify multiple objects, later objects may override the keys in earlier objects:
@jsonSchema({ foo: 1, minimum: 1 })
@jsonSchema({ bar: 1, minimum: 18 })
type Age = number; // schema includes { foo: 1, bar: 1, minimum: 18 }The one exception to this is the description field. Descriptions all get concatenated together, separated by new lines. This lets you create reusable types, and set a description in the description field, and know that that description will get passed down.
Container types, recursive types
Here is an array of ages.
type Ages = Age[]The validator will run once for every element in the array. You could also add a second validator that runs for the entire array.
@validate(nonEmpty)
type Ages = Age[]Similar situation for objects:
@validate(noNullFields)
type Person = {
name: string;
age: Age;
}Similar situations were for recursive types, although currently we hard code the recursion depth to prevent infinite recursion during validation.
JS Validators
You don't need to write your validation function in agency. You can write it in TypeScript if you want.
import { success, failure } from "agency-lang/runtime";
export function isPalindrome(value) {
const reversed = value.split("").reverse().join("");
return value === reversed
? success(value)
: failure("not a palindrome");
}Value-Parameterized Aliases
When you find yourself repeating the same @validate(...) / @jsonSchema(...) shape with just one or two numbers (or strings) changing, you can lift those numbers out as value parameters on the alias itself:
import { min, max } from "std::validators"
@validate(min.partial(n: low), max.partial(n: high))
@jsonSchema({ minimum: low, maximum: high })
type NumberInRange(low: number, high: number) = numberAt every use site you supply concrete arguments and the alias's tags are substituted at compile time as if you had written them out by hand:
type User = {
age: NumberInRange(0, 150)
score: NumberInRange(1, 100)
}The age property gets @validate(min.partial(n: 0), max.partial(n: 150)) plus @jsonSchema({ minimum: 0, maximum: 150 }); the score property gets the equivalent with 1 / 100. The two instantiations are nominally distinct but are mutually assignment-compatible (both bottom out at number); validation runs only at ! sites.
Syntax
Value parameters use
(...), distinct from type parameters'<...>. Value params must come after type params:tstype BoundedList<T>(n: number) = T[] // use site const xs: BoundedList<string>(3) = ["a", "b", "c"]Defaults are allowed and follow the same restriction set:
tstype Age(low: number = 0) = number const x: Age()! = 5 // uses default 0
What can appear as a use-site argument
Arguments are evaluated at compile time, not at runtime — they have to be statically known. The underlying rule: an argument is allowed only if its value can be folded to a TypeScript literal during compilation.
Allowed:
- String / number / boolean /
nullliterals - Multi-line
"""..."""strings - Unit literals: time (
30s,2h), cost ($5), size (100KB). These canonicalise to a plain number (ms / dollars / bytes) and the canonical value is what gets substituted - Regex literals (
re/pattern/flags); useful when forwarding to a custom validator declared with(pat: regex) - Identifiers that resolve to a top-level
static const(including const-bound imports) - Other value-param identifiers in scope (so a wrapper alias can forward its own value params)
- Object literals and array literals built from any of the above (with
...spread)
Not allowed:
- bare function calls (
Age(getDefault())) - ternaries, binary operators, pipes
- member access (
Age(config.min)) - identifiers that resolve to a
letbinding, function parameter, or local declaration
String interpolation is restricted to value-parameter identifiers
Agency string literals normally support interpolation everywhere — "hello ${name}" is a real expression that combines the literal text with whatever name evaluates to at runtime. Inside @validate(...), @jsonSchema(...), value-param defaults, and use-site value-args, the only ${...} form that is accepted is a bare identifier that names a value parameter of the enclosing alias:
// ✅ `${divisor}` references a value parameter — substituted at compile time
@jsonSchema({ description: "Must be divisible by ${divisor}" })
type DivisibleBy(divisor: number) = number
// At use sites, `divisor` is folded into the description:
// schema(DivisibleBy(3)).toJSONSchema().description // "Must be divisible by 3"
// schema(DivisibleBy(7)).toJSONSchema().description // "Must be divisible by 7"
// ❌ Arbitrary expressions are still rejected (no ternaries, no calls, no member access)
@jsonSchema({ pattern: "^${PREFIX}[0-9]+${foo()}$" })
type UserId = string
// ❌ Top-level static const references in `${...}` are also rejected:
// tag-arg strings are emitted into node-body schema chains where
// module-level Agency consts are not bound to JS identifiers.
static const PREFIX = "user-"
@jsonSchema({ pattern: "^${PREFIX}[0-9]+$" }) // ❌ not supported today
type UserId = stringIf you need to embed a static const, compose the literal yourself and pass the const in as the whole argument (which is statically known):
static const PATTERN = "^user-[0-9]+-foo$" // hand-written literal
@jsonSchema({ pattern: PATTERN }) // ✅ static const reference (no interpolation)
type UserId = stringBare function calls in tag arguments
The same restriction applies inside @validate(...) and @jsonSchema(...) arguments themselves:
// ❌ Rejected — bare function call in a tag argument
@validate(min(0))
type Age = number
// ✅ Use a partial-application chain instead
@validate(min.partial(n: 0))
type Age = numberThe partial-application (PFA) form is what makes value-parameter substitution well-defined: a value-param identifier (low) can flow into the named-argument slot of a method call, which we can manipulate as an expression tree at compile time.
Arithmetic erases bounds
Value-parameterized aliases attach validators and JSON schema constraints to a specific type name, not to the underlying number/string value itself. As a result, arithmetic and other expressions return the plain unwrapped type — the bounds do not propagate through operators:
@validate(min.partial(n: low), max.partial(n: high))
type NumberInRange(low: number, high: number) = number
const a: NumberInRange(0, 10)! = 7
const sum = a.value + 5 // sum is a plain `number`, NOT NumberInRange(0, 10)If you want to re-validate the result of an arithmetic expression, annotate it again with a value-parameterized alias and use the bang:
const checked: NumberInRange(0, 100)! = a.value + 5Pre-baked stdlib types
std::types already exports the most common parameterized shapes:
NumberInRange(low, high)StringWithLength(min, max)MatchesPattern(pat)BoundedArray<T>(min, max)
See the std::types reference for the full list.