Skip to content

Commit 88125d0

Browse files
authored
Merge pull request #14 from MistyKuu/fix/zod-v4-uuid-format
[TypeGen]: emit Zod 4 top-level format factories (fix #13)
2 parents f413146 + 41700f9 commit 88125d0

4 files changed

Lines changed: 95 additions & 29 deletions

File tree

docs/src/content/docs/packages/typegen/emitters/zod.md

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import { OrderStatusSchema } from './OrderStatus.schema';
3737

3838
export const OrderSchema = z.object({
3939
id: z.number().int(),
40-
email: z.string().email(),
40+
email: z.email(),
4141
qty: z.number().int().gte(1).lte(100),
4242
status: OrderStatusSchema,
4343
});
@@ -60,11 +60,15 @@ Same attributes that drive OpenAPI constraints map to Zod chained calls:
6060
| `[MaxLength(n)]`, `[ZMaxLength(n)]` | `.max(n)` |
6161
| `[Range(min, max)]`, `[ZRange(min, max)]` | `.gte(min).lte(max)` |
6262
| `[RegularExpression("pat")]`, `[ZMatch("pat")]` | `.regex(/pat/)` |
63-
| `[EmailAddress]`, `[ZEmail]` | `.email()` |
64-
| `[Url]`, `[ZUrl]` | `.url()` |
65-
| `System.Guid` | `z.string().uuid()` |
66-
| `System.DateTime` | `z.string().datetime()` |
67-
| `System.DateOnly` | `z.string().date()` *(Zod 3.23+)* |
63+
| `[EmailAddress]`, `[ZEmail]` | `z.email()` |
64+
| `[Url]`, `[ZUrl]` | `z.url()` |
65+
| `System.Guid` | `z.uuid()` |
66+
| `System.DateTime` | `z.iso.datetime()` |
67+
| `System.DateOnly` | `z.iso.date()` |
68+
69+
The emitter targets **Zod 4** — it emits the top-level format factories
70+
(`z.uuid()`, `z.email()`, `z.iso.datetime()`, …) rather than the chained
71+
`z.string().uuid()` forms that Zod 4 deprecated. Install Zod 4: `npm install zod@^4`.
6872

6973
## Type mapping
7074

@@ -128,4 +132,5 @@ b.Zod(z =>
128132
```
129133

130134
**Consumer install:** the emitted code imports `zod` — add it to the frontend
131-
project: `npm install zod`. TypeGen doesn't bundle or generate the dep.
135+
project: `npm install zod@^4`. The emitter targets Zod 4's top-level format
136+
factories. TypeGen doesn't bundle or generate the dep.

packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -339,18 +339,10 @@ private static string ApplyStringConstraints(string expr, SchemaProperty prop)
339339
if (!isString) return expr;
340340

341341
// Format markers come from validation attrs via OpenApiFormat — the
342-
// SchemaParser normalises [EmailAddress]/[ZEmail] → "email" etc.
343-
switch (prop.OpenApiFormat)
344-
{
345-
case "email": expr += ".email()"; break;
346-
case "uri": case "url": expr += ".url()"; break;
347-
case "uuid": expr += ".uuid()"; break;
348-
case "date-time": expr += ".datetime()"; break;
349-
case "date":
350-
// Zod 3.23+ ships z.string().date(); older versions fall back via regex.
351-
// We emit .date() — users on old Zod get a clear error, easy to see.
352-
expr += ".date()"; break;
353-
}
342+
// SchemaParser normalises [EmailAddress]/[ZEmail] → "email" etc. Zod 4
343+
// moved these to top-level factories (z.email(), z.uuid(), z.iso.datetime())
344+
// and deprecated the chained z.string().email() forms.
345+
expr = ApplyStringFormat(expr, prop.OpenApiFormat);
354346

355347
if (prop.MinLength is int min) expr += $".min({min})";
356348
if (prop.MaxLength is int max) expr += $".max({max})";
@@ -363,6 +355,33 @@ private static string ApplyStringConstraints(string expr, SchemaProperty prop)
363355
return expr;
364356
}
365357

358+
/// <summary>
359+
/// Applies a string <c>format</c> validator to a <c>z.string()</c>-based
360+
/// expression using the Zod 4 top-level factories (<c>z.email()</c>,
361+
/// <c>z.uuid()</c>, <c>z.url()</c>, <c>z.iso.datetime()</c>, <c>z.iso.date()</c>).
362+
/// The factory replaces the leading <c>z.string()</c> so any subsequent
363+
/// <c>.min()</c>/<c>.max()</c>/<c>.regex()</c> chain onto it.
364+
/// </summary>
365+
private static string ApplyStringFormat(string expr, string? format)
366+
{
367+
var factory = format switch
368+
{
369+
"email" => "z.email()",
370+
"uri" or "url" => "z.url()",
371+
"uuid" => "z.uuid()",
372+
"date-time" => "z.iso.datetime()",
373+
"date" => "z.iso.date()",
374+
_ => null,
375+
};
376+
if (factory is null) return expr; // no recognised format
377+
378+
// Swap the leading `z.string()` base for the top-level factory.
379+
const string stringBase = "z.string()";
380+
return expr.StartsWith(stringBase, System.StringComparison.Ordinal)
381+
? factory + expr.Substring(stringBase.Length)
382+
: expr;
383+
}
384+
366385
private static string ApplyNumericConstraints(string expr, SchemaProperty prop)
367386
{
368387
var isNumber = expr.StartsWith("z.number", System.StringComparison.Ordinal);
@@ -428,6 +447,8 @@ private static string MapCSharpToZod(
428447
if (dictMatch != null)
429448
return $"z.record({MapCSharpToZod(dictMatch.Value.K, false, nameByCSharp, schemaConstSuffix, typeParameters)}, {MapCSharpToZod(dictMatch.Value.V, false, nameByCSharp, schemaConstSuffix, typeParameters)})";
430449

450+
// Zod 4 top-level format factories — the chained z.string().uuid() forms
451+
// are deprecated in Zod 4. See also ApplyStringFormat for attr-driven formats.
431452
return t switch
432453
{
433454
"string" => "z.string()",
@@ -436,9 +457,9 @@ private static string MapCSharpToZod(
436457
or "System.Int32" or "System.Int64" => "z.number().int()",
437458
"float" or "double" or "System.Single" or "System.Double" => "z.number()",
438459
"decimal" or "System.Decimal" => "z.string()",
439-
"System.Guid" or "Guid" => "z.string().uuid()",
440-
"System.DateTime" or "DateTime" or "System.DateTimeOffset" or "DateTimeOffset" => "z.string().datetime()",
441-
"System.DateOnly" or "DateOnly" => "z.string().date()",
460+
"System.Guid" or "Guid" => "z.uuid()",
461+
"System.DateTime" or "DateTime" or "System.DateTimeOffset" or "DateTimeOffset" => "z.iso.datetime()",
462+
"System.DateOnly" or "DateOnly" => "z.iso.date()",
442463
"System.TimeOnly" or "TimeOnly" or "System.TimeSpan" or "TimeSpan" => "z.string()",
443464
"object" => "z.unknown()",
444465
_ => "z.unknown()",

packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodCompilationTests.cs

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public sealed class ZodCompilationTests : IDisposable
2222
{
2323
// Pin both packages for determinism across machines / CI.
2424
private const string TscPackageSpec = "typescript@5.7.3";
25-
private const string ZodPackageSpec = "zod@3.23.8";
25+
private const string ZodPackageSpec = "zod@4.4.3";
2626

2727
private readonly string _tempDir;
2828
private readonly bool _skip;
@@ -74,7 +74,7 @@ public async Task EmittedOutput_FromCrossReferencedModel_CompilesWithTsc()
7474

7575
var (exitCode, stdout, stderr) = await RunAsync(
7676
"npx",
77-
$"-y -p {TscPackageSpec} tsc --noEmit --strict --target ES2020 --moduleResolution node " +
77+
$"-y -p {TscPackageSpec} tsc --noEmit --strict --skipLibCheck --esModuleInterop --target ES2020 --moduleResolution node " +
7878
string.Join(" ", files.Select(f => f.FileName)),
7979
workingDir: _tempDir);
8080

@@ -123,7 +123,47 @@ public async Task PolymorphicUnion_ProducesValidDiscriminatedUnion()
123123

124124
var (exitCode, stdout, stderr) = await RunAsync(
125125
"npx",
126-
$"-y -p {TscPackageSpec} tsc --noEmit --strict --target ES2020 --moduleResolution node " +
126+
$"-y -p {TscPackageSpec} tsc --noEmit --strict --skipLibCheck --esModuleInterop --target ES2020 --moduleResolution node " +
127+
string.Join(" ", files.Select(f => f.FileName)),
128+
workingDir: _tempDir);
129+
130+
Assert.True(exitCode == 0,
131+
$"tsc failed (exit {exitCode}):{Environment.NewLine}{stdout}{Environment.NewLine}{stderr}");
132+
}
133+
134+
[Fact]
135+
public async Task ZodV4FormatTypes_CompileAgainstRealZod()
136+
{
137+
if (_skip) return;
138+
139+
// Guid/DateTime/DateOnly + [Email]/[Url] formats exercise the Zod 4
140+
// top-level factories (z.uuid(), z.iso.datetime(), z.email(), ...).
141+
// Compiling against a real zod 4 install proves the syntax is valid.
142+
var model = new SchemaModel();
143+
var cls = ClsModel("Account", new[]
144+
{
145+
("Id", "System.Guid", false),
146+
("CreatedAt", "System.DateTime", false),
147+
("BirthDate", "System.DateOnly", false),
148+
});
149+
cls.Properties.Add(new SchemaProperty
150+
{
151+
SourceName = "Email", CSharpTypeFullName = "string", OpenApiFormat = "email",
152+
});
153+
cls.Properties.Add(new SchemaProperty
154+
{
155+
SourceName = "Website", CSharpTypeFullName = "string", OpenApiFormat = "url",
156+
});
157+
model.Classes.Add(cls);
158+
159+
var files = ZodEmitter.Emit(model, new GlobalSettings());
160+
await PrepareWorkspaceAsync();
161+
foreach (var f in files)
162+
File.WriteAllText(Path.Combine(_tempDir, f.FileName), f.Content);
163+
164+
var (exitCode, stdout, stderr) = await RunAsync(
165+
"npx",
166+
$"-y -p {TscPackageSpec} tsc --noEmit --strict --skipLibCheck --esModuleInterop --target ES2020 --moduleResolution node " +
127167
string.Join(" ", files.Select(f => f.FileName)),
128168
workingDir: _tempDir);
129169

packages/ZibStack.NET.TypeGen/tests/ZibStack.NET.TypeGen.Tests/ZodEmitterTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ public void Primitives_MapToExpectedZodExpressions()
5959
Assert.Contains("name: z.string()", content);
6060
Assert.Contains("active: z.boolean()", content);
6161
Assert.Contains("price: z.string()", content); // decimal → string (precision)
62-
Assert.Contains("when: z.string().datetime()", content);
63-
Assert.Contains("token: z.string().uuid()", content);
62+
Assert.Contains("when: z.iso.datetime()", content);
63+
Assert.Contains("token: z.uuid()", content);
6464
}
6565

6666
[Fact]
@@ -173,7 +173,7 @@ public void NumericRange_AppendsGteLte()
173173
}
174174

175175
[Fact]
176-
public void EmailFormat_BecomesChainedEmail()
176+
public void EmailFormat_BecomesTopLevelEmail()
177177
{
178178
var cls = Cls("Customer");
179179
cls.Properties.Add(new SchemaProperty
@@ -184,7 +184,7 @@ public void EmailFormat_BecomesChainedEmail()
184184
});
185185
var content = ZodEmitter.Emit(ModelWith(cls), new GlobalSettings()).Single().Content;
186186

187-
Assert.Contains("email: z.string().email()", content);
187+
Assert.Contains("email: z.email()", content);
188188
}
189189

190190
[Fact]

0 commit comments

Comments
 (0)