Skip to content
Open
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
407 changes: 407 additions & 0 deletions docs/design/zod-4.6-integration.md

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions docs/src/content/docs/packages/typegen/emitters/tanstack-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ export function invalidateWorkflowQueries(queryClient: QueryClient) {
| `ApiClientImportPath` | `null` | Import a custom client instead of emitting `apiFetch` |
| `ApiClientName` | `apiFetch` | Default or imported client function name |
| `ModelsImportPath` | computed | Force model type imports from one module |
| `SchemasImportPath` | computed | Force generated Zod schema imports from one module |
| `PayloadValidation` | `None` | `None`, `Responses`, or `RequestsAndResponses` runtime parsing |
| `EmitQueryOptions` | `true` | Emit `queryOptions(...)` helpers |
| `EmitMutationOptions` | `true` | Emit `mutationOptions(...)` helpers |
| `EmitHooks` | `true` | Emit `useQuery` / `useMutation` wrappers |
Expand Down Expand Up @@ -238,6 +240,23 @@ arrays as repeated query-string keys and JSON-serializes request bodies.
Route and query parameter types use the same primitive mapping as generated
models; notably `decimal` maps to `string` to preserve precision.

## Runtime payload validation

Add `TypeTarget.Zod` to the request/response DTOs, install `zod@^4.6.1`, and opt in:

```csharp
b.TanStackQuery(q =>
{
q.PayloadValidation = QueryPayloadValidation.RequestsAndResponses;
// Optional when TypeGen can compute the relative schema paths:
q.SchemasImportPath = "../validation/schemas";
});
```

`Responses` parses successful API payloads before returning them to TanStack
Query. `RequestsAndResponses` additionally parses JSON request bodies before
they are sent. `None` preserves the existing zero-Zod-dependency client.

## Naming

For Minimal APIs, prefer `.WithName("searchWorkItems")` and `.WithTags("Workflow")`.
Expand Down
39 changes: 35 additions & 4 deletions docs/src/content/docs/packages/typegen/emitters/zod.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ export const OrderSchema = z.object({
export type Order = z.infer<typeof OrderSchema>;
```

**Independent from TypeScript emitter.** Both files are generated from the same
`SchemaModel`, so drift is structurally impossible — change the C# class and
both regen identically on the next build. The TS interface stays as the
**Independent from TypeScript emitter by default.** Both files are generated from the same
`SchemaModel` and regenerate together on the next build. The TS interface stays as the
ergonomic type-only view (cheap to import, no runtime dep); the Zod schema
carries the runtime validator and its own `z.infer` alias for Zod-only consumers.
Enable `ConformToTypeScriptTypes` when you also want `z.toZod<T>()` to make the
TypeScript compiler prove that both generated shapes match.

## Validation constraint mapping

Expand All @@ -62,6 +63,8 @@ Same attributes that drive OpenAPI constraints map to Zod chained calls:
| `[RegularExpression("pat")]`, `[ZMatch("pat")]` | `.regex(/pat/)` |
| `[EmailAddress]`, `[ZEmail]` | `z.email()` |
| `[Url]`, `[ZUrl]` | `z.url()` |
| `[CreditCard]`, `[ZCreditCard]` | `z.creditCard()` |
| `[ZIban]` | `z.iban()` |
| `System.Guid` | `z.uuid()` |
| `System.DateTime` | `z.iso.datetime()` |
| `System.DateOnly` | `z.iso.date()` |
Expand All @@ -70,6 +73,25 @@ The emitter targets **Zod 4** — it emits the top-level format factories
(`z.uuid()`, `z.email()`, `z.iso.datetime()`, …) rather than the chained
`z.string().uuid()` forms that Zod 4 deprecated. Install Zod 4: `npm install zod@^4`.

Zod-only formats can be selected without changing the OpenAPI contract:

```csharp
[ZodFormat(ZodStringFormat.Ulid)]
public string PublicId { get; set; } = "";

b.ForType<Payment>()
.Property(x => x.CardToken)
.ZodFormat(ZodStringFormat.Base64Url);

// Zod 4.6 custom NanoID length:
b.ForType<Payment>()
.Property(x => x.PublicToken)
.ZodNanoId(16);
```

Available formats are email, URL, UUID, date, date-time, hostname, ULID,
NanoID, Base64, Base64URL, credit card, and IBAN.

## Type mapping

| C# | Zod |
Expand All @@ -80,6 +102,7 @@ The emitter targets **Zod 4** — it emits the top-level format factories
| `string` | `z.string()` |
| `bool` | `z.boolean()` |
| `T?` (nullable) | `.nullish()` *(null ∪ undefined ∪ absent)* |
| `PatchField<T>` | `T.optional()` *(omission is distinct from an explicit value)* |
| `List<T>`, `T[]` | `z.array(T)` |
| `Dictionary<string, V>` | `z.record(z.string(), V)` |
| user DTO | direct ref `{Name}Schema` (cross-file import) |
Expand Down Expand Up @@ -128,9 +151,17 @@ b.Zod(z =>
z.SchemaConstSuffix = "Schema"; // default; "XxxSchema"
z.EmitInferredTypes = true; // default; adds `export type X = z.infer<…>`
z.FileSuffix = ".schema"; // `Order.schema.ts` avoids collision with TS's `Order.ts`
z.ConformToTypeScriptTypes = true; // opt-in `z.toZod<T>()` compile-time check
z.Compilation = ZodCompilationMode.Compile; // opt-in optimized parser
z.EmitValidationGuards = true; // emits `isOrder(value)` using `.validate()`
});
```

Recursive DTO references are emitted with `z.lazy(...)`, including mutually
recursive types split across files. Compilation is opt-in because Zod uses
generated JavaScript internally; unsupported schemas safely fall back to the
normal parser.

**Consumer install:** the emitted code imports `zod` — add it to the frontend
project: `npm install zod@^4`. The emitter targets Zod 4's top-level format
project: `npm install zod@^4.6.1`. The emitter targets Zod 4's top-level format
factories. TypeGen doesn't bundle or generate the dep.
1 change: 1 addition & 0 deletions docs/src/content/docs/packages/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ All attributes live in the `ZibStack.NET.Validation` namespace and are processed
| `[ZIn("a","b","c")]` | property | Value must be one of the allowed values |
| `[ZNotIn("x","y")]` | property | Value must NOT be any of the specified values |
| `[ZCreditCard]` | property (string) | Must pass the Luhn algorithm check |
| `[ZIban]` | property (string) | Must pass the ISO 13616 mod-97 check |
| `[ZPhone]` | property (string) | Must match phone number format regex |
| `[ZCascade]` | property | Stop after first rule failure for this property |

Expand Down
10 changes: 10 additions & 0 deletions docs/src/content/docs/packages/validation/attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ description: Complete reference for all validation attributes — [ZRequired], [
| `[ZIn("a","b","c")]` | property | Value must be one of the allowed values |
| `[ZNotIn("x","y")]` | property | Value must NOT be any of the specified values |
| `[ZCreditCard]` | property (string) | Must pass the Luhn algorithm check |
| `[ZIban]` | property (string) | Must pass the ISO 13616 mod-97 check |
| `[ZPhone]` | property (string) | Must match phone number format |
| `[ZCascade]` | property | Stop after first rule failure for this property |

Expand Down Expand Up @@ -84,6 +85,15 @@ public partial class PaymentForm
// Invalid: "1234567890123456"
```

## `[ZIban]` — ISO 13616

```csharp
[ZIban]
public string? BankAccount { get; set; }

// Valid: "GB82 WEST 1234 5698 7654 32"
```

## `[ZPhone]`

```csharp
Expand Down
5 changes: 4 additions & 1 deletion docs/src/content/docs/packages/validation/fluent.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,12 @@ b.Property(x => x.Role)

b.Property(x => x.CardNumber)
.CreditCard();

b.Property(x => x.BankAccount)
.Iban();
```

Available fluent methods: `.Required()`, `.Email()`, `.Url()`, `.NotEmpty()`, `.MinLength(n)`, `.MaxLength(n)`, `.Range(min, max)`, `.Match(pattern)`, `.In(values)`, `.NotIn(values)`, `.CreditCard()`, `.Phone()`.
Available fluent methods: `.Required()`, `.Email()`, `.Url()`, `.NotEmpty()`, `.MinLength(n)`, `.MaxLength(n)`, `.Range(min, max)`, `.Match(pattern)`, `.In(values)`, `.NotIn(values)`, `.CreditCard()`, `.Iban()`, `.Phone()`.

## Cross-Field Comparisons

Expand Down
61 changes: 60 additions & 1 deletion packages/ZibStack.NET.TypeGen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Roslyn source generator that emits **TypeScript** (`.ts`), **OpenAPI 3.0**
(`.yaml` / `.json`), and **TanStack Query** clients from C# DTOs/endpoints
annotated with `[GenerateTypes]`. Optional **Python** (Pydantic v2 / dataclass)
output. Compile-time only, zero reflection, no running app required.
and **Zod 4.6** output. Compile-time only, zero reflection, no running app required.

## What it does

Expand Down Expand Up @@ -75,6 +75,65 @@ That's it — `ZibStack.NET.TypeGen.Abstractions` (attributes + settings types)
is pulled in transitively. The analyzer self-registers; everything else is
in the attribute / configurator surface.

## Zod 4.6 and validated TanStack clients

Zod generation is opt-in per model. The configurator can compile schemas,
check them against the emitted TypeScript type, add `isX` guards, and make the
TanStack client parse API payloads at the network boundary:

```csharp
[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.Zod | TypeTarget.TanStackQuery)]
public sealed class Payment
{
[ZodFormat(ZodStringFormat.CreditCard)]
public string CardNumber { get; set; } = "";

[ZodFormat(ZodStringFormat.Iban)]
public string Iban { get; set; } = "";

public string PublicToken { get; set; } = "";

public Payment? Parent { get; set; } // emitted through z.lazy(...)
}

public sealed class TypeGenConfig : ITypeGenConfigurator
{
public void Configure(ITypeGenBuilder b)
{
b.Zod(z =>
{
z.Compilation = ZodCompilationMode.Compile;
z.ConformToTypeScriptTypes = true;
z.EmitValidationGuards = true;
});

b.TanStackQuery(q =>
q.PayloadValidation = QueryPayloadValidation.RequestsAndResponses);

b.ForType<Payment>()
.Property(x => x.PublicToken)
.ZodNanoId(16); // custom-length NanoID is also available fluently
}
}
```

The generated module uses Zod's native factories and compiler:

```ts
export const PaymentSchema = z.compile(z.toZod<Payment>()(z.object({
cardNumber: z.creditCard(),
iban: z.iban(),
publicToken: z.nanoid({ length: 16 }),
parent: z.lazy(() => PaymentSchema).optional(),
})));

export const isPayment = (value: unknown): value is z.output<typeof PaymentSchema> =>
PaymentSchema.validate(value);
```

See the sample project's `ZodFeatureExample` for credit cards, IBANs, ULIDs,
hostnames, Base64/Base64URL, custom NanoIDs, recursion, and response validation.

## Docs

Full reference — type mapping, diagnostic list (`TG0001`-`TG0021`), fluent DSL,
Expand Down
33 changes: 33 additions & 0 deletions packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Order.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,36 @@ public class Customer
public string Name { get; set; } = "";
public string Email { get; set; } = "";
}

/// <summary>
/// Zod 4.6 format showcase. These are TypeGen-only wire checks; use the
/// matching ZibStack.Validation attributes when the server must enforce the
/// same rule as well. Parent demonstrates recursive schema emission via z.lazy.
/// </summary>
[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.Zod,
OutputDir = "generated")]
public class ZodFeatureExample
{
[ZodFormat(ZodStringFormat.CreditCard)]
public string CardNumber { get; set; } = "";

[ZodFormat(ZodStringFormat.Iban)]
public string BankAccount { get; set; } = "";

[ZodFormat(ZodStringFormat.Ulid)]
public string SortableId { get; set; } = "";

[ZodFormat(ZodStringFormat.Hostname)]
public string Host { get; set; } = "";

[ZodFormat(ZodStringFormat.Base64)]
public string EncodedPayload { get; set; } = "";

[ZodFormat(ZodStringFormat.Base64Url)]
public string UrlSafePayload { get; set; } = "";

// Configured with .ZodNanoId(16) in TypeGenConfig.cs.
public string PublicToken { get; set; } = "";

public ZodFeatureExample? Parent { get; set; }
}
16 changes: 16 additions & 0 deletions packages/ZibStack.NET.TypeGen/sample/SampleApi/TypeGenConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public void Configure(ITypeGenBuilder b)
{
q.OutputDir = "generated";
q.SingleFileName = "api.gen.ts";
// Opt-in runtime boundary validation. Responses are fetched as
// unknown and parsed through their generated Zod schema before
// TanStack Query exposes them to components.
q.PayloadValidation = QueryPayloadValidation.Responses;
// The default is import.meta.env.VITE_API_URL. The sample uses the
// current origin so api.gen.ts type-checks in non-Vite clients too.
q.BaseUrlExpression = "window.location.origin";
Expand All @@ -45,7 +49,19 @@ public void Configure(ITypeGenBuilder b)
{
z.OutputDir = "generated";
z.EmitInferredTypes = true;
// Zod 4.6 additions: compile optimized parsers, prove the schemas
// match the generated TS interfaces, and emit cheap `isX(value)`
// guards backed by the short-circuiting validate API.
z.Compilation = ZodCompilationMode.Compile;
z.ConformToTypeScriptTypes = true;
z.EmitValidationGuards = true;
});

// Zod 4.6 supports a custom NanoID length. The fluent form is useful
// when the model lives in another project and cannot be annotated.
b.ForType<ZodFeatureExample>()
.Property(x => x.PublicToken)
.ZodNanoId(16);

b.ForType<Root>()
.WithGeneratedTypes(TypeTarget.TypeScript)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@ public interface IPropertyBuilder<TClass, TProp>
/// <summary>Equivalent to <c>[OpenApiProperty(Format = format)]</c>.</summary>
IPropertyBuilder<TClass, TProp> OpenApiFormat(string format);

/// <summary>Use a built-in Zod string-format validator for this property.</summary>
IPropertyBuilder<TClass, TProp> ZodFormat(ZodStringFormat format);

/// <summary>Validate a NanoID with an exact custom length.</summary>
IPropertyBuilder<TClass, TProp> ZodNanoId(int length);

/// <summary>Equivalent to <c>[OpenApiProperty(Description = description)]</c>.</summary>
IPropertyBuilder<TClass, TProp> OpenApiDescription(string description);

Expand Down
Loading
Loading