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
6 changes: 6 additions & 0 deletions docs/src/content/docs/packages/typegen/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ public sealed class TypeGenConfig : ITypeGenConfigurator
.WithGeneratedTypes(TypeTarget.TypeScript | TypeTarget.OpenApi)
.TsName("ArticleDto")
.Property(p => p.Body).TsType("string | null");

// Runtime-schema override from a frontend dependency. This is separate
// from TsType because TypeScript types do not validate runtime values.
b.ForType<Order>()
.Property(p => p.DeliveryPoint)
.ZodSchema("GeoJSONPointSchema", "zod-geojson");
}
}
```
Expand Down
65 changes: 65 additions & 0 deletions docs/src/content/docs/packages/typegen/emitters/zod.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,70 @@ b.ForType<Payment>()
Available formats are email, URL, UUID, date, date-time, hostname, ULID,
NanoID, Base64, Base64URL, credit card, and IBAN.

## External schemas with `[ZodSchema]`

`[TsType]` controls only the static TypeScript type; it cannot provide runtime
validation. Use `[ZodSchema]` when a third-party package or shared frontend
module already exports the runtime schema for a C# property:

```csharp
public sealed class OrderDto
{
[TsType("Point", ImportFrom = "geojson")]
[ZodSchema("GeoJSONPointSchema", ImportFrom = "zod-geojson")]
public Point? DeliveryAreaPoint { get; init; }

[TsType("Feature", ImportFrom = "geojson")]
[ZodSchema("GeoJSONFeatureSchema", ImportFrom = "zod-geojson")]
public Feature? DeliveryAreaFeature { get; init; }
}
```

The Zod file imports and uses those schemas directly:

```typescript
import { GeoJSONFeatureSchema, GeoJSONPointSchema } from 'zod-geojson';

export const OrderDtoSchema = z.object({
deliveryAreaPoint: GeoJSONPointSchema.nullish(),
deliveryAreaFeature: GeoJSONFeatureSchema.nullish(),
});
```

Install the referenced runtime package in the generated client's project:

```bash
npm install zod zod-geojson
npm install --save-dev @types/geojson
```

The same override is available when the C# model cannot be annotated:

```csharp
b.ForType<OrderDto>()
.Property(x => x.DeliveryAreaPoint)
.ZodSchema("GeoJSONPointSchema", "zod-geojson");
```

The schema expression is emitted verbatim. Omit `ImportFrom` for inline
expressions such as `z.string().startsWith('ord_')`. TypeGen still applies the
property's nullable, optional, read-only, and `PatchField<T>` modifier after the
override. Other inferred validation constraints are not appended because the
explicit schema owns validation for that property.

Imports sharing a module are grouped and deduplicated in both single-file and
file-per-class output. External schemas also participate normally in
`z.compile(...)`, generated guards, and TanStack payload parsing. With
`ConformToTypeScriptTypes`, the external schema's inferred type must exactly
match the `[TsType]` expression—not merely be structurally assignable. For
`zod-geojson`, use its own inferred aliases when exact conformance is enabled:

```csharp
[TsType("GeoJSONPoint", ImportFrom = "zod-geojson")]
[ZodSchema("GeoJSONPointSchema", ImportFrom = "zod-geojson")]
public Point? DeliveryAreaPoint { get; init; }
```

## Type mapping

| C# | Zod |
Expand All @@ -106,6 +170,7 @@ NanoID, Base64, Base64URL, credit card, and IBAN.
| `List<T>`, `T[]` | `z.array(T)` |
| `Dictionary<string, V>` | `z.record(z.string(), V)` |
| user DTO | direct ref `{Name}Schema` (cross-file import) |
| `[ZodSchema("X", ImportFrom = "pkg")]` | imported runtime schema `X` |
| numeric `enum` | `z.union([z.literal(0), z.literal(1), …])` |
| `enum` + `[JsonStringEnumConverter]` | `z.enum(['A', 'B', …])` |

Expand Down
5 changes: 4 additions & 1 deletion docs/src/content/docs/packages/typegen/type-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ The emitters translate C# types to target-language equivalents. Defaults:
| `enum` | `export enum` (numeric values) | `type: string, enum: [...]` | `IntEnum` |
| `enum` with `[JsonConverter(typeof(JsonStringEnumConverter))]` | `export type X = "A" \| "B";` (default — `TsEnumStyle.Union`) | `type: string, enum: [...]` | `(str, Enum)` |

Override any single property with `[TsType("...")]` or `[OpenApiProperty(Format = "...")]`.
Override any single property with `[TsType("...")]`, `[ZodSchema("...")]`, or
`[OpenApiProperty(Format = "...")]`. `[TsType]` is static type information;
`[ZodSchema]` supplies a runtime validator. See the [Zod emitter](/packages/typegen/emitters/zod/)
for external-schema imports such as `zod-geojson`.

## `[TsType]` with imports

Expand Down
16 changes: 15 additions & 1 deletion packages/ZibStack.NET.TypeGen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ public sealed class Payment

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

[TsType("GeoJSONPoint", ImportFrom = "zod-geojson")]
[ZodSchema("GeoJSONPointSchema", ImportFrom = "zod-geojson")]
public object? DeliveryPoint { get; set; }

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

Expand Down Expand Up @@ -132,7 +136,17 @@ export const isPayment = (value: unknown): value is z.output<typeof PaymentSchem
```

See the sample project's `ZodFeatureExample` for credit cards, IBANs, ULIDs,
hostnames, Base64/Base64URL, custom NanoIDs, recursion, and response validation.
hostnames, Base64/Base64URL, custom NanoIDs, recursion, external schemas, and
response validation. `[ZodSchema]` also has a fluent form:

```csharp
b.ForType<Payment>()
.Property(x => x.DeliveryPoint)
.ZodSchema("GeoJSONPointSchema", "zod-geojson");
```

The frontend must install any package named by `ImportFrom`; for this example,
use `npm install zod zod-geojson`.

## Docs

Expand Down
10 changes: 10 additions & 0 deletions packages/ZibStack.NET.TypeGen/sample/SampleApi/Models/Order.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,15 @@ public class ZodFeatureExample
// Configured with .ZodNanoId(16) in TypeGenConfig.cs.
public string PublicToken { get; set; } = "";

// External runtime schema override. The frontend installs `zod-geojson`;
// TypeGen emits both imports instead of z.unknown(). Using the schema
// package's inferred alias also satisfies exact z.toZod<T>() mode.
[TsType("GeoJSONPoint", ImportFrom = "zod-geojson")]
[ZodSchema("GeoJSONPointSchema", ImportFrom = "zod-geojson")]
public System.Text.Json.Nodes.JsonObject? DeliveryPoint { get; set; }

[TsType("GeoJSONFeature", ImportFrom = "zod-geojson")]
public System.Text.Json.Nodes.JsonObject? DeliveryFeature { get; set; }

public ZodFeatureExample? Parent { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ public void Configure(ITypeGenBuilder b)
// when the model lives in another project and cannot be annotated.
b.ForType<ZodFeatureExample>()
.Property(x => x.PublicToken)
.ZodNanoId(16);
.ZodNanoId(16)
// Same external-schema override through the fluent API, useful
// when DeliveryFeature belongs to a referenced assembly.
.Property(x => x.DeliveryFeature)
.ZodSchema("GeoJSONFeatureSchema", "zod-geojson");

b.ForType<Root>()
.WithGeneratedTypes(TypeTarget.TypeScript)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ public interface ITypeBuilder<T>
/// <summary>
/// Per-property fluent overrides. Mirrors the per-property attributes
/// (<see cref="TsNameAttribute"/>, <see cref="TsTypeAttribute"/>,
/// <see cref="OpenApiPropertyAttribute"/>, etc.). Use when you can't or don't
/// <see cref="ZodSchemaAttribute"/>, <see cref="OpenApiPropertyAttribute"/>, etc.).
/// Use when you can't or don't
/// want to annotate the source — e.g. DTOs from a referenced library.
/// </summary>
/// <typeparam name="TClass">Owning class.</typeparam>
Expand Down Expand Up @@ -227,6 +228,16 @@ public interface IPropertyBuilder<TClass, TProp>
/// <summary>Validate a NanoID with an exact custom length.</summary>
IPropertyBuilder<TClass, TProp> ZodNanoId(int length);

/// <summary>Replace the inferred validator with a Zod schema expression emitted verbatim.</summary>
IPropertyBuilder<TClass, TProp> ZodSchema(string schemaExpression);

/// <summary>
/// Replace the inferred validator with a Zod schema expression and import its
/// named symbols from <paramref name="importFrom"/>. Equivalent to
/// <c>[ZodSchema(schemaExpression, ImportFrom = importFrom)]</c>.
/// </summary>
IPropertyBuilder<TClass, TProp> ZodSchema(string schemaExpression, string? importFrom);

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,21 @@ public sealed class ZodFormatAttribute : Attribute

public ZodFormatAttribute(ZodStringFormat format) => Format = format;
}

/// <summary>
/// Replaces the inferred Zod validator for a property with a user-supplied Zod
/// schema expression. Use <see cref="ImportFrom"/> when the expression references
/// named exports from another module, such as <c>GeoJSONPointSchema</c> from
/// <c>zod-geojson</c>.
/// </summary>
[AttributeUsage(AttributeTargets.Property, Inherited = true)]
public sealed class ZodSchemaAttribute : Attribute
{
/// <summary>Zod schema expression emitted verbatim for the property.</summary>
public string SchemaExpression { get; }

/// <summary>Optional module specifier supplying named symbols used by the expression.</summary>
public string? ImportFrom { get; set; }

public ZodSchemaAttribute(string schemaExpression) => SchemaExpression = schemaExpression;
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public static IReadOnlyList<EmittedFile> Emit(SchemaModel model, GlobalSettings
EmitConformanceImports(sb, model.Classes.Where(c => !SkipClass(c) && (c.Targets & TypeTarget.TypeScript) != 0).Select(c => c.EmittedName)
.Concat(model.Enums.Where(e => !SkipEnum(e) && (e.Targets & TypeTarget.TypeScript) != 0).Select(e => e.EmittedName)),
ResolveOutputDir(zs.OutputDir, model), settings.TypeScript, zs, model);
EmitExternalSchemaImports(sb, model.Classes.Where(c => !SkipClass(c)));
sb.AppendLine();

// In SingleFile mode order matters — a schema has to be declared
Expand Down Expand Up @@ -85,6 +86,7 @@ public static IReadOnlyList<EmittedFile> Emit(SchemaModel model, GlobalSettings
var outputDir = (cls.HasExplicitOutputDir ? cls.OutputDir : !string.IsNullOrEmpty(globalZodDir) ? globalZodDir : cls.OutputDir) ?? ".";
EmitConformanceImports(sb, (cls.Targets & TypeTarget.TypeScript) != 0 ? new[] { cls.EmittedName } : System.Array.Empty<string>(), outputDir, settings.TypeScript, zs, model);
EmitImports(sb, CollectClassReferences(cls, nameByCSharp), cls.EmittedName, zs);
EmitExternalSchemaImports(sb, new[] { cls });
sb.AppendLine();
EmitClass(sb, cls, zs, nameByCSharp, model);
files.Add(new EmittedFile(
Expand Down Expand Up @@ -137,6 +139,39 @@ private static void EmitImports(StringBuilder sb, IEnumerable<string> refs, stri
sb.AppendLine($"import {{ {r}{zs.SchemaConstSuffix} }} from './{r}{zs.FileSuffix}';");
}

private static void EmitExternalSchemaImports(StringBuilder sb, IEnumerable<SchemaClass> classes)
{
var byPath = new Dictionary<string, HashSet<string>>(System.StringComparer.Ordinal);
foreach (var prop in classes.SelectMany(c => c.Properties))
{
if (prop.TsIgnore || string.IsNullOrWhiteSpace(prop.ZodSchemaOverride)
|| string.IsNullOrWhiteSpace(prop.ZodSchemaImportFrom))
continue;

if (!byPath.TryGetValue(prop.ZodSchemaImportFrom!, out var names))
byPath[prop.ZodSchemaImportFrom!] = names = new HashSet<string>(System.StringComparer.Ordinal);
foreach (var name in ExtractImportedSchemaIdentifiers(prop.ZodSchemaOverride!))
names.Add(name);
}

foreach (var entry in byPath.OrderBy(x => x.Key, System.StringComparer.Ordinal))
{
if (entry.Value.Count == 0) continue;
sb.AppendLine($"import {{ {string.Join(", ", entry.Value.OrderBy(x => x, System.StringComparer.Ordinal))} }} from '{entry.Key}';");
}
}

private static IEnumerable<string> ExtractImportedSchemaIdentifiers(string expression)
{
var trimmed = expression.Trim();
if (System.Text.RegularExpressions.Regex.IsMatch(trimmed, @"^[A-Za-z_$][A-Za-z0-9_$]*$"))
return new[] { trimmed };

return System.Text.RegularExpressions.Regex.Matches(expression, @"[A-Z][A-Za-z0-9_$]*")
.Cast<System.Text.RegularExpressions.Match>()
.Select(match => match.Value);
}

private static void EmitConformanceImports(
StringBuilder sb,
IEnumerable<string> typeNames,
Expand Down Expand Up @@ -375,12 +410,18 @@ private static string BuildPropertyZodExpr(
bool conformToTypeScript = false)
{
var targetFqn = prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName;
var core = MapCSharpToZod(targetFqn, prop.IsNullable, nameByCSharp, schemaConstSuffix, typeParameters, lazySchemaNames);
var hasSchemaOverride = !string.IsNullOrWhiteSpace(prop.ZodSchemaOverride);
var core = hasSchemaOverride
? prop.ZodSchemaOverride!.Trim()
: MapCSharpToZod(targetFqn, prop.IsNullable, nameByCSharp, schemaConstSuffix, typeParameters, lazySchemaNames);

// Apply string-shaped constraints (length, regex, email/url/uuid formats).
// Numeric constraints use gte/lte.
core = ApplyStringConstraints(core, prop);
core = ApplyNumericConstraints(core, prop);
// An explicit schema owns all validation. Inferred schemas continue to
// receive constraints discovered from validation/format attributes.
if (!hasSchemaOverride)
{
core = ApplyStringConstraints(core, prop);
core = ApplyNumericConstraints(core, prop);
}

// Nullable + optional → .nullish() by default. In TypeScript conformance
// mode, mirror the TS emitter's optional-only contract so z.toZod<T>()
Expand Down Expand Up @@ -573,6 +614,7 @@ private static HashSet<string> CollectClassReferences(SchemaClass cls, IReadOnly
foreach (var prop in cls.Properties)
{
if (prop.TsIgnore) continue;
if (!string.IsNullOrWhiteSpace(prop.ZodSchemaOverride)) continue;
CollectRefs(prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName, nameByCSharp, acc);
}
return acc;
Expand Down Expand Up @@ -602,6 +644,7 @@ private static HashSet<string> CollectLazySchemaNames(
var result = new HashSet<string>(System.StringComparer.Ordinal);
foreach (var prop in owner.Properties)
{
if (!string.IsNullOrWhiteSpace(prop.ZodSchemaOverride)) continue;
foreach (var referenced in EnumerateReferencedTypes(prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName, classes))
{
if (CanReach(referenced, owner.CSharpFullName, classes, new HashSet<string>(System.StringComparer.Ordinal))
Expand All @@ -621,8 +664,11 @@ private static bool CanReach(
if (current == target) return true;
if (!visited.Add(current) || !classes.TryGetValue(current, out var cls)) return false;
foreach (var prop in cls.Properties)
{
if (!string.IsNullOrWhiteSpace(prop.ZodSchemaOverride)) continue;
foreach (var next in EnumerateReferencedTypes(prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName, classes))
if (CanReach(next, target, classes, visited)) return true;
}
return false;
}

Expand Down Expand Up @@ -684,6 +730,7 @@ void Visit(SchemaClass c)
// (e.g. items: z.array(OrderItemSchema)) must come after the referenced one.
foreach (var prop in c.Properties)
{
if (!string.IsNullOrWhiteSpace(prop.ZodSchemaOverride)) continue;
var propType = (prop.TargetTypeCSharpFqn ?? prop.CSharpTypeFullName).TrimEnd('?');
// Unwrap collections: List<X>, X[], IEnumerable<X> etc.
var inner = ExtractGeneric(propType, "List", "IList", "ICollection", "IEnumerable",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,12 @@ internal sealed class SchemaProperty
public ZodStringFormat? ZodFormat { get; set; }
public int? ZodFormatLength { get; set; }

/// <summary>User-supplied Zod schema expression that replaces inferred property mapping.</summary>
public string? ZodSchemaOverride { get; set; }

/// <summary>Optional module specifier supplying named symbols used by <see cref="ZodSchemaOverride"/>.</summary>
public string? ZodSchemaImportFrom { get; set; }

/// <summary>True for ZibStack.NET.Dto's tri-state <c>PatchField&lt;T&gt;</c>.</summary>
public bool IsPatchField { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ public sealed class PerPropertyOverrides
public string? OpenApiDescription { get; set; }
public ZodStringFormat? ZodFormat { get; set; }
public int? ZodFormatLength { get; set; }
public string? ZodSchema { get; set; }
public string? ZodSchemaImportFrom { get; set; }
public bool? OpenApiNullable { get; set; }
public bool Ignore { get; set; }
public bool TsIgnore { get; set; }
Expand Down Expand Up @@ -556,6 +558,20 @@ private static void ApplyPropertyLevelCall(
return;
}

if (name == "ZodSchema")
{
o.ZodSchema = ReadStringArg(inv, name, sm, report);
if (inv.ArgumentList.Arguments.Count >= 2)
{
var importArg = ReadLiteralValue(inv.ArgumentList.Arguments[1].Expression, sm);
if (importArg is string s) o.ZodSchemaImportFrom = s;
else if (importArg is NonLiteralMarker) report(Diagnostic.Create(
TypeGenDiagnostics.NonLiteralArgument,
inv.ArgumentList.Arguments[1].GetLocation(), name));
}
return;
}

string? arg = ReadStringArg(inv, name, sm, report);
switch (name)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal static class SchemaParser
private const string OpenApiSchemaNameAttr = "ZibStack.NET.TypeGen.OpenApiSchemaNameAttribute";
private const string OpenApiPropertyAttr = "ZibStack.NET.TypeGen.OpenApiPropertyAttribute";
private const string ZodFormatAttr = "ZibStack.NET.TypeGen.ZodFormatAttribute";
private const string ZodSchemaAttr = "ZibStack.NET.TypeGen.ZodSchemaAttribute";
private const string OpenApiIgnoreAttr = "ZibStack.NET.TypeGen.OpenApiIgnoreAttribute";
// String-only — no reference to ZibStack.NET.Dto. The attribute is generated
// by Dto's source generator into the user's compilation, so we read it via
Expand Down Expand Up @@ -893,6 +894,8 @@ private static SchemaProperty ParseProperty(IPropertySymbol prop)
TsNameOverride = ReadStringArg(prop, TsNameAttr, "Name"),
TsTypeOverride = ReadStringArg(prop, TsTypeAttr, "TypeExpression"),
TsImportFrom = ReadNamedStringArg(prop, TsTypeAttr, "ImportFrom"),
ZodSchemaOverride = ReadStringArg(prop, ZodSchemaAttr, "SchemaExpression"),
ZodSchemaImportFrom = ReadNamedStringArg(prop, ZodSchemaAttr, "ImportFrom"),
OpenApiNameOverride = ReadStringArg(prop, OpenApiSchemaNameAttr, "Name"),
TsIgnore = HasAttr(prop, TsIgnoreAttr),
OpenApiIgnore = HasAttr(prop, OpenApiIgnoreAttr),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,8 @@ private static void ApplyFluentToClass(SchemaClass cls, ConfiguratorParser.Parse
prop.OpenApiNullableOverride ??= po.OpenApiNullable;
prop.ZodFormat ??= po.ZodFormat;
prop.ZodFormatLength ??= po.ZodFormatLength;
prop.ZodSchemaOverride ??= po.ZodSchema;
prop.ZodSchemaImportFrom ??= po.ZodSchemaImportFrom;
if (po.Ignore) { prop.TsIgnore = true; prop.OpenApiIgnore = true; }
prop.TsIgnore |= po.TsIgnore;
prop.OpenApiIgnore |= po.OpenApiIgnore;
Expand Down
Loading
Loading