diff --git a/docs/src/content/docs/packages/typegen/emitters/zod.md b/docs/src/content/docs/packages/typegen/emitters/zod.md index 5ca7d9c..e9e4c6c 100644 --- a/docs/src/content/docs/packages/typegen/emitters/zod.md +++ b/docs/src/content/docs/packages/typegen/emitters/zod.md @@ -37,7 +37,7 @@ import { OrderStatusSchema } from './OrderStatus.schema'; export const OrderSchema = z.object({ id: z.number().int(), - email: z.string().email(), + email: z.email(), qty: z.number().int().gte(1).lte(100), status: OrderStatusSchema, }); @@ -60,11 +60,15 @@ Same attributes that drive OpenAPI constraints map to Zod chained calls: | `[MaxLength(n)]`, `[ZMaxLength(n)]` | `.max(n)` | | `[Range(min, max)]`, `[ZRange(min, max)]` | `.gte(min).lte(max)` | | `[RegularExpression("pat")]`, `[ZMatch("pat")]` | `.regex(/pat/)` | -| `[EmailAddress]`, `[ZEmail]` | `.email()` | -| `[Url]`, `[ZUrl]` | `.url()` | -| `System.Guid` | `z.string().uuid()` | -| `System.DateTime` | `z.string().datetime()` | -| `System.DateOnly` | `z.string().date()` *(Zod 3.23+)* | +| `[EmailAddress]`, `[ZEmail]` | `z.email()` | +| `[Url]`, `[ZUrl]` | `z.url()` | +| `System.Guid` | `z.uuid()` | +| `System.DateTime` | `z.iso.datetime()` | +| `System.DateOnly` | `z.iso.date()` | + +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`. ## Type mapping @@ -128,4 +132,5 @@ b.Zod(z => ``` **Consumer install:** the emitted code imports `zod` — add it to the frontend -project: `npm install zod`. TypeGen doesn't bundle or generate the dep. +project: `npm install zod@^4`. The emitter targets Zod 4's top-level format +factories. TypeGen doesn't bundle or generate the dep. diff --git a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs index bb68f37..cdbb5d9 100644 --- a/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs +++ b/packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs @@ -339,18 +339,10 @@ private static string ApplyStringConstraints(string expr, SchemaProperty prop) if (!isString) return expr; // Format markers come from validation attrs via OpenApiFormat — the - // SchemaParser normalises [EmailAddress]/[ZEmail] → "email" etc. - switch (prop.OpenApiFormat) - { - case "email": expr += ".email()"; break; - case "uri": case "url": expr += ".url()"; break; - case "uuid": expr += ".uuid()"; break; - case "date-time": expr += ".datetime()"; break; - case "date": - // Zod 3.23+ ships z.string().date(); older versions fall back via regex. - // We emit .date() — users on old Zod get a clear error, easy to see. - expr += ".date()"; break; - } + // SchemaParser normalises [EmailAddress]/[ZEmail] → "email" etc. Zod 4 + // moved these to top-level factories (z.email(), z.uuid(), z.iso.datetime()) + // and deprecated the chained z.string().email() forms. + expr = ApplyStringFormat(expr, prop.OpenApiFormat); if (prop.MinLength is int min) expr += $".min({min})"; if (prop.MaxLength is int max) expr += $".max({max})"; @@ -363,6 +355,33 @@ private static string ApplyStringConstraints(string expr, SchemaProperty prop) return expr; } + /// + /// Applies a string format validator to a z.string()-based + /// expression using the Zod 4 top-level factories (z.email(), + /// z.uuid(), z.url(), z.iso.datetime(), z.iso.date()). + /// The factory replaces the leading z.string() so any subsequent + /// .min()/.max()/.regex() chain onto it. + /// + private static string ApplyStringFormat(string expr, string? format) + { + var factory = format switch + { + "email" => "z.email()", + "uri" or "url" => "z.url()", + "uuid" => "z.uuid()", + "date-time" => "z.iso.datetime()", + "date" => "z.iso.date()", + _ => null, + }; + if (factory is null) return expr; // no recognised format + + // Swap the leading `z.string()` base for the top-level factory. + const string stringBase = "z.string()"; + return expr.StartsWith(stringBase, System.StringComparison.Ordinal) + ? factory + expr.Substring(stringBase.Length) + : expr; + } + private static string ApplyNumericConstraints(string expr, SchemaProperty prop) { var isNumber = expr.StartsWith("z.number", System.StringComparison.Ordinal); @@ -428,6 +447,8 @@ private static string MapCSharpToZod( if (dictMatch != null) return $"z.record({MapCSharpToZod(dictMatch.Value.K, false, nameByCSharp, schemaConstSuffix, typeParameters)}, {MapCSharpToZod(dictMatch.Value.V, false, nameByCSharp, schemaConstSuffix, typeParameters)})"; + // Zod 4 top-level format factories — the chained z.string().uuid() forms + // are deprecated in Zod 4. See also ApplyStringFormat for attr-driven formats. return t switch { "string" => "z.string()", @@ -436,9 +457,9 @@ private static string MapCSharpToZod( or "System.Int32" or "System.Int64" => "z.number().int()", "float" or "double" or "System.Single" or "System.Double" => "z.number()", "decimal" or "System.Decimal" => "z.string()", - "System.Guid" or "Guid" => "z.string().uuid()", - "System.DateTime" or "DateTime" or "System.DateTimeOffset" or "DateTimeOffset" => "z.string().datetime()", - "System.DateOnly" or "DateOnly" => "z.string().date()", + "System.Guid" or "Guid" => "z.uuid()", + "System.DateTime" or "DateTime" or "System.DateTimeOffset" or "DateTimeOffset" => "z.iso.datetime()", + "System.DateOnly" or "DateOnly" => "z.iso.date()", "System.TimeOnly" or "TimeOnly" or "System.TimeSpan" or "TimeSpan" => "z.string()", "object" => "z.unknown()", _ => "z.unknown()", diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs index d2d1762..9ed226c 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs @@ -22,7 +22,7 @@ public sealed class ZodCompilationTests : IDisposable { // Pin both packages for determinism across machines / CI. private const string TscPackageSpec = "typescript@5.7.3"; - private const string ZodPackageSpec = "zod@3.23.8"; + private const string ZodPackageSpec = "zod@4.4.3"; private readonly string _tempDir; private readonly bool _skip; @@ -74,7 +74,7 @@ public async Task EmittedOutput_FromCrossReferencedModel_CompilesWithTsc() var (exitCode, stdout, stderr) = await RunAsync( "npx", - $"-y -p {TscPackageSpec} tsc --noEmit --strict --target ES2020 --moduleResolution node " + + $"-y -p {TscPackageSpec} tsc --noEmit --strict --skipLibCheck --esModuleInterop --target ES2020 --moduleResolution node " + string.Join(" ", files.Select(f => f.FileName)), workingDir: _tempDir); @@ -123,7 +123,47 @@ public async Task PolymorphicUnion_ProducesValidDiscriminatedUnion() var (exitCode, stdout, stderr) = await RunAsync( "npx", - $"-y -p {TscPackageSpec} tsc --noEmit --strict --target ES2020 --moduleResolution node " + + $"-y -p {TscPackageSpec} tsc --noEmit --strict --skipLibCheck --esModuleInterop --target ES2020 --moduleResolution node " + + string.Join(" ", files.Select(f => f.FileName)), + workingDir: _tempDir); + + Assert.True(exitCode == 0, + $"tsc failed (exit {exitCode}):{Environment.NewLine}{stdout}{Environment.NewLine}{stderr}"); + } + + [Fact] + public async Task ZodV4FormatTypes_CompileAgainstRealZod() + { + if (_skip) return; + + // Guid/DateTime/DateOnly + [Email]/[Url] formats exercise the Zod 4 + // top-level factories (z.uuid(), z.iso.datetime(), z.email(), ...). + // Compiling against a real zod 4 install proves the syntax is valid. + var model = new SchemaModel(); + var cls = ClsModel("Account", new[] + { + ("Id", "System.Guid", false), + ("CreatedAt", "System.DateTime", false), + ("BirthDate", "System.DateOnly", false), + }); + cls.Properties.Add(new SchemaProperty + { + SourceName = "Email", CSharpTypeFullName = "string", OpenApiFormat = "email", + }); + cls.Properties.Add(new SchemaProperty + { + SourceName = "Website", CSharpTypeFullName = "string", OpenApiFormat = "url", + }); + model.Classes.Add(cls); + + var files = ZodEmitter.Emit(model, new GlobalSettings()); + await PrepareWorkspaceAsync(); + foreach (var f in files) + File.WriteAllText(Path.Combine(_tempDir, f.FileName), f.Content); + + var (exitCode, stdout, stderr) = await RunAsync( + "npx", + $"-y -p {TscPackageSpec} tsc --noEmit --strict --skipLibCheck --esModuleInterop --target ES2020 --moduleResolution node " + string.Join(" ", files.Select(f => f.FileName)), workingDir: _tempDir); diff --git a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs index fe0fffb..df06881 100644 --- a/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs +++ b/packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs @@ -59,8 +59,8 @@ public void Primitives_MapToExpectedZodExpressions() Assert.Contains("name: z.string()", content); Assert.Contains("active: z.boolean()", content); Assert.Contains("price: z.string()", content); // decimal → string (precision) - Assert.Contains("when: z.string().datetime()", content); - Assert.Contains("token: z.string().uuid()", content); + Assert.Contains("when: z.iso.datetime()", content); + Assert.Contains("token: z.uuid()", content); } [Fact] @@ -173,7 +173,7 @@ public void NumericRange_AppendsGteLte() } [Fact] - public void EmailFormat_BecomesChainedEmail() + public void EmailFormat_BecomesTopLevelEmail() { var cls = Cls("Customer"); cls.Properties.Add(new SchemaProperty @@ -184,7 +184,7 @@ public void EmailFormat_BecomesChainedEmail() }); var content = ZodEmitter.Emit(ModelWith(cls), new GlobalSettings()).Single().Content; - Assert.Contains("email: z.string().email()", content); + Assert.Contains("email: z.email()", content); } [Fact]