diff --git a/docs/pages/docs/features/validation/index.md b/docs/pages/docs/features/validation/index.md
index c4c7a18dfc84f611c4f461bbb8d264a8bbdd0398..5f8df33b6a18f51a64f38aa8b70d5c76091a1e0d 100644
--- a/docs/pages/docs/features/validation/index.md
+++ b/docs/pages/docs/features/validation/index.md
@@ -200,6 +200,7 @@ use Distantmagic\Resonance\HttpResponderInterface;
 use Distantmagic\Resonance\RequestMethod;
 use Distantmagic\Resonance\SingletonCollection;
 use Ds\Map;
+use Ds\Set;
 use Swoole\Http\Request;
 use Swoole\Http\Response;
 
@@ -220,7 +221,7 @@ final readonly class BlogPostStore extends HttpController
     }
 
     /**
-     * @param Map<string,string> $errors
+     * @param Map<string,Set<string>> $errors
      */
     #[ValidationErrorsHandler]
     public function handleValidationErrors(
diff --git a/src/Constraint.php b/src/Constraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..75be005acf0141bd93e37f18c3d4d864b2d8278c
--- /dev/null
+++ b/src/Constraint.php
@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use LogicException;
+
+/**
+ * @psalm-import-type PJsonSchema from JsonSchemableInterface
+ */
+abstract readonly class Constraint implements JsonSchemableInterface
+{
+    abstract public function default(mixed $defaultValue): self;
+
+    abstract public function nullable(): self;
+
+    abstract public function optional(): self;
+
+    /**
+     * @return PJsonSchema
+     */
+    abstract protected function doConvertToJsonSchema(): array|object;
+
+    /**
+     * @param mixed $notValidatedData explicitly mixed for typechecks
+     */
+    abstract protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult;
+
+    public function __construct(
+        public ?ConstraintDefaultValue $defaultValue = null,
+        public bool $isNullable = false,
+        public bool $isRequired = true,
+    ) {}
+
+    /**
+     * @return PJsonSchema
+     */
+    public function toJsonSchema(): array|object
+    {
+        $jsonSchema = $this->doConvertToJsonSchema();
+
+        if (is_object($jsonSchema)) {
+            return $jsonSchema;
+        }
+
+        if ($this->defaultValue) {
+            /**
+             * @var mixed explicitly mixed for typechecks
+             */
+            $jsonSchema['default'] = $this->defaultValue->defaultValue;
+        }
+
+        if ($this->isNullable) {
+            if (!isset($jsonSchema['type'])) {
+                throw new LogicException('Schema cannot be nullable without a type');
+            }
+
+            if (is_array($jsonSchema['type'])) {
+                $jsonSchema['type'] = ['null', ...$jsonSchema['type']];
+            } else {
+                $jsonSchema['type'] = ['null', $jsonSchema['type']];
+            }
+        }
+
+        /**
+         * @var PJsonSchema
+         */
+        return $jsonSchema;
+    }
+
+    /**
+     * @param mixed $notValidatedData explicitly mixed for typechecks
+     */
+    public function validate(
+        mixed $notValidatedData,
+        ConstraintPath $path = new ConstraintPath(),
+    ): ConstraintResult {
+        if ($this->isNullable && is_null($notValidatedData)) {
+            return new ConstraintResult(
+                castedData: null,
+                path: $path,
+                reason: ConstraintReason::Ok,
+                status: ConstraintResultStatus::Valid,
+            );
+        }
+
+        return $this->doValidate($notValidatedData, $path);
+    }
+}
diff --git a/src/Constraint/AnyConstraint.php b/src/Constraint/AnyConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..0baab197962c60fb89649ac9aca82c17a9f8e332
--- /dev/null
+++ b/src/Constraint/AnyConstraint.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\ConstraintDefaultValue;
+use Distantmagic\Resonance\ConstraintPath;
+use Distantmagic\Resonance\ConstraintReason;
+use Distantmagic\Resonance\ConstraintResult;
+use Distantmagic\Resonance\ConstraintResultStatus;
+use stdClass;
+
+readonly class AnyConstraint extends Constraint
+{
+    public function default(mixed $defaultValue): self
+    {
+        return new self(
+            defaultValue: new ConstraintDefaultValue($defaultValue),
+            isNullable: $this->isNullable,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function nullable(): self
+    {
+        return new self(
+            defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null),
+            isNullable: true,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function optional(): self
+    {
+        return new self(
+            defaultValue: $this->defaultValue,
+            isNullable: $this->isNullable,
+            isRequired: false,
+        );
+    }
+
+    protected function doConvertToJsonSchema(): object
+    {
+        return new stdClass();
+    }
+
+    protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
+    {
+        return new ConstraintResult(
+            castedData: $notValidatedData,
+            path: $path,
+            reason: ConstraintReason::InvalidEnumValue,
+            status: ConstraintResultStatus::Valid,
+        );
+    }
+}
diff --git a/src/Constraint/AnyConstraintTest.php b/src/Constraint/AnyConstraintTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..aad39b4e1beb536475aaf60f68f2e06a7de8fc6c
--- /dev/null
+++ b/src/Constraint/AnyConstraintTest.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use PHPUnit\Framework\TestCase;
+use stdClass;
+
+/**
+ * @coversNothing
+ *
+ * @internal
+ */
+final class AnyConstraintTest extends TestCase
+{
+    public function test_is_converted_optionally_to_json_schema(): void
+    {
+        $constraint = new AnyConstraint();
+
+        self::assertEquals(new stdClass(), $constraint->optional()->toJsonSchema());
+    }
+
+    public function test_is_converted_to_json_schema(): void
+    {
+        $constraint = new AnyConstraint();
+
+        self::assertEquals(new stdClass(), $constraint->toJsonSchema());
+    }
+
+    public function test_nullable_is_converted_to_json_schema(): void
+    {
+        $constraint = new AnyConstraint();
+
+        self::assertEquals(new stdClass(), $constraint->nullable()->toJsonSchema());
+    }
+
+    public function test_validates(): void
+    {
+        $constraint = new AnyConstraint();
+
+        self::assertTrue($constraint->validate('foo')->status->isValid());
+    }
+}
diff --git a/src/Constraint/ConstConstraint.php b/src/Constraint/ConstConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..2bfc053ba4b55a1250ff4efc7d4e866e3ca894ce
--- /dev/null
+++ b/src/Constraint/ConstConstraint.php
@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\ConstraintPath;
+use Distantmagic\Resonance\ConstraintReason;
+use Distantmagic\Resonance\ConstraintResult;
+use Distantmagic\Resonance\ConstraintResultStatus;
+use LogicException;
+
+readonly class ConstConstraint extends Constraint
+{
+    /**
+     * @param float|int|non-empty-string $constValue
+     */
+    public function __construct(public float|int|string $constValue)
+    {
+        parent::__construct();
+    }
+
+    public function default(mixed $defaultValue): never
+    {
+        throw new LogicException('Const constraint cannot have a default value');
+    }
+
+    public function nullable(): never
+    {
+        throw new LogicException('Const constraint cannot be nullable');
+    }
+
+    public function optional(): never
+    {
+        throw new LogicException('Const constraint cannot be optional');
+    }
+
+    protected function doConvertToJsonSchema(): array
+    {
+        return [
+            'const' => $this->constValue,
+        ];
+    }
+
+    protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
+    {
+        if ($notValidatedData !== $this->constValue) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                path: $path,
+                reason: ConstraintReason::InvalidEnumValue,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        return new ConstraintResult(
+            castedData: $notValidatedData,
+            path: $path,
+            reason: ConstraintReason::Ok,
+            status: ConstraintResultStatus::Valid,
+        );
+    }
+}
diff --git a/src/Constraint/ConstConstraintTest.php b/src/Constraint/ConstConstraintTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..658bcb8ceae2469d78a42f4ba02eae38a2dc8e5d
--- /dev/null
+++ b/src/Constraint/ConstConstraintTest.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversNothing
+ *
+ * @internal
+ */
+final class ConstConstraintTest extends TestCase
+{
+    public function test_is_converted_to_json_schema(): void
+    {
+        $constraint = new ConstConstraint(2);
+
+        self::assertEquals([
+            'const' => 2,
+        ], $constraint->toJsonSchema());
+    }
+
+    public function test_validates(): void
+    {
+        $constraint = new ConstConstraint(4);
+
+        self::assertTrue($constraint->validate(4)->status->isValid());
+        self::assertFalse($constraint->validate(4.5)->status->isValid());
+    }
+}
diff --git a/src/Constraint/EnumConstraint.php b/src/Constraint/EnumConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..bb14cc29abfcc40b8008db08692b52db9ab60bd0
--- /dev/null
+++ b/src/Constraint/EnumConstraint.php
@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\ConstraintDefaultValue;
+use Distantmagic\Resonance\ConstraintPath;
+use Distantmagic\Resonance\ConstraintReason;
+use Distantmagic\Resonance\ConstraintResult;
+use Distantmagic\Resonance\ConstraintResultStatus;
+
+readonly class EnumConstraint extends Constraint
+{
+    /**
+     * @param array<non-empty-string> $values
+     */
+    public function __construct(
+        public array $values,
+        ?ConstraintDefaultValue $defaultValue = null,
+        bool $isNullable = false,
+        bool $isRequired = true,
+    ) {
+        parent::__construct(
+            defaultValue: $defaultValue,
+            isNullable: $isNullable,
+            isRequired: $isRequired,
+        );
+    }
+
+    public function default(mixed $defaultValue): self
+    {
+        return new self(
+            defaultValue: new ConstraintDefaultValue($defaultValue),
+            values: $this->values,
+            isNullable: $this->isNullable,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function nullable(): self
+    {
+        return new self(
+            defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null),
+            values: $this->values,
+            isNullable: true,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function optional(): self
+    {
+        return new self(
+            defaultValue: $this->defaultValue,
+            values: $this->values,
+            isNullable: $this->isNullable,
+            isRequired: false,
+        );
+    }
+
+    protected function doConvertToJsonSchema(): array
+    {
+        return [
+            'type' => 'string',
+            'enum' => $this->values,
+        ];
+    }
+
+    protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
+    {
+        if (!is_string($notValidatedData)) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                path: $path,
+                reason: ConstraintReason::InvalidDataType,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        if (!in_array($notValidatedData, $this->values, true)) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                path: $path,
+                reason: ConstraintReason::InvalidEnumValue,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        return new ConstraintResult(
+            castedData: $notValidatedData,
+            path: $path,
+            reason: ConstraintReason::Ok,
+            status: ConstraintResultStatus::Valid,
+        );
+    }
+}
diff --git a/src/Constraint/EnumConstraintTest.php b/src/Constraint/EnumConstraintTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..7d6a20c1b23e5d61bbc25c6bed97230d07e5ad3a
--- /dev/null
+++ b/src/Constraint/EnumConstraintTest.php
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversNothing
+ *
+ * @internal
+ */
+final class EnumConstraintTest extends TestCase
+{
+    public function test_is_converted_optionally_to_json_schema(): void
+    {
+        $constraint = new EnumConstraint(['foo', 'bar']);
+
+        self::assertEquals([
+            'type' => 'string',
+            'enum' => ['foo', 'bar'],
+        ], $constraint->optional()->toJsonSchema());
+    }
+
+    public function test_is_converted_to_json_schema(): void
+    {
+        $constraint = new EnumConstraint(['foo', 'bar']);
+
+        self::assertEquals([
+            'type' => 'string',
+            'enum' => ['foo', 'bar'],
+        ], $constraint->toJsonSchema());
+    }
+
+    public function test_nullable_is_converted_to_json_schema(): void
+    {
+        $constraint = new EnumConstraint(['foo', 'bar']);
+
+        self::assertEquals([
+            'type' => ['null', 'string'],
+            'enum' => ['foo', 'bar'],
+            'default' => null,
+        ], $constraint->nullable()->toJsonSchema());
+    }
+
+    public function test_validates(): void
+    {
+        $constraint = new EnumConstraint(['foo', 'bar']);
+
+        self::assertTrue($constraint->validate('foo')->status->isValid());
+        self::assertFalse($constraint->validate('booz')->status->isValid());
+    }
+}
diff --git a/src/Constraint/IntegerConstraint.php b/src/Constraint/IntegerConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..764f0b45a3875a097eb481ea38065e4fe9cfaae2
--- /dev/null
+++ b/src/Constraint/IntegerConstraint.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\ConstraintDefaultValue;
+use Distantmagic\Resonance\ConstraintPath;
+use Distantmagic\Resonance\ConstraintReason;
+use Distantmagic\Resonance\ConstraintResult;
+use Distantmagic\Resonance\ConstraintResultStatus;
+
+readonly class IntegerConstraint extends Constraint
+{
+    public function default(mixed $defaultValue): self
+    {
+        return new self(
+            defaultValue: new ConstraintDefaultValue($defaultValue),
+            isNullable: $this->isNullable,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function nullable(): self
+    {
+        return new self(
+            defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null),
+            isNullable: true,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function optional(): self
+    {
+        return new self(
+            defaultValue: $this->defaultValue,
+            isNullable: $this->isNullable,
+            isRequired: false,
+        );
+    }
+
+    protected function doConvertToJsonSchema(): array
+    {
+        return [
+            'type' => 'integer',
+        ];
+    }
+
+    protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
+    {
+        if (!is_int($notValidatedData)) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                path: $path,
+                reason: ConstraintReason::InvalidDataType,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        return new ConstraintResult(
+            castedData: $notValidatedData,
+            path: $path,
+            reason: ConstraintReason::Ok,
+            status: ConstraintResultStatus::Valid,
+        );
+    }
+}
diff --git a/src/Constraint/IntegerConstraintTest.php b/src/Constraint/IntegerConstraintTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..178148c45aeccd0ec3a89640230e5431fbf1229a
--- /dev/null
+++ b/src/Constraint/IntegerConstraintTest.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversNothing
+ *
+ * @internal
+ */
+final class IntegerConstraintTest extends TestCase
+{
+    public function test_is_converted_optionally_to_json_schema(): void
+    {
+        $constraint = new IntegerConstraint();
+
+        self::assertEquals([
+            'type' => 'integer',
+        ], $constraint->optional()->toJsonSchema());
+    }
+
+    public function test_is_converted_to_json_schema(): void
+    {
+        $constraint = new IntegerConstraint();
+
+        self::assertEquals([
+            'type' => 'integer',
+        ], $constraint->toJsonSchema());
+    }
+
+    public function test_nullable_is_converted_to_json_schema(): void
+    {
+        $constraint = new IntegerConstraint();
+
+        self::assertEquals([
+            'type' => ['null', 'integer'],
+            'default' => null,
+        ], $constraint->nullable()->toJsonSchema());
+    }
+
+    public function test_validates(): void
+    {
+        $constraint = new IntegerConstraint();
+
+        self::assertTrue($constraint->validate(5)->status->isValid());
+        self::assertFalse($constraint->validate(5.5)->status->isValid());
+    }
+}
diff --git a/src/Constraint/NumberConstraint.php b/src/Constraint/NumberConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..fc39114dffab53b9517fe781431b13cf95931ab4
--- /dev/null
+++ b/src/Constraint/NumberConstraint.php
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\ConstraintDefaultValue;
+use Distantmagic\Resonance\ConstraintPath;
+use Distantmagic\Resonance\ConstraintReason;
+use Distantmagic\Resonance\ConstraintResult;
+use Distantmagic\Resonance\ConstraintResultStatus;
+
+readonly class NumberConstraint extends Constraint
+{
+    public function default(mixed $defaultValue): self
+    {
+        return new self(
+            defaultValue: new ConstraintDefaultValue($defaultValue),
+            isNullable: $this->isNullable,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function nullable(): self
+    {
+        return new self(
+            defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null),
+            isNullable: true,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function optional(): self
+    {
+        return new self(
+            defaultValue: $this->defaultValue,
+            isNullable: $this->isNullable,
+            isRequired: false,
+        );
+    }
+
+    protected function doConvertToJsonSchema(): array
+    {
+        return [
+            'type' => 'number',
+        ];
+    }
+
+    protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
+    {
+        if (!is_numeric($notValidatedData) || is_string($notValidatedData)) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                path: $path,
+                reason: ConstraintReason::InvalidDataType,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        return new ConstraintResult(
+            castedData: $notValidatedData,
+            path: $path,
+            reason: ConstraintReason::Ok,
+            status: ConstraintResultStatus::Valid,
+        );
+    }
+}
diff --git a/src/Constraint/NumberConstraintTest.php b/src/Constraint/NumberConstraintTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..981bd62be8d3f41df3c4ad559db4b7eb78f249ae
--- /dev/null
+++ b/src/Constraint/NumberConstraintTest.php
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversNothing
+ *
+ * @internal
+ */
+final class NumberConstraintTest extends TestCase
+{
+    public function test_is_converted_optionally_to_json_schema(): void
+    {
+        $constraint = new NumberConstraint();
+
+        self::assertEquals([
+            'type' => 'number',
+        ], $constraint->optional()->toJsonSchema());
+    }
+
+    public function test_is_converted_to_json_schema(): void
+    {
+        $constraint = new NumberConstraint();
+
+        self::assertEquals([
+            'type' => 'number',
+        ], $constraint->toJsonSchema());
+    }
+
+    public function test_nullable_is_converted_to_json_schema(): void
+    {
+        $constraint = new NumberConstraint();
+
+        self::assertEquals([
+            'type' => ['null', 'number'],
+            'default' => null,
+        ], $constraint->nullable()->toJsonSchema());
+    }
+
+    public function test_validates(): void
+    {
+        $constraint = new NumberConstraint();
+
+        self::assertTrue($constraint->validate(5)->status->isValid());
+        self::assertTrue($constraint->validate(5.5)->status->isValid());
+    }
+}
diff --git a/src/Constraint/ObjectConstraint.php b/src/Constraint/ObjectConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..3a24b775b5737f6815346f459bce7ab03ea9bf5b
--- /dev/null
+++ b/src/Constraint/ObjectConstraint.php
@@ -0,0 +1,150 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\ConstraintDefaultValue;
+use Distantmagic\Resonance\ConstraintPath;
+use Distantmagic\Resonance\ConstraintReason;
+use Distantmagic\Resonance\ConstraintResult;
+use Distantmagic\Resonance\ConstraintResultStatus;
+
+final readonly class ObjectConstraint extends Constraint
+{
+    /**
+     * @param array<non-empty-string,Constraint> $properties
+     */
+    public function __construct(
+        public array $properties,
+        ?ConstraintDefaultValue $defaultValue = null,
+        bool $isNullable = false,
+        bool $isRequired = true,
+    ) {
+        parent::__construct(
+            defaultValue: $defaultValue,
+            isNullable: $isNullable,
+            isRequired: $isRequired,
+        );
+    }
+
+    public function default(mixed $defaultValue): self
+    {
+        return new self(
+            properties: $this->properties,
+            defaultValue: new ConstraintDefaultValue($defaultValue),
+            isNullable: $this->isNullable,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function nullable(): self
+    {
+        return new self(
+            properties: $this->properties,
+            defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null),
+            isNullable: true,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function optional(): self
+    {
+        return new self(
+            properties: $this->properties,
+            defaultValue: $this->defaultValue,
+            isNullable: $this->isNullable,
+            isRequired: false,
+        );
+    }
+
+    protected function doConvertToJsonSchema(): array
+    {
+        $convertedProperties = [];
+        $requiredProperties = [];
+
+        foreach ($this->properties as $name => $property) {
+            $convertedProperties[$name] = $property->toJsonSchema();
+
+            if ($property->isRequired) {
+                $requiredProperties[] = $name;
+            }
+        }
+
+        return [
+            'type' => 'object',
+            'properties' => $convertedProperties,
+            'required' => $requiredProperties,
+            'additionalProperties' => false,
+        ];
+    }
+
+    protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
+    {
+        if (!is_array($notValidatedData)) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                path: $path,
+                reason: ConstraintReason::InvalidDataType,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        $ret = [];
+
+        /**
+         * @var list<ConstraintResult>
+         */
+        $invalidChildStatuses = [];
+
+        foreach ($this->properties as $name => $validator) {
+            if (!array_key_exists($name, $notValidatedData)) {
+                if ($validator->defaultValue) {
+                    /**
+                     * @var mixed explicitly mixed for typechecks
+                     */
+                    $ret[$name] = $validator->defaultValue->defaultValue;
+                } elseif ($validator->isRequired) {
+                    $invalidChildStatuses[] = new ConstraintResult(
+                        castedData: null,
+                        path: $path->fork($name),
+                        reason: ConstraintReason::MissingProperty,
+                        status: ConstraintResultStatus::Invalid,
+                    );
+                }
+            } else {
+                $childResult = $validator->validate(
+                    notValidatedData: $notValidatedData[$name],
+                    path: $path->fork($name)
+                );
+
+                if ($childResult->status->isValid()) {
+                    /**
+                     * @var mixed explicitly mixed for typechecks
+                     */
+                    $ret[$name] = $childResult->castedData;
+                } else {
+                    $invalidChildStatuses[] = $childResult;
+                }
+            }
+        }
+
+        if (!empty($invalidChildStatuses)) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                nested: $invalidChildStatuses,
+                path: $path,
+                reason: ConstraintReason::InvalidNestedConstraint,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        return new ConstraintResult(
+            castedData: $ret,
+            path: $path,
+            reason: ConstraintReason::Ok,
+            status: ConstraintResultStatus::Valid,
+        );
+    }
+}
diff --git a/src/Constraint/ObjectConstraintTest.php b/src/Constraint/ObjectConstraintTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..a28960ab07da4a653cde4c6d234f830fe621c84d
--- /dev/null
+++ b/src/Constraint/ObjectConstraintTest.php
@@ -0,0 +1,234 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversNothing
+ *
+ * @internal
+ */
+final class ObjectConstraintTest extends TestCase
+{
+    public function test_is_converted_optionally_to_json_schema(): void
+    {
+        $constraint = new ObjectConstraint(
+            properties: [
+                'aaa' => new StringConstraint(),
+                'bbb' => new EnumConstraint(['foo']),
+            ]
+        );
+        self::assertEquals([
+            'type' => 'object',
+            'properties' => [
+                'aaa' => [
+                    'type' => 'string',
+                    'minLength' => 1,
+                ],
+                'bbb' => [
+                    'type' => 'string',
+                    'enum' => ['foo'],
+                ],
+            ],
+            'required' => ['aaa', 'bbb'],
+            'additionalProperties' => false,
+        ], $constraint->optional()->toJsonSchema());
+    }
+
+    public function test_is_converted_to_json_schema(): void
+    {
+        $constraint = new ObjectConstraint(
+            properties: [
+                'aaa' => new StringConstraint(),
+                'bbb' => new EnumConstraint(['foo']),
+            ]
+        );
+        self::assertEquals([
+            'type' => 'object',
+            'properties' => [
+                'aaa' => [
+                    'type' => 'string',
+                    'minLength' => 1,
+                ],
+                'bbb' => [
+                    'type' => 'string',
+                    'enum' => ['foo'],
+                ],
+            ],
+            'required' => ['aaa', 'bbb'],
+            'additionalProperties' => false,
+        ], $constraint->toJsonSchema());
+    }
+
+    public function test_is_converted_to_json_schema_with_optionals(): void
+    {
+        $constraint = new ObjectConstraint(
+            properties: [
+                'aaa' => (new StringConstraint())->optional(),
+                'bbb' => (new EnumConstraint(['foo']))->default(null),
+            ]
+        );
+        self::assertEquals([
+            'type' => 'object',
+            'properties' => [
+                'aaa' => [
+                    'type' => 'string',
+                    'minLength' => 1,
+                ],
+                'bbb' => [
+                    'type' => 'string',
+                    'enum' => ['foo'],
+                    'default' => null,
+                ],
+            ],
+            'required' => ['bbb'],
+            'additionalProperties' => false,
+        ], $constraint->toJsonSchema());
+    }
+
+    public function test_nullable_is_converted_to_json_schema(): void
+    {
+        $constraint = new ObjectConstraint(
+            properties: [
+                'aaa' => new StringConstraint(),
+                'bbb' => new EnumConstraint(['foo']),
+            ]
+        );
+        self::assertEquals([
+            'type' => ['null', 'object'],
+            'properties' => [
+                'aaa' => [
+                    'type' => 'string',
+                    'minLength' => 1,
+                ],
+                'bbb' => [
+                    'type' => 'string',
+                    'enum' => ['foo'],
+                ],
+            ],
+            'required' => ['aaa', 'bbb'],
+            'additionalProperties' => false,
+            'default' => null,
+        ], $constraint->nullable()->toJsonSchema());
+    }
+
+    public function test_validates_defaults(): void
+    {
+        $constraint = new ObjectConstraint(
+            properties: [
+                'aaa' => (new StringConstraint())->default('hi'),
+                'bbb' => new EnumConstraint(['foo']),
+            ]
+        );
+
+        $validatedResult = $constraint->validate([
+            'bbb' => 'foo',
+        ]);
+
+        self::assertTrue($validatedResult->status->isValid());
+        self::assertEquals([
+            'aaa' => 'hi',
+            'bbb' => 'foo',
+        ], $validatedResult->castedData);
+    }
+
+    public function test_validates_fail(): void
+    {
+        $constraint = new ObjectConstraint(
+            properties: [
+                'aaa' => new StringConstraint(),
+                'bbb' => new EnumConstraint(['foo']),
+            ]
+        );
+
+        $validatedResult = $constraint->validate([
+            'aaa' => 'hi',
+            'bbb' => 'woot',
+        ]);
+        self::assertFalse($validatedResult->status->isValid());
+        self::assertEquals([
+            '' => 'invalid_nested_constraint',
+            'bbb' => 'invalid_enum_value',
+        ], $validatedResult->getErrors()->toArray());
+    }
+
+    public function test_validates_nullable(): void
+    {
+        $constraint = new ObjectConstraint(
+            properties: [
+                'aaa' => (new StringConstraint())->nullable(),
+                'bbb' => new EnumConstraint(['foo']),
+            ]
+        );
+
+        $validatedResult = $constraint->validate([
+            'bbb' => 'foo',
+        ]);
+
+        self::assertTrue($validatedResult->status->isValid());
+        self::assertEquals([
+            'aaa' => null,
+            'bbb' => 'foo',
+        ], $validatedResult->castedData);
+    }
+
+    public function test_validates_nullable_null(): void
+    {
+        $constraint = new ObjectConstraint(
+            properties: [
+                'aaa' => (new StringConstraint())->nullable(),
+                'bbb' => new EnumConstraint(['foo']),
+            ]
+        );
+
+        $validatedResult = $constraint->validate([
+            'aaa' => null,
+            'bbb' => 'foo',
+        ]);
+
+        self::assertTrue($validatedResult->status->isValid());
+        self::assertEquals([
+            'aaa' => null,
+            'bbb' => 'foo',
+        ], $validatedResult->castedData);
+    }
+
+    public function test_validates_ok(): void
+    {
+        $constraint = new ObjectConstraint(
+            properties: [
+                'aaa' => new StringConstraint(),
+                'bbb' => new EnumConstraint(['foo']),
+            ]
+        );
+
+        $validatedResult = $constraint->validate([
+            'aaa' => 'hi',
+            'bbb' => 'foo',
+        ]);
+
+        self::assertTrue($validatedResult->status->isValid());
+    }
+
+    public function test_validates_optional(): void
+    {
+        $constraint = new ObjectConstraint(
+            properties: [
+                'aaa' => (new StringConstraint())->optional(),
+                'bbb' => new EnumConstraint(['foo']),
+            ]
+        );
+
+        $validatedResult = $constraint->validate([
+            'bbb' => 'foo',
+        ]);
+
+        self::assertTrue($validatedResult->status->isValid());
+        self::assertEquals([
+            'bbb' => 'foo',
+        ], $validatedResult->castedData);
+    }
+}
diff --git a/src/Constraint/StringConstraint.php b/src/Constraint/StringConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..47432d07399406d32bdaec2b23484a9c2d43737b
--- /dev/null
+++ b/src/Constraint/StringConstraint.php
@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\ConstraintDefaultValue;
+use Distantmagic\Resonance\ConstraintPath;
+use Distantmagic\Resonance\ConstraintReason;
+use Distantmagic\Resonance\ConstraintResult;
+use Distantmagic\Resonance\ConstraintResultStatus;
+use Distantmagic\Resonance\ConstraintStringFormat;
+use RuntimeException;
+
+readonly class StringConstraint extends Constraint
+{
+    public function __construct(
+        public ?ConstraintStringFormat $format = null,
+        ?ConstraintDefaultValue $defaultValue = null,
+        bool $isNullable = false,
+        bool $isRequired = true,
+    ) {
+        parent::__construct(
+            defaultValue: $defaultValue,
+            isNullable: $isNullable,
+            isRequired: $isRequired,
+        );
+    }
+
+    public function default(mixed $defaultValue): self
+    {
+        return new self(
+            defaultValue: new ConstraintDefaultValue($defaultValue),
+            format: $this->format,
+            isNullable: $this->isNullable,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function nullable(): self
+    {
+        return new self(
+            defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null),
+            format: $this->format,
+            isNullable: true,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function optional(): self
+    {
+        return new self(
+            defaultValue: $this->defaultValue,
+            format: $this->format,
+            isNullable: $this->isNullable,
+            isRequired: false,
+        );
+    }
+
+    protected function doConvertToJsonSchema(): array
+    {
+        return [
+            'type' => 'string',
+            'minLength' => 1,
+        ];
+    }
+
+    protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
+    {
+        if (!is_string($notValidatedData) || empty($notValidatedData)) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                path: $path,
+                reason: ConstraintReason::InvalidDataType,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        if (!$this->format) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                path: $path,
+                reason: ConstraintReason::Ok,
+                status: ConstraintResultStatus::Valid,
+            );
+        }
+
+        if (ConstraintStringFormat::Uuid === $this->format) {
+            if (uuid_is_valid($notValidatedData)) {
+                return new ConstraintResult(
+                    castedData: $notValidatedData,
+                    path: $path,
+                    reason: ConstraintReason::Ok,
+                    status: ConstraintResultStatus::Valid,
+                );
+            }
+
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                path: $path,
+                reason: ConstraintReason::InvalidFormat,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        throw new RuntimeException('Unknown string format');
+    }
+}
diff --git a/src/Constraint/StringConstraintTest.php b/src/Constraint/StringConstraintTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9d6908a89ce9c16100c5d8f789cb3b357422a14d
--- /dev/null
+++ b/src/Constraint/StringConstraintTest.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use Distantmagic\Resonance\ConstraintStringFormat;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversNothing
+ *
+ * @internal
+ */
+final class StringConstraintTest extends TestCase
+{
+    public function test_is_converted_optionally_to_json_schema(): void
+    {
+        $constraint = new StringConstraint();
+
+        self::assertEquals([
+            'type' => 'string',
+            'minLength' => 1,
+        ], $constraint->optional()->toJsonSchema());
+    }
+
+    public function test_is_converted_to_json_schema(): void
+    {
+        $constraint = new StringConstraint();
+
+        self::assertEquals([
+            'type' => 'string',
+            'minLength' => 1,
+        ], $constraint->toJsonSchema());
+    }
+
+    public function test_nullable_is_converted_to_json_schema(): void
+    {
+        $constraint = new StringConstraint();
+
+        self::assertEquals([
+            'type' => ['null', 'string'],
+            'minLength' => 1,
+            'default' => null,
+        ], $constraint->nullable()->toJsonSchema());
+    }
+
+    public function test_validates(): void
+    {
+        $constraint = new StringConstraint();
+
+        self::assertTrue($constraint->validate('hi')->status->isValid());
+    }
+
+    public function test_validates_uuid(): void
+    {
+        $constraint = new StringConstraint(format: ConstraintStringFormat::Uuid);
+
+        self::assertFalse($constraint->validate('hi')->status->isValid());
+        self::assertTrue($constraint->validate('ccaf9acc-123e-4ff3-85da-1117342b0e02')->status->isValid());
+    }
+}
diff --git a/src/Constraint/TupleConstraint.php b/src/Constraint/TupleConstraint.php
new file mode 100644
index 0000000000000000000000000000000000000000..67a6d96a3436563caab78ef23a99cccb79b55905
--- /dev/null
+++ b/src/Constraint/TupleConstraint.php
@@ -0,0 +1,156 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\ConstraintDefaultValue;
+use Distantmagic\Resonance\ConstraintPath;
+use Distantmagic\Resonance\ConstraintReason;
+use Distantmagic\Resonance\ConstraintResult;
+use Distantmagic\Resonance\ConstraintResultStatus;
+
+final readonly class TupleConstraint extends Constraint
+{
+    /**
+     * @param list<Constraint> $items
+     */
+    public function __construct(
+        public array $items,
+        ?ConstraintDefaultValue $defaultValue = null,
+        bool $isNullable = false,
+        bool $isRequired = true,
+    ) {
+        parent::__construct(
+            defaultValue: $defaultValue,
+            isNullable: $isNullable,
+            isRequired: $isRequired,
+        );
+    }
+
+    public function default(mixed $defaultValue): self
+    {
+        return new self(
+            items: $this->items,
+            defaultValue: new ConstraintDefaultValue($defaultValue),
+            isNullable: $this->isNullable,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function nullable(): self
+    {
+        return new self(
+            items: $this->items,
+            defaultValue: $this->defaultValue ?? new ConstraintDefaultValue(null),
+            isNullable: true,
+            isRequired: $this->isRequired,
+        );
+    }
+
+    public function optional(): self
+    {
+        return new self(
+            items: $this->items,
+            defaultValue: $this->defaultValue,
+            isNullable: $this->isNullable,
+            isRequired: false,
+        );
+    }
+
+    protected function doConvertToJsonSchema(): array
+    {
+        $prefixItems = [];
+
+        foreach ($this->items as $item) {
+            $prefixItems[] = $item->toJsonSchema();
+        }
+
+        return [
+            'type' => 'array',
+            'items' => false,
+            'prefixItems' => $prefixItems,
+        ];
+    }
+
+    protected function doValidate(mixed $notValidatedData, ConstraintPath $path): ConstraintResult
+    {
+        if (!is_array($notValidatedData)) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                path: $path,
+                reason: ConstraintReason::InvalidDataType,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        $i = 0;
+
+        /**
+         * @var list<mixed> explicitly mixed for typechecks
+         */
+        $ret = [];
+
+        /**
+         * @var list<ConstraintResult>
+         */
+        $invalidChildStatuses = [];
+
+        /**
+         * @var mixed $item explicitly mixed for typechecks
+         */
+        foreach ($this->items as $validator) {
+            if (!array_key_exists($i, $notValidatedData)) {
+                if ($validator->defaultValue) {
+                    /**
+                     * @var mixed explicitly mixed for typechecks
+                     */
+                    $ret[] = $validator->defaultValue->defaultValue;
+                } elseif ($validator->isRequired) {
+                    $invalidChildStatuses[] = new ConstraintResult(
+                        castedData: null,
+                        path: $path->fork((string) $i),
+                        reason: ConstraintReason::MissingProperty,
+                        status: ConstraintResultStatus::Invalid,
+                    );
+                } else {
+                    $ret[] = null;
+                }
+            } else {
+                $childResult = $validator->validate(
+                    notValidatedData: $notValidatedData[$i],
+                    path: $path->fork((string) $i)
+                );
+
+                if ($childResult->status->isValid()) {
+                    /**
+                     * @var mixed explicitly mixed for typechecks
+                     */
+                    $ret[] = $childResult->castedData;
+                } else {
+                    $invalidChildStatuses[] = $childResult;
+                }
+            }
+
+            ++$i;
+        }
+
+        if (!empty($invalidChildStatuses)) {
+            return new ConstraintResult(
+                castedData: $notValidatedData,
+                nested: $invalidChildStatuses,
+                path: $path,
+                reason: ConstraintReason::InvalidNestedConstraint,
+                status: ConstraintResultStatus::Invalid,
+            );
+        }
+
+        return new ConstraintResult(
+            castedData: $ret,
+            path: $path,
+            reason: ConstraintReason::Ok,
+            status: ConstraintResultStatus::Valid,
+        );
+    }
+}
diff --git a/src/Constraint/TupleConstraintTest.php b/src/Constraint/TupleConstraintTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9949c265e27e66dbecf616ad4a30ea7b6a0f517c
--- /dev/null
+++ b/src/Constraint/TupleConstraintTest.php
@@ -0,0 +1,133 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\Constraint;
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * @coversNothing
+ *
+ * @internal
+ */
+final class TupleConstraintTest extends TestCase
+{
+    public function test_is_converted_optionally_to_json_schema(): void
+    {
+        $constraint = new TupleConstraint(
+            items: [
+                new StringConstraint(),
+                new EnumConstraint(['foo']),
+            ]
+        );
+        self::assertEquals([
+            'type' => 'array',
+            'items' => false,
+            'prefixItems' => [
+                [
+                    'type' => 'string',
+                    'minLength' => 1,
+                ],
+                [
+                    'type' => 'string',
+                    'enum' => ['foo'],
+                ],
+            ],
+        ], $constraint->optional()->toJsonSchema());
+    }
+
+    public function test_is_converted_to_json_schema(): void
+    {
+        $constraint = new TupleConstraint(
+            items: [
+                new StringConstraint(),
+                new NumberConstraint(),
+            ]
+        );
+
+        self::assertEquals([
+            'type' => 'array',
+            'items' => false,
+            'prefixItems' => [
+                [
+                    'type' => 'string',
+                    'minLength' => 1,
+                ],
+                [
+                    'type' => 'number',
+                ],
+            ],
+        ], $constraint->toJsonSchema());
+    }
+
+    public function test_nullable_is_converted_to_json_schema(): void
+    {
+        $constraint = new TupleConstraint(
+            items: [
+                new StringConstraint(),
+                new NumberConstraint(),
+            ]
+        );
+
+        self::assertEquals([
+            'type' => ['null', 'array'],
+            'items' => false,
+            'prefixItems' => [
+                [
+                    'type' => 'string',
+                    'minLength' => 1,
+                ],
+                [
+                    'type' => 'number',
+                ],
+            ],
+            'default' => null,
+        ], $constraint->nullable()->toJsonSchema());
+    }
+
+    public function test_validates_fail(): void
+    {
+        $constraint = new TupleConstraint(
+            items: [
+                new StringConstraint(),
+                new NumberConstraint(),
+            ]
+        );
+
+        $validatedResult = $constraint->validate(['hi', 'ho']);
+
+        self::assertFalse($validatedResult->status->isValid());
+        self::assertEquals([
+            '' => 'invalid_nested_constraint',
+            '1' => 'invalid_data_type',
+        ], $validatedResult->getErrors()->toArray());
+    }
+
+    public function test_validates_ok(): void
+    {
+        $constraint = new TupleConstraint(
+            items: [
+                new StringConstraint(),
+                new NumberConstraint(),
+            ]
+        );
+
+        self::assertTrue($constraint->validate(['hi', 5])->status->isValid());
+        self::assertFalse($constraint->validate(['hi', 'ho'])->status->isValid());
+    }
+
+    public function test_validates_ok_default(): void
+    {
+        $constraint = new TupleConstraint(
+            items: [
+                new EnumConstraint(['hi', 'ho']),
+                new AnyConstraint(),
+                (new StringConstraint())->nullable(),
+            ]
+        );
+
+        self::assertTrue($constraint->validate(['hi', 5])->status->isValid());
+        self::assertTrue($constraint->validate(['hi', 5, null])->status->isValid());
+    }
+}
diff --git a/src/ConstraintDefaultValue.php b/src/ConstraintDefaultValue.php
new file mode 100644
index 0000000000000000000000000000000000000000..3718b1ebc83a0c316a88c109ee47c5888f8467ec
--- /dev/null
+++ b/src/ConstraintDefaultValue.php
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+readonly class ConstraintDefaultValue
+{
+    public function __construct(public mixed $defaultValue) {}
+}
diff --git a/src/ConstraintPath.php b/src/ConstraintPath.php
new file mode 100644
index 0000000000000000000000000000000000000000..ba705907bf79b47f0e60a9911a996e53ca60dabb
--- /dev/null
+++ b/src/ConstraintPath.php
@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use Stringable;
+
+readonly class ConstraintPath implements Stringable
+{
+    /**
+     * @param array<string> $path
+     */
+    public function __construct(private array $path = []) {}
+
+    public function __toString(): string
+    {
+        return implode('.', $this->path);
+    }
+
+    public function fork(string $next): self
+    {
+        return new self([...$this->path, $next]);
+    }
+}
diff --git a/src/ConstraintReason.php b/src/ConstraintReason.php
new file mode 100644
index 0000000000000000000000000000000000000000..bc85854314c7b19337e5115f872272a3088b55b8
--- /dev/null
+++ b/src/ConstraintReason.php
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+enum ConstraintReason: string
+{
+    case InvalidDataType = 'invalid_data_type';
+    case InvalidEnumValue = 'invalid_enum_value';
+    case InvalidFormat = 'invalid_format';
+    case InvalidNestedConstraint = 'invalid_nested_constraint';
+    case MissingProperty = 'missing_property';
+    case Ok = 'ok';
+}
diff --git a/src/ConstraintResult.php b/src/ConstraintResult.php
new file mode 100644
index 0000000000000000000000000000000000000000..88a3c3d29fc4e6bcbf94cd0913a6e4333019bf68
--- /dev/null
+++ b/src/ConstraintResult.php
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use Ds\Map;
+
+readonly class ConstraintResult
+{
+    /**
+     * @param array<ConstraintResult> $nested
+     */
+    public function __construct(
+        public mixed $castedData,
+        public ConstraintPath $path,
+        public ConstraintResultStatus $status,
+        public ConstraintReason $reason,
+        public array $nested = [],
+    ) {}
+
+    /**
+     * @param Map<string,non-empty-string> $errors
+     *
+     * @return Map<string,non-empty-string>
+     */
+    public function getErrors(Map $errors = new Map()): Map
+    {
+        if ($this->status->isValid()) {
+            return $errors;
+        }
+
+        $errors->put((string) $this->path, $this->reason->value);
+
+        foreach ($this->nested as $nestedResult) {
+            $nestedResult->getErrors($errors);
+        }
+
+        return $errors;
+    }
+}
diff --git a/src/ConstraintResultStatus.php b/src/ConstraintResultStatus.php
new file mode 100644
index 0000000000000000000000000000000000000000..bae7fb2e682fe4b40092a5ba789ae3bdfc9fa22d
--- /dev/null
+++ b/src/ConstraintResultStatus.php
@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+enum ConstraintResultStatus
+{
+    case Invalid;
+    case Valid;
+
+    public function isValid(): bool
+    {
+        return ConstraintResultStatus::Valid === $this;
+    }
+}
diff --git a/src/ConstraintStringFormat.php b/src/ConstraintStringFormat.php
new file mode 100644
index 0000000000000000000000000000000000000000..4df07ee764075ef6f46d28a9e99115567858dc29
--- /dev/null
+++ b/src/ConstraintStringFormat.php
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+enum ConstraintStringFormat
+{
+    case Uuid;
+}
diff --git a/src/HttpControllerReflectionMethod.php b/src/HttpControllerReflectionMethod.php
index 3a407b9230cf6fe9aecaa5dffdfb0fd49f7f541f..76f3fba19db80c7d021e7f06e921625d04f7eece 100644
--- a/src/HttpControllerReflectionMethod.php
+++ b/src/HttpControllerReflectionMethod.php
@@ -53,9 +53,13 @@ readonly class HttpControllerReflectionMethod
                 );
             }
 
-            if ($returnType->isBuiltin() && 'void' !== $returnType->getName()) {
+            if ($returnType->isBuiltin()) {
+                if ('void' === $returnType->getName()) {
+                    return;
+                }
+
                 throw new HttpControllerMetadataException(
-                    'Only supported return type',
+                    'Only supported builtin return type is "void"',
                     $this->reflectionMethod,
                 );
             }
@@ -71,7 +75,7 @@ readonly class HttpControllerReflectionMethod
 
             throw new HttpControllerMetadataException(
                 sprintf(
-                    'Controller handle can only return null or %s or %s',
+                    'Controller handle can only return null or "%s" or "%s"',
                     HttpResponderInterface::class,
                     HttpInterceptableInterface::class,
                 ),
diff --git a/src/InputValidationResult.php b/src/InputValidationResult.php
index 18b72404ea767008e8f2d3cf0d6a47a453d5c24a..36c9ac51c8ede9a69f7bd25527d172f290a3190a 100644
--- a/src/InputValidationResult.php
+++ b/src/InputValidationResult.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Distantmagic\Resonance;
 
 use Ds\Map;
+use Ds\Set;
 
 /**
  * @template TValidatedModel of InputValidatedData
@@ -12,7 +13,7 @@ use Ds\Map;
 readonly class InputValidationResult
 {
     /**
-     * @var Map<string,string> $errors
+     * @var Map<non-empty-string,Set<non-empty-string>> $errors
      */
     public Map $errors;
 
diff --git a/src/InputValidatorController.php b/src/InputValidatorController.php
index 8793da2b8190917e442e7755fc7b05811af44679..91ed354de016b2d8cfaf7fc1614e3ada44d28420 100644
--- a/src/InputValidatorController.php
+++ b/src/InputValidatorController.php
@@ -53,7 +53,7 @@ readonly class InputValidatorController
         foreach ($errors as $propertyName => $propertyErrors) {
             $validationResult->errors->put(
                 $propertyName,
-                implode("\n", $propertyErrors),
+                $propertyErrors,
             );
         }
 
diff --git a/src/JsonSchemaValidationErrorMessage.php b/src/JsonSchemaValidationErrorMessage.php
index 994ba6f4b975ba169be6c382d7a869cd94156eda..9f5036246156d9dcdce050d11a96c21640b07cb7 100644
--- a/src/JsonSchemaValidationErrorMessage.php
+++ b/src/JsonSchemaValidationErrorMessage.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Ds\Set;
 use Stringable;
 
 readonly class JsonSchemaValidationErrorMessage implements Stringable
@@ -11,7 +12,7 @@ readonly class JsonSchemaValidationErrorMessage implements Stringable
     public string $message;
 
     /**
-     * @param array<string,array<string>> $errors
+     * @param array<non-empty-string,Set<non-empty-string>> $errors
      */
     public function __construct(array $errors)
     {
diff --git a/src/JsonSchemaValidationException.php b/src/JsonSchemaValidationException.php
index 3c2be17594ddcf501028f3d6e3c0b8b863bdbbde..9f81f837fcaf66848ea3f0beab8a236f003f1e9d 100644
--- a/src/JsonSchemaValidationException.php
+++ b/src/JsonSchemaValidationException.php
@@ -4,12 +4,13 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Ds\Set;
 use RuntimeException;
 
 class JsonSchemaValidationException extends RuntimeException
 {
     /**
-     * @param array<string,array<string>> $errors
+     * @param array<non-empty-string,Set<non-empty-string>> $errors
      */
     public function __construct(array $errors)
     {
diff --git a/src/JsonSchemaValidationResult.php b/src/JsonSchemaValidationResult.php
index 0120fd6e7189c032698636586dd95bd2e6cda265..db8b14cb002ffa5ce22ecff834796697a09f93b2 100644
--- a/src/JsonSchemaValidationResult.php
+++ b/src/JsonSchemaValidationResult.php
@@ -4,14 +4,16 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Ds\Set;
+
 /**
  * @template TValidatedData
  */
 readonly class JsonSchemaValidationResult
 {
     /**
-     * @param TValidatedData              $data
-     * @param array<string,array<string>> $errors
+     * @param TValidatedData                                $data
+     * @param array<non-empty-string,Set<non-empty-string>> $errors
      */
     public function __construct(
         public mixed $data,
diff --git a/src/JsonSchemaValidator.php b/src/JsonSchemaValidator.php
index 1af23bc770c2fe9f21bd7f054f7ce122c2ca8ed5..713e64a59fdefb78cde177ef06fc594e29bbc0ed 100644
--- a/src/JsonSchemaValidator.php
+++ b/src/JsonSchemaValidator.php
@@ -6,7 +6,9 @@ namespace Distantmagic\Resonance;
 
 use Distantmagic\Resonance\Attribute\Singleton;
 use Ds\Map;
+use Ds\Set;
 use Opis\JsonSchema\Errors\ErrorFormatter;
+use Opis\JsonSchema\Errors\ValidationError;
 use Opis\JsonSchema\Helper;
 use Opis\JsonSchema\Parsers\SchemaParser;
 use Opis\JsonSchema\Schema;
@@ -91,7 +93,7 @@ readonly class JsonSchemaValidator
     }
 
     /**
-     * @return array<string,array<string>>
+     * @return array<non-empty-string,Set<non-empty-string>>
      */
     private function formatErrors(ValidationResult $validationResult): array
     {
@@ -102,8 +104,74 @@ readonly class JsonSchemaValidator
         }
 
         /**
-         * @var array<string,array<string>>
+         * @var list<array{
+         *     message: non-empty-string,
+         *     keywords: list<non-empty-string>,
+         *     path: non-empty-string,
+         * }>
          */
-        return $this->errorFormatter->formatKeyed($error);
+        $nestedFormat = [];
+
+        $this->errorFormatter->formatNested(
+            error: $error,
+            formatter: function (ValidationError $validationError) use (&$nestedFormat) {
+                $fullPath = implode('.', $validationError->data()->fullPath());
+
+                /**
+                 * @var array<non-empty-string> $keywords
+                 */
+                $keywords = [
+                    $validationError->keyword(),
+                ];
+
+                $args = $validationError->args();
+
+                /**
+                 * @var non-empty-string $keyword
+                 * @var list<string>     $paths
+                 */
+                foreach ($args as $keyword => $paths) {
+                    $keywords[] = $keyword;
+
+                    foreach ($paths as $path) {
+                        if (empty($fullPath)) {
+                            $fullPath = $path;
+                        } else {
+                            throw new RuntimeException('Ambigous error path');
+                        }
+                    }
+                }
+
+                if (empty($fullPath)) {
+                    throw new RuntimeException('Unable to determine error path');
+                }
+
+                $nestedFormat[] = [
+                    'message' => $this->errorFormatter->formatErrorMessage($validationError),
+                    'keywords' => $keywords,
+                    'path' => $fullPath,
+                ];
+            },
+        );
+
+        /**
+         * @var array<non-empty-string,Set<non-empty-string>>
+         */
+        $ret = [];
+
+        foreach ($nestedFormat as $error) {
+            if (!isset($ret[$error['path']])) {
+                /**
+                 * @var Set<non-empty-string>
+                 */
+                $ret[$error['path']] = new Set();
+            }
+
+            foreach ($error['keywords'] as $keyword) {
+                $ret[$error['path']]->add($keyword);
+            }
+        }
+
+        return $ret;
     }
 }
diff --git a/src/JsonSchemableInterface.php b/src/JsonSchemableInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..7fe349fecc56d93e79ec3a682b478c58c4062f39
--- /dev/null
+++ b/src/JsonSchemableInterface.php
@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance;
+
+use stdClass;
+
+/**
+ * @psalm-type PJsonSchema = stdClass|array{
+ *     const?: int|float|non-empty-string,
+ *     default?: mixed,
+ *     type?: non-empty-string|list<non-empty-string>,
+ *     ...
+ * }
+ */
+interface JsonSchemableInterface
+{
+    /**
+     * @return PJsonSchema
+     */
+    public function toJsonSchema(): array|object;
+}