@@ -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()" ,
0 commit comments