ProvenPin Install

msgspec Structs to JSON Schema with constraints and tagged unions: working versions (tested 2026-10-18)

Generate JSON Schema from msgspec Structs using Annotated Meta constraints, tagged unions and optional fields, then decode payloads with the same validation rules.

passed6 passed · 0 failed2026-10-18 03:57 UTC
Status
passed
Last passed
2026-10-18 03:57 UTC
Tests
6 / 6
Language
Python
Runtime
Python 3.13.16
pinned versions2
msgspec@0.22.0pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
Python 3.13.16
linux 6.17.0-1022-azure
x64
runner: github-actions 39095370682

The code

Exactly the files that passed. Your agent can pull them with get_recipe("msgspec-json-schema-generation") (install), or see them on GitHub.

requirements.txt
msgspec==0.22.0
pytest==9.1.1
models.py
from typing import Annotated, Union

import msgspec


class User(msgspec.Struct, tag="user"):
    name: Annotated[str, msgspec.Meta(min_length=1, max_length=50, pattern="^[a-z]+$")]
    age: Annotated[int, msgspec.Meta(ge=0, le=120)]
    nickname: str | None = None


class Team(msgspec.Struct, tag="team"):
    team_name: Annotated[str, msgspec.Meta(min_length=1)]
    members: list[str] = []


Message = Union[User, Team]


def user_schema() -> dict:
    return msgspec.json.schema(User)


def message_schema() -> dict:
    return msgspec.json.schema(Message)


def deref(schema: dict, node: dict) -> dict:
    ref = node.get("$ref")
    return schema["$defs"][ref.rsplit("/", 1)[-1]] if ref else node


def decode_message(payload: bytes) -> User | Team:
    return msgspec.json.decode(payload, type=Message)
tests/test_schema.py
import msgspec
import pytest
from models import User, decode_message, deref, message_schema, user_schema


def test_meta_constraints_become_json_schema_keywords():
    schema = user_schema()
    user = deref(schema, schema)
    assert user["properties"]["age"] == {"type": "integer", "minimum": 0, "maximum": 120}
    assert user["properties"]["name"]["minLength"] == 1
    assert user["properties"]["name"]["maxLength"] == 50
    assert user["properties"]["name"]["pattern"] == "^[a-z]+$"


def test_tagged_union_schema_emits_discriminator():
    schema = message_schema()
    assert set(schema["$defs"]) == {"User", "Team"}
    assert schema["discriminator"] == {
        "propertyName": "type",
        "mapping": {"user": "#/$defs/User", "team": "#/$defs/Team"},
    }
    assert all(ref["$ref"].startswith("#/$defs/") for ref in schema["anyOf"])


def test_tagged_union_dispatches_on_tag():
    user = decode_message(b'{"type":"user","name":"bob","age":30}')
    assert isinstance(user, User) and user.age == 30
    team = decode_message(b'{"type":"team","team_name":"core"}')
    assert team.members == []
    with pytest.raises(msgspec.ValidationError):
        decode_message(b'{"type":"nope","name":"bob"}')


def test_missing_tag_ok_lone_but_not_in_union():
    assert msgspec.json.decode(b'{"name":"bob","age":1}', type=User).name == "bob"
    with pytest.raises(msgspec.ValidationError):
        decode_message(b'{"name":"bob","age":1}')


def test_optional_field_is_not_required():
    schema = user_schema()
    user = deref(schema, schema)
    assert user["required"] == ["type", "name", "age"]
    assert user["properties"]["nickname"]["anyOf"] == [{"type": "string"}, {"type": "null"}]
    assert user["properties"]["nickname"]["default"] is None
    assert decode_message(b'{"type":"user","name":"bob","age":1}').nickname is None


def test_decode_enforces_meta_constraints():
    for payload in (
        b'{"type":"user","name":"Bob","age":1}',
        b'{"type":"user","name":"","age":1}',
        b'{"type":"user","name":"bob","age":121}',
        b'{"type":"user","name":"bob","age":"1"}',
    ):
        with pytest.raises(msgspec.ValidationError):
            decode_message(payload)
    # fields absent from the schema are ignored unless forbid_unknown_fields is set
    assert decode_message(b'{"type":"user","name":"bob","age":1,"x":2}').name == "bob"
pytest.ini
[pytest]
pythonpath = .

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.