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
19 changes: 12 additions & 7 deletions docs/src/content/docs/packages/typegen/emitters/zod.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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

Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
EmitClass(sb, cls, zs, nameByCSharp, model);
files.Add(new EmittedFile(
Target: TypeTarget.Zod,
OutputDir: cls.HasExplicitOutputDir ? cls.OutputDir : !string.IsNullOrEmpty(globalZodDir) ? globalZodDir : cls.OutputDir,

Check warning on line 87 in packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'OutputDir' in 'EmittedFile.EmittedFile(TypeTarget Target, string OutputDir, string FileName, string Content)'.
FileName: cls.EmittedName + zs.FileSuffix + ".ts",
Content: sb.ToString()));
}
Expand All @@ -98,7 +98,7 @@
EmitEnum(sb, en, zs);
files.Add(new EmittedFile(
Target: TypeTarget.Zod,
OutputDir: en.HasExplicitOutputDir ? en.OutputDir : !string.IsNullOrEmpty(globalZodDir) ? globalZodDir : en.OutputDir,

Check warning on line 101 in packages/ZibStack.NET.TypeGen/src/ZibStack.NET.TypeGen/Emitters/ZodEmitter.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Possible null reference argument for parameter 'OutputDir' in 'EmittedFile.EmittedFile(TypeTarget Target, string OutputDir, string FileName, string Content)'.
FileName: en.EmittedName + zs.FileSuffix + ".ts",
Content: sb.ToString()));
}
Expand Down Expand Up @@ -339,18 +339,10 @@
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})";
Expand All @@ -363,6 +355,33 @@
return expr;
}

/// <summary>
/// Applies a string <c>format</c> validator to a <c>z.string()</c>-based
/// expression using the Zod 4 top-level factories (<c>z.email()</c>,
/// <c>z.uuid()</c>, <c>z.url()</c>, <c>z.iso.datetime()</c>, <c>z.iso.date()</c>).
/// The factory replaces the leading <c>z.string()</c> so any subsequent
/// <c>.min()</c>/<c>.max()</c>/<c>.regex()</c> chain onto it.
/// </summary>
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);
Expand Down Expand Up @@ -428,6 +447,8 @@
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()",
Expand All @@ -436,9 +457,9 @@
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()",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down
Loading