Zod

Functional approach to data validation.
Independent of UI.
Available client and server side.
Attempts to match python typing.
Heavily based on the TypeScript library zod.dev.

Demo App

Basic Usage

Creating a simple string schema

from anvil_extras import zod as z

# create a schema
schema = z.string()

# parsing
schema.parse("tuna") # -> "tuna"
schema.parse(42) # -> throws ParseError

# "safe" parsing - doesn't throw if valid
result = schema.safe_parse("tuna") # -> ParseResult(success=True, data="tuna")
result.success # True
result = schema.safe_parse(42) # -> ParseResult(success=False, error=ParseError("Invalid type"))
result.success # False

Creating a typed_dict schema

from anvil_extras import zod as z

# create a schema
user = z.typed_dict({
    "username": z.string()
})

user.parse({"username": "Meredydd"}) # -> {"username": "Meredydd"}

Primitives

from anvil_extras import zod as z

z.string()
z.integer()
z.float()
z.number() # int or float
z.boolean()
z.date()
z.datetime()
z.none()

# catch all types - allow any value
z.any()
z.unknown()

# never types - allows no values
z.never()

Literals

from anvil_extras import zod as z

tuna = z.literal("tuna")
empty_str = z.literal("")
true = z.literal(True)
_42 = z.literal(42)

# retrieve the literal value
tuna.value # "tuna"

Strings

Zod includes a handful of string-specific validations.

z.string().max(5)
z.string().min(5)
z.string().len(5)
z.string().email()
z.string().url()
z.string().uuid()
z.string().regex(re.compile(r"^\d+$""))
z.string().startswith(string)
z.string().endswith(string)
z.string().strip() # strips whitespace
z.string().lower() # convert to lower case
z.string().upper() # convert to upper case
z.string().datetime() # defaults to iso format string
z.string().date() # defaults to iso format string

You can customize some common error messages when creating a string schema.

name = z.string(
    required_error="Name is required",
    invalid_type_error="Name must be a string",
)

When using validation methods, you can pass in an additional argument to provide a custom error message

z.string().min(5, message="Must be 5 or more characters long")
z.string().max(5, message="Must be 5 or fewer characters long")
z.string().length(5, message="Must be exactly 5 characters long")
z.string().email(message="Invalid email address")
z.string().url(message="Invalid url")
z.string().uuid(message="Invalid UUID")
z.string().startswith("https://", message="Must provide secure URL")
z.string().endswith(".com", message="Only .com domains allowed")
z.string().datetime(message="Invalid datetime string! Must be in isoformat")

Coercion for primitives

Zod provides a convenient way to coerce primitive values.

schema = z.coerce.string()

# remove print statements
schema.parse("tuna")  # => "tuna"
schema.parse(12)      # => "12"
schema.parse(True)    # => "True"

During the parsing step, the input is passed through the str() function. Note that the returned schema is a ZodString instance so you can use all string methods.

z.coerce.string().email().min(5)

The following primitive types support coercion

z.coerce.string() # str(input)
z.coerce.boolean() # bool(input)
z.coerce.integer() # int(input)
z.coerce.float() # float(input)

The int and float coercions will be surrounded in a try/except. This way coercion failures will be reported as invalid type errors.

Numbers, Integers and Floats

Zod integer and float expect their equivalent python types when parsed. A zod number accepts either integer or float.

from anvil_extras.zod import z

age = z.number(
    required_error="Age is required",
    invalid_type_error="Age must be a number",
)

Zod includes a handful of number-specific validations.

from anvil_extras.zod import z

z.number().gt(5)
z.number().ge(5)  # greater than or equal to, alias .min(5)
z.number().lt(5)
z.number().le(5)  # less than or equal to, alias .max(5)

z.number().int()  # value must be an integer

z.number().positive()     # > 0
z.number().nonnegative()  # >= 0
z.number().negative()     # < 0
z.number().nonpositive()  # <= 0

The equivalent validations are available on integer and float.

Optionally, you can pass in a second argument to provide a custom error message.

z.number().le(5, message="this👏is👏too👏big")

Booleans

You can customize certain error messages when creating a boolean schema

is_active = z.boolean(
    required_error="isActive is required",
    invalid_type_error="isActive must be a boolean",
)

Dates and Datetimes

from anvil_extras.zod import z
from datetime import date

z.date().safe_parse(date.today())  # success: True
z.date().safe_parse("2022-01-12")  # success: False

You can customize the error messages

my_date_schema = z.date(
    required_error="Please select a date and time",
    invalid_type_error="That's not a date!",
)

Zod provides a handful of datetime-specific validations.

z.date().min(
    date(1900, 1, 1),
    message="Too old"
)
z.date().max(
    date.today(),
    message="Too young!"
)

Supporting date strings

def preprocess_date(arg):
    if isinstance(arg, str):
        try:
            return date.fromisoformat(arg) #could use datetime.strptime().date
        except Exception:
            return arg

    else:
        return arg

date_schema = z.preprocess(preprocess_date, z.date())

date_schema.safe_parse(date(2022, 1, 12))  # success: True
date_schema.safe_parse("2022-01-12")  # success: True

Enums

from anvil_extras.zod import z

FishEnum = z.enum(["Salmon", "Tuna", "Trout"])

z.enum is a way to declare a schema with a fixed set of allowable values. Pass the list of values directly into z.enum().

To retrieve the enum options use .options

FishEnum.options  # ["Salmon", "Tuna", "Trout

Optional

Optional is synonymous with python’s typing.Optional. In other words, something optional can also be None. (This is different to Zod TypeScript’s optional)

from anvil_extras.zod import z

schema = z.optional(z.string())

schema.parse(None)  # returns None

For convenience, you can also call the .optional() method on an existing schema.

schema = z.string().optional()

You can extract the wrapped schema from a ZodOptional instance with .unwrap().

string_schema = z.string()
optional_string = string_schema.optional()
optional_string.unwrap() == string_schema # True

TypedDict

This is equivalent to Zod TypeScript’s object schema. We chose typed_dict since it matches Python’s typing.TypedDict. (z.object is also available for convenience)

from anvil_extras.zod import z

# all properties are required by default
Dog = z.typed_dict({
    "name": z.string(),
    "age": z.number()
})

API

class ZodTypedDict
shape

Use .shape to access the schemas for a particular key.

Dog.shape["name"]  # => string schema
Dog.shape["age"]   # => number schema
keyof()

Use .keyof to create a ZodEnum schema from the keys of a typed_dict schema.

key_schema = Dog.keyof()
key_schema # ZodEnum<["name", "age"]>
extend()

You can add additional fields to a typed_dict schema with the .extend method.

from anvil_extras.zod import z

# all properties are required by default
Dog = z.typed_dict({
    "name": z.string(),
    "age": z.number()
})

DogWithBreed = Dog.extend({
    "breed": z.string()
})

You can use .extend to overwrite fields! Be careful with this power!

merge(B)

Equivalent to A.extend(B.shape).

If the two schemas share keys, the properties of B overrides the property of A. The returned schema also inherits the “unknownKeys” policy (strip/strict/passthrough) and the catchall schema of B.

BaseTeacher = z.typed_dict({
    "students": z.list(z.string())
})

HasID = z.typed_dict({
    "id": z.string()
})

Teacher = BaseTeacher.merge(HasID)

# the type of the `Teacher` variable is inferred as follows:
# {
#     "students": z.array(z.string()),
#     "id": z.string()
# }
pick(keys=None)

Returns a modified version of the typed_dict schema that only includes the keys specified in the keys argument. (This method is inspired by TypeScript’s built-in Pick utility type).

from anvil_extras.zod import z

Recipe = z.typed_dict({
    "id": z.string(),
    "name": z.string(),
    "ingredients": z.list(z.string()),
})

JustTheName = Recipe.pick(["name"])

# the type of the JustTheName variable is inferred as follows:
# {
#     "name": z.string()
# }
omit(keys=None)

Returns a modified version of the typed_dict schema that excludes the keys specified in the keys argument. (This method is inspired by TypeScript’s built-in Omit utility type).

from anvil_extras.zod import z

Recipe = z.typed_dict({
    "id": z.string(),
    "name": z.string(),
    "ingredients": z.list(z.string()),
})

NoIDRecipe = Recipe.omit(["id"])

# the type of the `NoIDRecipe` variable is inferred as follows:
# {
#     "name": z.string(),
#     "ingredients": z.list(z.string())
# }
partial(keys=None)
Returns:

a modified version of the typed_dict schema in which all properties are made optional. This method is inspired by the built-in TypeScript utility type Partial.

Parameters:

keys (iterable) – Optional argument that specifies which properties to make optional. If not provided, all properties are made optional.

from anvil_extras.zod import z

User = z.typed_dict({
    "email": z.string(),
    "username": z.string(),
})

# create a partial version of the `User` schema
PartialUser = User.partial()

PartialUser.parse({"email": "foo@gmail.com"}) # -> {"email": "foo@gmail.com"}
PartialUser.parse({}) # -> {}
PartialUser.parse({"email": None}) # -> raises ParseError

the type of the PartialUser variable is equivalent to:

{
    "email": z.string().not_required(),
    "username": z.string().not_required(),
}

In other words the parsed dictionary may or may not include the "email" and "username" key. Note this is different to .optional() which would allow the value to be None

Create a partial version of the User schema where only the email property is made optional

OptionalEmail = User.partial(["email"])

# the type of the `OptionalEmail` variable is equivalent to:
# {
#     "email": z.string().not_required(),
#     "username": z.string(),
# }
required(keys=None)

Returns a modified version of the typed_dict schema in which all properties are made required. This method is the opposite of the .partial method, which makes all properties optional.

Parameters:

keys (iterable) – Optional argument that specifies which properties to make required. If not provided, all properties are made required.

from anvil_extras.zod import z

User = z.typed_dict({
    "email": z.string(),
    "username": z.string(),
}).partial()

# create a required version of the `User` schema
RequiredUser = User.required()

RequiredUser is now equivalent to the original shape.

Create a required version of the User schema where only the email property is made required

RequiredEmail = User.required(["email"])

# the type of the `RequiredEmail` variable is equivalent to:
# {
#     "email": z.string(),
#     "username": z.string().not_required(),
# }
passthrough()

Returns a modified version of the typed_dict schema that enables "passthrough" mode. In passthrough mode, unrecognized keys are not stripped out during parsing.

from anvil_extras.zod import z

Person = z.typed_dict({
    "name": z.string(),
})

# parse a dict with unrecognized keys
result = Person.parse({
    "name": "bob dylan",
    "extraKey": 61,
})

# the `result` variable is as follows:
# {
#     "name": "bob dylan",
# }

The extraKey property has been stripped out because the Person schema is not in "passthrough" mode

# enable "passthrough" mode for the `Person` schema
PassthroughPerson = Person.passthrough()

# parse a dict with unrecognized keys
result = PassthroughPerson.parse({
    "name": "bob dylan",
    "extraKey": 61,
})

# the `result` variable is now as follows:
# {
#     "name": "bob dylan",
#     "extraKey": 61,
# }

Now the extraKey property has not been stripped out because the PassthroughPerson schema is in "passthrough" mode

strict()

Returns a modified version of the typed_dict schema that disallows unknown keys during parsing. If the input to .parse() contains any unknown keys, a ParseError will be thrown.

from anvil_extras.zod import z

Person = z.typed_dict({
    "name": z.string(),
})

# parse a dict with unrecognized keys
try:
    result = Person.strict().parse({
        "name": "bob dylan",
        "extraKey": 61,
    })
except z.ParseError as e:
    print(e)
    # => "Unknown key 'extraKey' found in input at path 'extraKey'"

The code above will throw a ParseError because the Person schema is in "strict" mode and the input contains an unknown key

strip()

Returns a modified version of the typed_dict schema that strips out unrecognized keys during parsing. This is the default behavior of ZodTypedDict schemas.

catchall(schema: ZodAny) ZodTypedDict

You can pass a "catchall" schema into a typed_dict schema. All unknown keys will be validated against it.

Parameters:

schema – A Zod schema for validating unknown keys.

Returns:

A new ZodTypedDict schema with catchall schema for unknown keys.

Raises:

ParseError – If any unknown keys fail validation.

Example:

from zod import z

# Create a person schema with `name` field
person = z.typed_dict({
    "name": z.string()
})

# Add a catchall schema for any unknown keys
person = person.catchall(z.number())

# Parse with valid extra key
person.parse({
    "name": "bob dylan",
    "validExtraKey": 61
})

# Parse with invalid extra key
person.parse({
    "name": "bob dylan",
    "invalidExtraKey": "foo"
})
# => raises ParseError

Using .catchall() obviates .passthrough(), .strip(), or .strict(). All keys are now considered “known”.

NotRequired

The .not_required() method can be used in conjunction with typed_dict schemas. This means the key value pair can be missing. See the ZodTypedDict.partial() method.

List

Similar to typing.List type.

string_list = z.list(z.string())

# equivalent
string_array = z.string().list()

Be careful with the .list() method. It returns a new ZodList instance. This means the order in which you call methods matters. For instance:

z.string().optional().list() # (string | None)[]
z.string().list().optional() # string[] | None

A ZodList schema will parse a tuple or list. A tuple will be returned as a list upon parsing.

The following method are provided on a list schema

z.string().list().min(5)  # must contain 5 or more items
z.string().list().max(5)  # must contain 5 or fewer items
z.string().list().len(5)  # must contain 5 items exactly

Additional API

class ZodList
element

Use .element to access the schema for an element of the array.

string_list.element; # => string schema
nonempty(message)

If you want to ensure that an array contains at least one element, use .nonempty().

Parameters:

message – Optional custom error message.

Returns:

The same ZodList instance with .nonempty() added.

Example:

non_empty_strings = z.string().list().nonempty();
non_empty_strings.parse([]); // throws: "List cannot be empty"
non_empty_strings.parse(["Ariana Grande"]); # passes

You can optionally specify a custom error message:

from anvil_extras import zod as z

# optional custom error message
non_empty_strings = z.string().array().nonempty(
    message="Can't be empty!"
)

Tuples

Unlike lists, tuples have a fixed number of elements and each element can have a different type. It is similar to typing.Tuple type.

athlete_schema = z.tuple([
    z.string(), # name
    z.integer(), # jersey number
    z.dict({"points_scored": z.number()}) # statistics
])

A variadic (“rest”) argument can be added with the .rest method.

from anvil_extras import zod as z

variadic_tuple = z.tuple([z.string()]).rest(z.number())
result = variadic_tuple.parse(["hello", 1, 2, 3])]

For convenience a tuple schema will parse both A list and a tuple in the same way.

Unions

Zod includes a built-in z.union method for composing “OR” types. This is similar to typing.Union.

string_or_number = z.union([z.string(), z.number()])

string_or_number.parse("foo") # passes
string_or_number.parse(14) # passes

Zod will test the input against each of the “options” in order and return the first value that validates successfully.

For convenience, you can also use the .union method:

string_or_number = z.string().union(z.number())

Mappings

Mappings are similar to Python’s typing.Mapping or typing.Dict types. You should specify a key and value schema

NumberCache = z.mapping(z.string(), z.integer());

# expects to parse dict[str, int]

This is particularly useful for storing or caching items by ID

user_schema = z.typed_dict({"name": z.string()})
user_cache_schema = z.mapping(z.string().uuid(), user_schema)

user_store = {}

user_store["77d2586b-9e8e-4ecf-8b21-ea7e0530eadd"] = {"name": "Carlotta"}
user_cache_schema.parse(user_store) # passes


user_store["77d2586b-9e8e-4ecf-8b21-ea7e0530eadd"] = {"whatever": "Ice cream sundae"}
user_cache_schema.parse(user_store) # Fails

Recursive types

from anvil_extras import zod as z

Category = z.lazy(lambda:
    z.typed_dict({
        'name': z.string(),
        'subcategories': z.list(Category),
    })
)

Category.parse({
    'name': 'People',
    'subcategories': [
        {
        'name': 'Politicians',
        'subcategories': [{ 'name': 'Presidents', 'subcategories': [] }],
        },
    ],
}) # passes

If you want to validate any JSON value, you can use the snippet below.

literal_schema = z.union([z.string(), z.number(), z.boolean(), z.none()])
json_schema = z.lazy(lambda: z.union([literal_schema, z.list(json_schema), z.mapping(json_schema)]))

json_schema.parse(data)

Isinstance

You can use z.isinstance to check that the input is an instance of a class. This is useful to validate inputs against classes.

from anvil_extras import zod as z

class Test:
    def __init__(self, name: str):
        self.name = name

TestSchema = z.isinstance(Test)

blob = "whatever"
TestSchema.parse(Test("my_name")) # passes
TestSchema.parse(blob) # throws

Preprocess

Typically Zod operates under a “parse then transform” paradigm. Zod validates the input first, then passes it through a chain of transformation functions. (For more information about transforms)

But sometimes you want to apply some transform to the input before parsing happens. A common use case: type coercion. Zod enables this with the z.preprocess().

cast_to_string = z.preprocess(lambda val: str(val), z.string())

Schema Methods

parse(data)
Returns:

If the given value is valid according to the schema, a value is returned. Otherwise, an error is thrown.

IMPORTANT: The value returned by .parse is a deep clone of the variable you passed in.

Example:

string_schema = z.string()
string_schema.parse("fish")  # returns "fish"
string_schema.parse(12)  # throws ParseError
safe_parse(data)
Returns:

ParseResult(success: bool, data: any, error: ParseError | None)

If you don’t want Zod to throw errors when validation fails, use .safe_parse. This method returns a ParseResult containing either the successfully parsed data or a ParseError instance containing detailed information about the validation problems.

Example:

string_schema.safe_parse(12)  # ParseResult(success=False, error=ParseError)
string_schema.safe_parse("fish")  # ParseResult(success=True, data="fish")

You can handle the errors conveniently:

result = stringSchema.safeParse("billie")
if not result.success:
    # handle error then return
    print(result.error)
else:
    # do something
    print(result.data)

Not Yet Documented:

  • refine

  • super_refine

  • transform

  • super_transform

  • default

  • catch

  • optional

  • error handling and formatting

  • pipe