AnyVali Portability Guide¶
Overview¶
AnyVali's core value proposition is that schemas move between languages. This guide explains how the portability system works, how to design schemas that transfer cleanly, and what to do when they cannot.
The Three Portability Tiers¶
Every feature in AnyVali falls into one of three tiers.
Tier 1: Portable Core¶
Guaranteed import and export compatibility across all AnyVali SDKs.
The portable core includes:
- All schema kinds (
string,int64,object,array,union, etc.) - All declarative constraints (
minLength,max,pattern, etc.) - All portable format validators (
email,url,uuid,ipv4,ipv6,date,date-time) - All portable coercions (
string -> int,string -> number,string -> bool, whitespace trim, case normalization) - Defaults (when the default value is representable in JSON)
- Definitions and references
- Unknown key modes (
reject,strip,allow)
If your schema uses only Tier 1 features, it will export successfully in portable mode and import correctly in every AnyVali SDK.
Tier 2: Portable Core + Extensions¶
Compatible only where the relevant extension namespace is understood.
Extensions allow SDKs to attach language-specific metadata to schemas. Examples:
- A Go SDK might add struct tag information
- A TypeScript SDK might add branded type markers
- A Java SDK might add annotation metadata
Extensions are namespaced in the exported JSON:
{
"anyvaliVersion": "1.0",
"schemaVersion": "1",
"root": { "kind": "string", "minLength": 1 },
"definitions": {},
"extensions": {
"go": {
"structTags": { "name": "json:\"name\" validate:\"required\"" }
},
"js": {
"brandedType": "UserId"
}
}
}
A Tier 2 schema exports successfully in extended mode. It imports successfully in SDKs that understand its extension namespaces. SDKs that do not understand the extensions may still import the schema if the extensions are informational (safe to ignore).
Tier 3: Local-Only¶
Never part of canonical interchange.
Local-only features include:
- Custom validators (arbitrary functions attached to schemas)
- Function-based defaults
- Async validation hooks
- Any feature that requires executable code
These features work within a single SDK but cannot be serialized. Attempting to export a schema with Tier 3 features in portable mode will fail. In extended mode, the schema exports with the Tier 3 features omitted (or the export fails if the feature is required for correctness).
Designing Portable Schemas¶
Use only portable schema kinds¶
Stick to the kinds defined in the AnyVali spec:
// Portable -- these work everywhere
v.string()
v.int() // int64
v.number() // float64
v.bool()
v.object({...})
v.array(v.string())
v.union([v.string(), v.int()])
v.enum(["a", "b", "c"])
v.literal("active")
v.nullable(v.string())
v.optional(v.int())
v.tuple([v.string(), v.int()])
v.record(v.string(), v.int())
Use only portable constraints¶
All constraints in the portable core are declarative and data-only:
# Portable constraints
name = v.string().min_length(1).max_length(255)
age = v.int().min(0).max(150)
email = v.string().format("email")
code = v.string().pattern(r"^[A-Z]{3}-\d{4}$")
items = v.array(v.string()).min_items(1).max_items(100)
price = v.number().min(0).exclusive_min(True)
Use JSON-representable defaults¶
Defaults must be pure data that can be serialized to JSON:
// Portable defaults
role := v.String().Default("user")
tags := v.Array(v.String()).Default([]string{})
config := v.Object(v.Fields{
"retries": v.Int().Default(3),
"timeout": v.Number().Default(30.0),
})
Do not use function-based defaults:
// NOT PORTABLE -- function default
const createdAt = v.string().default(() => new Date().toISOString());
// This will fail portable export
Avoid custom validators for shared schemas¶
Custom validators are Tier 3 (local-only):
# NOT PORTABLE -- custom validator
schema = v.string().custom(lambda s: s.startswith("usr_"))
# PORTABLE equivalent -- use pattern constraint
schema = v.string().pattern(r"^usr_")
Export Modes¶
Portable Mode¶
Portable mode is the strict default. It ensures the exported schema uses only Tier 1 features.
// Portable export -- fails if schema uses non-portable features
const doc = schema.export({ mode: "portable" });
If the schema contains any non-portable features, the export fails with a descriptive error:
ExportError: Schema contains non-portable features:
- Custom validator at path "user.email" (custom validators are local-only)
- Function default at path "user.createdAt" (function defaults are not serializable)
Extended Mode¶
Extended mode emits the core schema plus any extension namespaces:
// Extended export -- includes extension metadata
const doc = schema.export({ mode: "extended" });
The resulting document contains the full portable core in root and language-specific metadata in extensions. SDKs that understand the extensions can use them; others can ignore informational extensions or reject the schema if semantic extensions are missing.
The Extension Model¶
Informational Extensions¶
Informational extensions provide metadata that is safe to ignore. The schema remains correct without them.
Examples:
- Documentation strings
- Struct tags for code generation
- UI display hints
- Source location metadata
When importing a schema with informational extensions for an unknown namespace, the SDK silently ignores them. The schema imports and validates correctly.
Semantic Extensions¶
Semantic extensions change the meaning or behavior of the schema. The schema may not validate correctly without them.
Examples:
- Custom coercion rules specific to a language
- Additional validation constraints beyond the portable core
- Type system features that alter parsing behavior
When importing a schema with semantic extensions for an unknown namespace:
- If the target SDK's namespace has the required extension, use it.
- If the target SDK's namespace is missing but a
defaultextension is provided, use the default. - If neither the target namespace nor a default can satisfy the requirement, the import fails.
{
"extensions": {
"default": {
"customCoerce": { "type": "trimAndLower" }
},
"js": {
"customCoerce": { "type": "trimAndLower", "locale": "en-US" }
},
"go": {
"customCoerce": { "type": "trimAndLower" }
}
}
}
In this example, a Python SDK importing the schema would find no python namespace. It would check for a default namespace, find the customCoerce instruction, and apply it if the SDK understands that extension. If the extension is semantic and the Python SDK does not understand customCoerce, the import fails.
What Happens When Import Fails¶
Import can fail for several reasons. Each produces a specific error.
Missing Required Extension¶
ImportError: Schema requires semantic extension "customCoerce"
in namespace "go". No handler registered for this extension
and no "default" fallback provided.
Code: unsupported_extension
Unsupported Schema Kind¶
If a future version of AnyVali adds new schema kinds that an older SDK does not understand:
ImportError: Unknown schema kind "branded" at path "root.userId".
Code: unsupported_schema_kind
Custom Validator Referenced¶
If a schema somehow references a custom validator (this should not happen with portable export, but could with hand-edited JSON):
ImportError: Schema references custom validator at path
"root.email". Custom validators are not portable.
Code: custom_validation_not_portable
Recovery Strategies¶
When import fails, consumers can:
- Remove the offending feature -- edit the JSON document to remove the non-portable extension or custom validator, then retry.
- Register a local handler -- implement the missing extension in the target SDK, then retry.
- Use a different export mode -- re-export the schema in portable mode from the source SDK, removing non-portable features.
Custom Validators and Why They Are Local-Only¶
Custom validators are arbitrary functions:
const positiveEven = v.int().custom((val) => {
if (val % 2 !== 0) return { code: "not_even", message: "Must be even" };
return true;
});
These cannot be made portable because:
- Functions cannot be serialized to JSON
- Executing foreign code would be a security risk
- Different languages have different capabilities and standard libraries
- Behavioral equivalence across languages is not verifiable
Instead, express validation rules using portable constraints wherever possible:
| Custom Validator | Portable Alternative |
|---|---|
val => val.startsWith("usr_") |
.startsWith("usr_") or .pattern("^usr_") |
val => val % 2 === 0 |
.multipleOf(2) |
val => val >= 0 && val <= 100 |
.min(0).max(100) |
val => isEmail(val) |
.format("email") |
val => val.length >= 3 |
.minLength(3) |
When no portable constraint can express your rule, the custom validator is necessarily local-only.
Coercion Portability¶
AnyVali v1 defines a small set of portable coercions. These are the only coercions that are guaranteed to work identically across all SDKs.
Portable Coercions¶
| From | To | Behavior |
|---|---|---|
string |
int |
Parse string as integer (e.g., "42" becomes 42). Fail on non-numeric strings. |
string |
number |
Parse string as float (e.g., "3.14" becomes 3.14). Fail on non-numeric strings. |
string |
bool |
"true" / "1" become true, "false" / "0" become false. All other strings fail. |
string |
string (trim) |
Remove leading and trailing whitespace. |
string |
string (lower) |
Convert to lowercase. |
string |
string (upper) |
Convert to uppercase. |
Usage¶
// JS/TS
const port = v.int().coerce("string"); // "8080" -> 8080
const name = v.string().coerce("trim"); // " Alice " -> "Alice"
const flag = v.bool().coerce("string"); // "true" -> true
# Python
port = v.int().coerce("string")
name = v.string().coerce("trim")
flag = v.bool().coerce("string")
// Go
port := v.Int().Coerce(v.FromString)
name := v.String().Coerce(v.Trim)
flag := v.Bool().Coerce(v.FromString)
Coercion Ordering¶
Coercions run early in the parse pipeline:
- Detect presence or absence
- If present and coercion configured, attempt coercion
- If absent and default exists, materialize default
- Validate resulting value
- Return parsed output or structured error
If coercion fails, the SDK returns an issue with code coercion_failed:
{
"code": "coercion_failed",
"message": "Cannot coerce \"abc\" to int",
"path": ["port"],
"expected": "int",
"received": "\"abc\""
}
Format Validator Portability¶
AnyVali v1 defines seven portable format validators for strings:
| Format | Description | Example |
|---|---|---|
email |
Email address (RFC 5321/5322 simplified) | user@example.com |
url |
URL (RFC 3986) | https://example.com/path |
uuid |
UUID (RFC 4122) | 550e8400-e29b-41d4-a716-446655440000 |
ipv4 |
IPv4 address | 192.168.1.1 |
ipv6 |
IPv6 address | ::1 |
date |
ISO 8601 date | 2025-01-15 |
date-time |
ISO 8601 date-time | 2025-01-15T09:30:00Z |
All SDKs must implement these format validators and produce consistent accept/reject decisions for the same input. The conformance test suite includes test cases for each format.
Format values outside this list are not portable. SDKs may support additional format values as local extensions, but they will not export in portable mode.
Step-by-Step Migration Guide¶
Migrating from Zod (JavaScript)¶
Step 1: Map Zod types to AnyVali types¶
| Zod | AnyVali |
|---|---|
z.string() |
v.string() |
z.number() |
v.number() |
z.number().int() |
v.int() |
z.boolean() |
v.bool() |
z.object({...}) |
v.object({...}) |
z.array(...) |
v.array(...) |
z.enum([...]) |
v.enum([...]) |
z.union([...]) |
v.union([...]) |
z.literal(...) |
v.literal(...) |
z.optional(...) |
v.optional(...) |
z.nullable(...) |
v.nullable(...) |
Step 2: Replace transforms with portable alternatives¶
Zod transforms are not portable. Replace them with coercions or remove them:
// Zod (non-portable)
const schema = z.string().transform((s) => s.trim().toLowerCase());
// AnyVali (portable)
const schema = v.string().coerce("trim").coerce("lower");
Step 3: Replace refinements with constraints¶
// Zod (non-portable)
const schema = z.number().refine((n) => n % 2 === 0, "Must be even");
// AnyVali (portable)
const schema = v.number().multipleOf(2);
Step 4: Replace function defaults with data defaults¶
// Zod (non-portable)
const schema = z.string().default(() => crypto.randomUUID());
// AnyVali -- keep as local-only, or use a static default
const schema = v.string(); // generate UUID at call site, not in schema
Step 5: Export and verify¶
const doc = schema.export({ mode: "portable" });
// If this succeeds, your schema is fully portable
Migrating from JSON Schema¶
Step 1: Import the JSON Schema as a starting point¶
AnyVali's canonical JSON is different from JSON Schema, so there is no direct import. Map types manually:
| JSON Schema | AnyVali |
|---|---|
{ "type": "string" } |
{ "kind": "string" } |
{ "type": "integer" } |
{ "kind": "int64" } |
{ "type": "number" } |
{ "kind": "float64" } |
{ "type": "boolean" } |
{ "kind": "bool" } |
{ "type": "array", "items": ... } |
{ "kind": "array", "items": ... } |
{ "type": "object", "properties": ... } |
{ "kind": "object", "properties": ... } |
{ "$ref": "#/$defs/X" } |
{ "kind": "ref", "ref": "#/definitions/X" } |
Step 2: Map constraints¶
| JSON Schema | AnyVali |
|---|---|
minLength |
minLength |
maxLength |
maxLength |
pattern |
pattern |
minimum |
min |
maximum |
max |
exclusiveMinimum |
exclusiveMin |
exclusiveMaximum |
exclusiveMax |
multipleOf |
multipleOf |
minItems |
minItems |
maxItems |
maxItems |
format |
format (only portable values) |
Step 3: Handle features AnyVali does not support¶
JSON Schema features without AnyVali equivalents in v1:
oneOf-- useunion(closest equivalent)allOf-- useintersectionanyOf-- useunionnot-- no equivalent; model differentlyif/then/else-- no equivalent; use unions or restructurepatternProperties-- no equivalent in v1additionalProperties-- useunknownKeysmode
Migrating from Pydantic (Python)¶
Step 1: Map Pydantic fields to AnyVali¶
# Pydantic
class User(BaseModel):
name: str = Field(min_length=1, max_length=100)
age: int = Field(ge=0, le=150)
email: EmailStr
# AnyVali
user_schema = v.object({
"name": v.string().min_length(1).max_length(100),
"age": v.int().min(0).max(150),
"email": v.string().format("email"),
})
Step 2: Replace validators with constraints¶
# Pydantic (non-portable)
@validator("name")
def name_must_be_capitalized(cls, v):
return v.title()
# AnyVali -- use coercion or handle at application level
Step 3: Export and test¶
doc = user_schema.export(mode="portable")
# Import in another language to verify
FAQ and Common Pitfalls¶
Can I use regex patterns portably?¶
Yes, but with caveats. The pattern constraint accepts regular expressions, but regex syntax varies across languages. Stick to basic regex features that are common across all target languages:
- Character classes:
[a-z],[0-9],\d,\w,\s - Quantifiers:
*,+,?,{n},{n,m} - Anchors:
^,$ - Alternation:
| - Groups:
(...), non-capturing(?:...)
Avoid language-specific regex features like lookbehinds (not supported in all JS engines), possessive quantifiers, or Unicode property escapes.
What happens if I export from a newer SDK and import into an older one?¶
The anyvaliVersion and schemaVersion fields in the document allow the importing SDK to detect version mismatches. If the document uses a newer schema version with features the SDK does not understand, the import fails with a descriptive error.
Can I mix portable and local-only features in one schema?¶
Yes. A schema can have both portable constraints and local custom validators. The local features work during runtime validation. When you export:
- Portable mode: the export fails because local features are present.
- Extended mode: the export includes the portable core; local features are either omitted or represented as extensions (depending on SDK implementation).
Why does portable export fail instead of silently dropping non-portable features?¶
Failing loudly prevents subtle bugs. If a custom validator is silently dropped during export, the importing SDK would accept values that the authoring SDK would reject. This leads to data quality issues that are hard to diagnose. Explicit failure forces the developer to make a conscious decision about portability.
How do I share schemas between frontend (JS) and backend (Go)?¶
- Define the schema in either language.
- Export it in portable mode:
schema.export({ mode: "portable" }). - Store the JSON document (in a file, database, or API response).
- Import it in the other language:
v.ImportSchema(doc). - Both sides now validate identically.
For this to work, the schema must use only Tier 1 features.
Can I use int64 safely with JavaScript?¶
Values up to Number.MAX_SAFE_INTEGER (9,007,199,254,740,991 or 2^53 - 1) work without issues. For larger values, the JS SDK will use BigInt or reject the value. If your integer values never exceed 2^53, you can use int64 freely. If you need the full int64 range in a JavaScript context, be aware of the BigInt requirement.
What is the default extension namespace?¶
The default namespace in extensions provides fallback behavior for SDKs that do not have their own namespace. When an SDK imports a schema with a semantic extension:
- It looks for its own namespace (e.g.,
python). - If not found, it looks for
default. - If neither exists, the import fails.
This allows schema authors to provide reasonable default behavior for SDKs they have not explicitly targeted.
How do I handle unknown keys in portable schemas?¶
AnyVali defaults to rejecting unknown keys. You can change this per object:
// Reject unknown keys (default)
v.object({ name: v.string() })
// Strip unknown keys
v.object({ name: v.string() }).unknownKeys("strip")
// Allow unknown keys (pass through)
v.object({ name: v.string() }).unknownKeys("allow")
All three modes are portable and behave identically across SDKs.
My schema uses a custom format. Is it portable?¶
Only the seven built-in format values are portable: email, url, uuid, ipv4, ipv6, date, date-time. Custom format values are local-only. If you need portable validation for a custom format, express it using pattern or other portable constraints.