Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,53 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and

Released versions correspond to `v<version>` GitHub releases.

## 2.4.0

### Added

- **Dictionary validation.** A property whose type implements `IDictionary<TKey, TValue>` or
`IReadOnlyDictionary<TKey, TValue>` (`Dictionary`, `SortedDictionary`, `ConcurrentDictionary`,
`ImmutableDictionary`, custom implementations) is now a map rather than a sequence. The generated validator
iterates the entries, validates every **value** against the value type's nested schema — discovered
recursively, with or without its own `[ZodSchema]` — and reports a failure at the entry's key
(`["Variants", "blue"]`), matching Zod's `z.record` issue path. Keys are used only in error paths.
`AllowNulls`, and therefore `[CollectionBehavior]`, `[ZodSchema(AllowNulls = ...)]`, the assembly default
and `ZodSharpAllowNulls`, now govern a map's values. `[Length]`/`[MinLength]`/`[MaxLength]` count entries.
- **Dictionary JSON Schema export.** A dictionary exports as
`{ "type": "object", "additionalProperties": <value> }` instead of an array of pairs, and its size
constraints export as `minProperties`/`maxProperties` (new `JsonSchemaDefinition.MinProperties` and
`MaxProperties` members). A nullable value exports a `null` branch on `additionalProperties`.
- **Record JSON Schema export and import.** `Z.ToJsonSchema(Z.Record(valueSchema))` now exports the same
object/`additionalProperties` shape, and `Z.FromJsonSchema` imports an object with `additionalProperties`
as a record in both JSON integration packages (`additionalProperties: true` treats every member as `any`).
See [JSON Schema Export](docs/wiki/JsonSchema-Export.md#dictionaries) and
[JSON Schema Import](docs/wiki/JsonSchema-Import.md#records).
- **`IEnumerable<T>` properties validate their elements.** A property declared as the enumerable interface
itself now resolves its element type, validates elements and exports its `items` schema, like `T[]`.

### Fixed

- **Collection element resolution for dictionaries.** The element type was taken from whichever
`IEnumerable`-derived interface appeared first, so a `Dictionary<string, T>` resolved its **key** type
(`string`) as the element. The generated validator then emitted `if (item is null)` over a
`KeyValuePair<string, T>` (`CS0037`), exported `items: {"type":"string"}`, and skipped the value schema
entirely. The per-entry type now comes from `IEnumerable<T>` / the dictionary value.
- **`ZodRecord` null values.** A null value is now the value schema's decision: `Z.Record(Z.String())`
rejects it (matching `z.record(z.string(), T)`), while a nullable value schema accepts it. Previously null
values were silently accepted and dropped. Re-keyed errors also preserve the inner issue's `origin`,
`minimum`, `maximum`, `inclusive` and `category`.
- **Nullable value-type elements export their base schema.** `List<int?>` / `Dictionary<string, int?>` now
export `integer` (plus the `null` branch where allowed) instead of the empty schema `Nullable<int>`
produced.

### Upgrade notes

- A `Dictionary<TKey, TValue>` property is now validated per value rather than per key. If a dictionary was
relying on the old (incorrect) behaviour, add the value type's `[ZodSchema]` where desired; no source change
is required otherwise.
- `Z.Record(nonNullableValueSchema)` now rejects a null dictionary value. Use a nullable/optional value
schema to keep accepting one.

## 2.3.0

### Added
Expand Down
170 changes: 60 additions & 110 deletions bun.lock

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion docs/wiki/Arrays-and-Other-Schemas.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ var schema2 = Z.Literal(42);
var schema = Z.Record(Z.Number());
```

A null value is the value schema's decision: `Z.Record(Z.String())` rejects it, while
`Z.Record(Z.Optional(Z.String()))` accepts it. The schema exports and imports as
`{ "type": "object", "additionalProperties": … }` (see
[JSON Schema Export](JsonSchema-Export.md#dictionaries)), and a generated `Dictionary<TKey, TValue>`
property is validated the same way.

## Tuple

`ZodTuple` validates fixed-length tuples. Two- and three-element overloads exist; input is `object?[]`.
Expand Down Expand Up @@ -119,4 +125,4 @@ optional.Validate(null); // Success
optional.Validate("value"); // Success
```

For a generated collection, element nullability follows the element type: `T[]`/`List<T>` rejects `null` elements, while `T?[]`/`List<T?>` accepts them — the same distinction as `z.array(T)` versus `z.array(T.nullable())`. See [Collection null items](Source-Generator.md#collection-null-items).
For a generated collection, element nullability follows the element type: `T[]`/`List<T>` rejects `null` elements, while `T?[]`/`List<T?>` accepts them — the same distinction as `z.array(T)` versus `z.array(T.nullable())`. A dictionary property (`IDictionary<,>`/`IReadOnlyDictionary<,>`) is a map instead: values are validated like elements, and it maps to `z.record(z.string(), V)`. See [Collection null items](Source-Generator.md#collection-null-items).
7 changes: 7 additions & 0 deletions docs/wiki/Cross-Platform-Interop.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ The same interop goal is available without fixtures via JSON Schema:

A generated collection maps to Zod's array element type: `T[]` / `List<T>` ↔ `z.array(T)`, and a nullable element (`T?[]`, `List<T?>`, or a property that allows nulls via `[CollectionBehavior(AllowNulls = true)]`) ↔ `z.array(T.nullable())`. A collection whose elements may be null exports `items.anyOf: [ …, { "type": "null" } ]`, and both `z.fromJSONSchema` and `Z.FromJsonSchema` read that shape back. Null elements are rejected by default, so a generated `T[]` matches `z.array(T)` exactly — see [Collection null items](Source-Generator.md#collection-null-items).

A dictionary property (`IDictionary<TKey, TValue>` / `IReadOnlyDictionary<TKey, TValue>`) is a map:
`Dictionary<string, V>` ↔ `z.record(z.string(), V)`. The generated schema exports
`{ "type": "object", "additionalProperties": … }` (with `minProperties`/`maxProperties` for size constraints),
and its failures use the same key-prefixed paths as `z.record`. Both `z.fromJSONSchema` and
`Z.FromJsonSchema` read that shape back as a record — see
[JSON Schema Export](JsonSchema-Export.md#dictionaries).

The temporal parity suite (`tests/ts/temporal-parity.test.ts`) proves the same loop for `[NonSentinel]` on
`DateOnly`, `TimeOnly`, `DateTime`, and `DateTimeOffset` under the default preserve-offset contract, including
the offset-equivalent bounds. The C# producer
Expand Down
25 changes: 24 additions & 1 deletion docs/wiki/JsonSchema-Export.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ var jsonSchema = Z.ToJsonSchema<Dictionary<string, object?>>(userSchema, new ToJ

## Supported schema types

`ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, and `ZodLazy`.
`ToJsonSchemaConverter` handles `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, `ZodUnion`, `ZodArray`, `ZodRecord`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, and `ZodLazy`.

- Objects emit `additionalProperties: false`.
- Records (`Z.Record(valueSchema)`) emit `{ "type": "object", "additionalProperties": <value> }`.
- Literals emit `const` (and `type`).
- Native enums emit `enum` with the defined members in their System.Text.Json wire form: the numeric values by default, or the member names when the enum is serialized as a string. A `[Flags]` enum emits `type: integer` instead, because a combination is valid without being a defined member.
- Lazy/recursive schemas emit `$ref` entries under `$defs` (e.g. `#/$defs/__lazyN`).
Expand Down Expand Up @@ -125,6 +126,28 @@ A type that exports JSON Schema must not reference a type whose export is disabl
analyzer reports **ZODSGEN045** naming the property and the disabled type, so the gap is surfaced at build
time rather than shipped silently.

## Dictionaries

System.Text.Json writes a dictionary as a JSON object, so a generated dictionary property
(`IDictionary<TKey, TValue>` / `IReadOnlyDictionary<TKey, TValue>`) exports an object rather than an array:

```json
"variants": {
"type": "object",
"additionalProperties": { "type": "object", "properties": { … } }
}
```

- `additionalProperties` is the **value** type's schema. A nested `[ZodSchema]` value type is referenced
inline, exactly as an element schema is for an array; a type whose export is disabled falls back to `{}`
and reports `ZODSGEN045`.
- A nullable value (`Dictionary<string, Child?>`, `Dictionary<string, int?>`, or
`[CollectionBehavior(AllowNulls = true)]`) wraps the value schema in
`anyOf: [ …, { "type": "null" } ]`, mirroring the sequence `items` branch.
- `[Length]`/`[MinLength]`/`[MaxLength]` export `minProperties`/`maxProperties` (not `minItems`/`maxItems`).
- The TypeScript counterpart is `z.record(z.string(), V)`. Importing an object with `additionalProperties`
produces a record schema — see [JSON Schema Import](JsonSchema-Import.md#records).

## Validation rules and JSON Schema

Every rule that can describe its own JSON Schema constraint implements `ZodSharp.JsonSchema.IJsonSchemaRule`.
Expand Down
21 changes: 21 additions & 0 deletions docs/wiki/JsonSchema-Import.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ var result = userSchema.Validate(userData);
- Numeric constraints — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`.
- Objects — `required` and optional fields via `Z.Object().Field(...)`.
- Arrays — `items`, `minItems`, `maxItems`. A nullable item schema (`items.anyOf` containing a `{"type":"null"}` branch) imports as a nullable element, matching `z.array(T.nullable())` and the shape the generated exporter emits for an allow-null collection.
- Records — an object with no `properties` and an `additionalProperties` schema imports as a record: every member is validated by the value schema, and a failure is reported at the member's key (`["a"]`). `additionalProperties: true` treats every member as `any`; `false` (and an absent value) keeps the object contract.

## Records

```csharp
var schema = Z.FromJsonSchema(
"""
{
"type": "object",
"additionalProperties": { "type": "string", "minLength": 1 }
}
"""
);

schema.Validate(new Dictionary<string, object?> { ["a"] = "x" }); // Success
schema.Validate(new Dictionary<string, object?> { ["a"] = "" }); // Failure at ["a"]
```

This is the shape the generated exporter writes for a dictionary property, so a generated dictionary schema
round-trips through `Z.FromJsonSchema` in either integration package. The result is a record schema (the
`Z.Record` shape), not an object with fixed properties.

## Limitations

Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Release-Flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Purview.ZodSharp releases are driven by the shared [purview-dev/build](https://g

## Versioning

The package version comes from `package.json` (`version` field). The current stable line is `2.2.0`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix).
The package version comes from `package.json` (`version` field). The current stable line is `2.4.0`; bump `package.json` to release a new version (prerelease builds use a `MAJOR.MINOR.PATCH-prerelease.N` suffix).

Package identities are `Purview.ZodSharp.*` (core, SystemTextJson, NewtonsoftJson, AspNetCore). Central package management lives in `Directory.Packages.props`; package versions there are minimum requirements, not exact pins, so the resolved graph can drift.

Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/Source-Generator-DataAnnotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ var result = BasketSchema.Validate(new Basket { Items = ["apple"] });

## Collection null items

A collection property rejects `null` elements by default: the generated validator reports `invalid_type` at the element's own path (`["Items", 0]`). Control it per property with `[CollectionBehavior(AllowNulls = true/false)]`, per element type with `T?`, per type with `[ZodSchema(AllowNulls = ...)]`, or per assembly with `[assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]` and the `ZodSharpAllowNulls` MSBuild property. The most specific statement wins. See [Collection null items](Source-Generator.md#collection-null-items) for the full precedence, and `ZODSGEN046` for the attribute-applicability warning.
A collection property rejects `null` elements by default: the generated validator reports `invalid_type` at the element's own path (`["Items", 0]`). Control it per property with `[CollectionBehavior(AllowNulls = true/false)]`, per element type with `T?`, per type with `[ZodSchema(AllowNulls = ...)]`, or per assembly with `[assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]` and the `ZodSharpAllowNulls` MSBuild property. The most specific statement wins. A dictionary property (`IDictionary<,>`/`IReadOnlyDictionary<,>`) applies the same setting to its **values**, and reports a failure at the entry's key (`["Items", "a"]`). See [Collection null items](Source-Generator.md#collection-null-items) for the full precedence, and `ZODSGEN046` for the attribute-applicability warning.

## Range

Expand Down
64 changes: 41 additions & 23 deletions docs/wiki/Source-Generator.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ All options are optional.
| `SuppressIValidateOptions` | `false` | Opt out even when auto-detection would enable it. |
| `ValidateEnumValues` | `true` | Set to `false` to skip the automatic enum validation for the type's enum properties. |
| `GenerateJsonSchema` | `true` | Set to `false` to omit the JSON Schema export members (`BuildJsonSchema` and the `IJsonSchemaProvider` on the adapter), keeping the type out of the cross-platform contract surface. A type referenced by an exported type is reported as `ZODSGEN045`. |
| `AllowNulls` | unset | Whether this type's collection properties may contain `null` elements. Unset applies the assembly default; `true`/`false` overrides it for this type. See [Collection null items](#collection-null-items). |
| `AllowNulls` | unset | Whether this type's collection properties may contain `null` elements (or `null` dictionary values). Unset applies the assembly default; `true`/`false` overrides it for this type. See [Collection null items](#collection-null-items). |

> [!NOTE]
> `Parse`, the value-first composition methods (`ApplyAnd`/`ApplyOr`/`ApplyRefine`), the `IZodSchemaValidator` adapter and the `IValidateOptions` validator all depend on `Validate`. Setting `GenerateValidateMethod = false` omits them together.
Expand Down Expand Up @@ -172,6 +172,46 @@ A collection property rejects `null` elements by default: the generated validato

Precedence, highest first: the property attribute, the element's `T?` annotation, the containing type's `[ZodSchema]` option, the assembly attribute, the `ZodSharpAllowNulls` MSBuild property, then the built-in default (reject).

### Dictionaries

A property whose type implements `IDictionary<TKey, TValue>` or `IReadOnlyDictionary<TKey, TValue>` is a
**map**, not a sequence: `Dictionary`, `SortedDictionary`, `ConcurrentDictionary`, `ImmutableDictionary`
and custom implementations all qualify. The generated validator iterates the entries, validates each
**value** against the value type's nested schema (discovered recursively, with or without its own
`[ZodSchema]`), and reports a failure at the entry's key — `["Variants", "blue"]` — matching Zod's
`z.record` issue path. Keys are used only in error paths; they are not validated.

The null-items setting above applies to a map's **values**: `Dictionary<string, Child>` rejects a null
value by default, while `Dictionary<string, Child?>` or `[CollectionBehavior(AllowNulls = true)]` accepts
one. `Dictionary<string, int>` has no null case at all, so `[CollectionBehavior]` on it is reported as
`ZODSGEN046`. `[Length]`/`[MinLength]`/`[MaxLength]` count entries, exactly as they count elements for a
sequence.

```csharp
using ZodSharp;

[assembly: ZodSharpSchemaDefaults(AllowNulls = false)]

[ZodSchema]
public class RepositoryInventoryResponse
{
// Rejected by default: a null element reports invalid_type at ["Items", index].
public RepositoryInventoryItem[] Items { get; set; } = [];

// Explicit opt-in for one property.
[CollectionBehavior(AllowNulls = true)]
public RepositoryInventoryItem[] OptionalItems { get; set; } = [];

// A map: the setting governs null values, and failures are reported at ["Variants", key].
public Dictionary<string, VariantConfiguration> Variants { get; set; } = [];
}
```

> [!NOTE]
> `[CollectionBehavior]` and `[ZodSchema(AllowNulls = ...)]` only take effect when the named argument is present; an attribute that leaves it out defers to the next level. `[CollectionBehavior]` on a property that is not a collection of nullable elements (or a dictionary of nullable values) is reported as `ZODSGEN046`.

A collection whose elements may be null exports its `items` schema with a `null` branch, mirroring how a nullable property is exported. A dictionary exports `{ "type": "object", "additionalProperties": … }` — see [JSON Schema Export](JsonSchema-Export.md#dictionaries).

## DateTimeOffset wire form

The JSON Schema generated for `[NonSentinel] DateTimeOffset` depends on the wire form assumed for the value.
Expand Down Expand Up @@ -211,28 +251,6 @@ package's `UtcDateTimeOffsetConverter` (and `PreserveOffset` under a global UTC
narrows the accepted wire form to a zero offset. See
[JSON Schema Export](JsonSchema-Export.md#temporal-sentinels-and-the-datetimeoffset-wire-form).

```csharp
using ZodSharp;

[assembly: ZodSharpSchemaDefaults(AllowNulls = false)]

[ZodSchema]
public class RepositoryInventoryResponse
{
// Rejected by default: a null element reports invalid_type at ["Items", index].
public RepositoryInventoryItem[] Items { get; set; } = [];

// Explicit opt-in for one property.
[CollectionBehavior(AllowNulls = true)]
public RepositoryInventoryItem[] OptionalItems { get; set; } = [];
}
```

> [!NOTE]
> `[CollectionBehavior]` and `[ZodSchema(AllowNulls = ...)]` only take effect when the named argument is present; an attribute that leaves it out defers to the next level. `[CollectionBehavior]` on a property that is not a collection of nullable elements is reported as `ZODSGEN046`.

A collection whose elements may be null exports its `items` schema with a `null` branch, mirroring how a nullable property is exported.

## Automatic enum validation

Every non-flags enum property is validated automatically: the generated validator rejects a value that is not a defined member of the enum type. The check is emitted as an `EnumRule<TEnum>` (see [Validation Rules Reference](Validation-Rules-Reference.md#enum-rules)) and reports `invalid_enum_value`.
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zodsharp",
"version": "2.3.0",
"version": "2.4.0",
"private": true,
"license": "MIT",
"author": {
Expand All @@ -21,8 +21,8 @@
"generate-fixtures": "bun run src/ts/generate-fixtures.ts"
},
"devDependencies": {
"vitest": "^5.0.0",
"zod": "^4.6.2"
"vitest": "^5.0.5",
"zod": "^4.6.7"
},
"engines": {
"bun": ">=1.4.2"
Expand Down
2 changes: 1 addition & 1 deletion skills/purview-zodsharp-diagnostics/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ All of these mean the method the generator was told to call cannot be called:

| ID | Meaning |
| --- | --- |
| `ZODSGEN046` | *(warning)* `[CollectionBehavior]` is applied to a property that is not a collection of nullable elements (a scalar, or a collection whose element cannot be `null`), so the setting can never take effect. |
| `ZODSGEN046` | *(warning)* `[CollectionBehavior]` is applied to a property that is not a collection of nullable elements or values (a scalar, a collection whose element cannot be `null`, or a dictionary whose value cannot be `null`), so the setting can never take effect. |

## Unions, value objects, JSON Schema

Expand Down
5 changes: 5 additions & 0 deletions skills/purview-zodsharp-json/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ Things worth knowing:
`z.array(T.nullable())`. A collection whose elements may be null exports
`items.anyOf: [ …, { "type": "null" } ]`; by default the generated validator rejects a null element with
`invalid_type` at the element's path, matching `z.array(T)`.
- **Dictionaries map to `z.record(z.string(), V)`.** A property implementing `IDictionary<,>` or
`IReadOnlyDictionary<,>` exports `{ "type": "object", "additionalProperties": <V> }` (a nullable value
wraps that value schema in a `null` branch), and its size constraints use `minProperties`/`maxProperties`.
`Z.Record(schema)` exports the same shape, and `Z.FromJsonSchema` reads an object with
`additionalProperties` back as a record in both JSON integrations.

## JSON Schema import

Expand Down
Loading
Loading