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
18 changes: 0 additions & 18 deletions testdata/TestGenerics.golden

This file was deleted.

54 changes: 54 additions & 0 deletions testdata/TestGenerics/v3.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// @zod-version: v3
// @typecheck
export const StringIntPairSchema = z.object({
First: z.string(),
Second: z.number(),
})
export type StringIntPair = z.infer<typeof StringIntPairSchema>

export const GenericPairIntBoolSchema = z.object({
First: z.number(),
Second: z.boolean(),
})
export type GenericPairIntBool = z.infer<typeof GenericPairIntBoolSchema>

export const PairMapStringIntBoolSchema = z.object({
items: z.record(z.string(), GenericPairIntBoolSchema).nullable(),
})
export type PairMapStringIntBool = z.infer<typeof PairMapStringIntBoolSchema>

export const GenericPairIntIntSchema = z.object({
First: z.number(),
Second: z.number(),
})
export type GenericPairIntInt = z.infer<typeof GenericPairIntIntSchema>

export const EmbeddedIntPairSchema = z.object({
}).merge(GenericPairIntIntSchema)
export type EmbeddedIntPair = z.infer<typeof EmbeddedIntPairSchema>

export const GenericPairIntGenericPairIntIntSchema = z.object({
First: z.number(),
Second: GenericPairIntIntSchema,
})
export type GenericPairIntGenericPairIntInt = z.infer<typeof GenericPairIntGenericPairIntIntSchema>

export const EmbeddedIntTripletSchema = z.object({
}).merge(GenericPairIntGenericPairIntIntSchema)
export type EmbeddedIntTriplet = z.infer<typeof EmbeddedIntTripletSchema>

export const GenericModelSchema = z.object({
ID: z.string(),
})
export type GenericModel = z.infer<typeof GenericModelSchema>

export const GenericPairIntGenericModelSchema = z.object({
First: z.number(),
Second: GenericModelSchema,
})
export type GenericPairIntGenericModel = z.infer<typeof GenericPairIntGenericModelSchema>

export const EmbeddedIntModelPairSchema = z.object({
}).merge(GenericPairIntGenericModelSchema)
export type EmbeddedIntModelPair = z.infer<typeof EmbeddedIntModelPairSchema>

57 changes: 57 additions & 0 deletions testdata/TestGenerics/v4.golden
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// @zod-version: v4
// @typecheck
export const StringIntPairSchema = z.object({
First: z.string(),
Second: z.number(),
})
export type StringIntPair = z.infer<typeof StringIntPairSchema>

export const GenericPairIntBoolSchema = z.object({
First: z.number(),
Second: z.boolean(),
})
export type GenericPairIntBool = z.infer<typeof GenericPairIntBoolSchema>

export const PairMapStringIntBoolSchema = z.object({
items: z.record(z.string(), GenericPairIntBoolSchema).nullable(),
})
export type PairMapStringIntBool = z.infer<typeof PairMapStringIntBoolSchema>

export const GenericPairIntIntSchema = z.object({
First: z.number(),
Second: z.number(),
})
export type GenericPairIntInt = z.infer<typeof GenericPairIntIntSchema>

export const EmbeddedIntPairSchema = z.object({
...GenericPairIntIntSchema.shape,
})
export type EmbeddedIntPair = z.infer<typeof EmbeddedIntPairSchema>

export const GenericPairIntGenericPairIntIntSchema = z.object({
First: z.number(),
Second: GenericPairIntIntSchema,
})
export type GenericPairIntGenericPairIntInt = z.infer<typeof GenericPairIntGenericPairIntIntSchema>

export const EmbeddedIntTripletSchema = z.object({
...GenericPairIntGenericPairIntIntSchema.shape,
})
export type EmbeddedIntTriplet = z.infer<typeof EmbeddedIntTripletSchema>

export const GenericModelSchema = z.object({
ID: z.string(),
})
export type GenericModel = z.infer<typeof GenericModelSchema>

export const GenericPairIntGenericModelSchema = z.object({
First: z.number(),
Second: GenericModelSchema,
})
export type GenericPairIntGenericModel = z.infer<typeof GenericPairIntGenericModelSchema>

export const EmbeddedIntModelPairSchema = z.object({
...GenericPairIntGenericModelSchema.shape,
})
export type EmbeddedIntModelPair = z.infer<typeof EmbeddedIntModelPairSchema>

27 changes: 24 additions & 3 deletions tests/cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1715,25 +1715,46 @@ export const cases: TestCase[] = [
// --- TestGenerics ---
{
name: "generics: StringIntPairSchema",
golden: "TestGenerics.golden",
golden: "TestGenerics",
schema: "StringIntPairSchema",
input: { First: "hello", Second: 42 },
success: true,
},
{
name: "generics: GenericPairIntBoolSchema",
golden: "TestGenerics.golden",
golden: "TestGenerics",
schema: "GenericPairIntBoolSchema",
input: { First: 1, Second: true },
success: true,
},
{
name: "generics: PairMapStringIntBoolSchema",
golden: "TestGenerics.golden",
golden: "TestGenerics",
schema: "PairMapStringIntBoolSchema",
input: { items: { key: { First: 1, Second: false } } },
success: true,
},
{
name: "generics: EmbeddedIntPairSchema",
golden: "TestGenerics",
schema: "EmbeddedIntPairSchema",
input: { First: 1, Second: 2 },
success: true,
},
{
name: "generics: EmbeddedIntTripletSchema",
golden: "TestGenerics",
schema: "EmbeddedIntTripletSchema",
input: { First: 1, Second: { First: 2, Second: 3 } },
success: true,
},
{
name: "generics: EmbeddedIntModelPairSchema",
golden: "TestGenerics",
schema: "EmbeddedIntModelPairSchema",
input: { First: 1, Second: { ID: "model" } },
success: true,
},

// --- TestInterfaceAny ---
{
Expand Down
57 changes: 52 additions & 5 deletions zod.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"sort"
"strconv"
"strings"
"unicode"
"unicode/utf8"
)

// Opt represents a converter option used to modify its behavior.
Expand Down Expand Up @@ -91,13 +93,20 @@ func (c *Converter) AddTypeWithName(input any, name string) {
// multiple times, followed by Export to get the corresponding zod schemas.
func (c *Converter) AddType(input any) {
t := reflect.TypeOf(input)
c.addType(t, typeName(t))
name := typeName(t)
if name == "" {
panic("input must be a named struct; use AddTypeWithName for anonymous structs")
}
c.addType(t, name)
}

func (c *Converter) addType(t reflect.Type, name string) {
if t.Kind() != reflect.Struct {
panic("input must be a struct")
}
if name == "" {
Comment thread
hi-rai marked this conversation as resolved.
panic("name must not be empty")
}

if _, ok := c.outputs[name]; ok {
return
Expand Down Expand Up @@ -1619,10 +1628,48 @@ func getTypeNameWithGenerics(name string) string {
var sb strings.Builder
sb.WriteString(name[:typeArgsIdx])

typeArgs := strings.SplitSeq(name[typeArgsIdx+1:len(name)-1], ",")
for arg := range typeArgs {
sb.WriteString(strings.ToUpper(arg[:1])) // Capitalize first letter
sb.WriteString(arg[1:])
// Reflected generic arguments can contain full package paths and composite
// type syntax. Keep only each type's unqualified name and the meaningful
// composite type markers.
typeArgs := name[typeArgsIdx+1 : len(name)-1]
for len(typeArgs) > 0 {
switch {
case strings.HasPrefix(typeArgs, "[]"):
sb.WriteString("Slice")
typeArgs = typeArgs[2:]
case strings.HasPrefix(typeArgs, "*"):
sb.WriteString("Pointer")
typeArgs = typeArgs[1:]
default:
partEnd := strings.IndexFunc(typeArgs, func(r rune) bool {
return r != '_' && r != '.' && r != '/' && r != '-' &&
!unicode.IsLetter(r) && !unicode.IsDigit(r)
})
if partEnd == -1 {
partEnd = len(typeArgs)
}
if partEnd == 0 {
delimiter, size := utf8.DecodeRuneInString(typeArgs)
typeArgs = typeArgs[size:]
if delimiter == '\u00b7' {
typeArgs = strings.TrimLeftFunc(typeArgs, unicode.IsDigit)
}
continue
}

partName := typeArgs[:partEnd]
typeArgs = typeArgs[partEnd:]
if packageEnd := strings.LastIndex(partName, "."); packageEnd != -1 {
partName = partName[packageEnd+1:]
}
if partName == "" {
continue
}

first, size := utf8.DecodeRuneInString(partName)
sb.WriteRune(unicode.ToUpper(first))
sb.WriteString(partName[size:])
}
}

return sb.String()
Expand Down
90 changes: 78 additions & 12 deletions zod_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,10 @@ func TestCyclic(t *testing.T) {
})
}

type GenericModel struct {
ID string
}

type GenericPair[T any, U any] struct {
First T
Second U
Expand All @@ -1196,21 +1200,83 @@ type PairMap[K comparable, T any, U any] struct {
Items map[K]GenericPair[T, U] `json:"items"`
}

type EmbeddedIntPair struct {
GenericPair[int, int]
}

type EmbeddedIntTriplet struct {
GenericPair[int, GenericPair[int, int]]
}

type EmbeddedIntModelPair struct {
GenericPair[int, GenericModel]
}

func TestGenerics(t *testing.T) {
c := NewConverterWithOpts()
c.AddType(StringIntPair{})
c.AddType(GenericPair[int, bool]{})
c.AddType(PairMap[string, int, bool]{})
for _, version := range []string{"v3", "v4"} {
t.Run(version, func(t *testing.T) {
var opts []Opt
if version == "v3" {
opts = append(opts, WithZodV3())
}

v3c := NewConverterWithOpts(WithZodV3())
v3c.AddType(StringIntPair{})
v3c.AddType(GenericPair[int, bool]{})
v3c.AddType(PairMap[string, int, bool]{})
c := NewConverterWithOpts(opts...)
c.AddType(StringIntPair{})
c.AddType(GenericPair[int, bool]{})
c.AddType(PairMap[string, int, bool]{})
c.AddType(EmbeddedIntPair{})
c.AddType(EmbeddedIntTriplet{})
c.AddType(EmbeddedIntModelPair{})
goldenAssert(t, c.Export(), version)
})
}
}

v3out := v3c.Export()
v4out := c.Export()
assert.Equal(t, v3out, v4out)
goldenAssert(t, v4out, "")
func TestGetTypeNameWithGenerics(t *testing.T) {
tests := map[string]string{
Comment thread
hi-rai marked this conversation as resolved.
"SimpleType": "SimpleType",
"GenericPair[int,bool]": "GenericPairIntBool",
"GenericPair[pkg.]": "GenericPair",
"GenericPair[int,zen.GenericModel]": "GenericPairIntGenericModel",
"GenericPair[int,github.com/hypersequent/zen.GenericModel]": "GenericPairIntGenericModel",
// Go reflection appends compiler-generated suffixes such as "·84" to
// function-local type names. Those unstable suffixes must not leak into the
// generated TypeScript identifiers.
"GenericPair[int,zen.GenericModel\u00b784]": "GenericPairIntGenericModel",
"GenericPair[int,*zen.GenericModel]": "GenericPairIntPointerGenericModel",
"GenericPair[int,[]zen.GenericModel]": "GenericPairIntSliceGenericModel",
"GenericPair[int,map[string]zen.GenericModel]": "GenericPairIntMapStringGenericModel",
"GenericPair[int,zen.GenericPair[string,bool]]": "GenericPairIntGenericPairStringBool",
"GenericPair[int,zen.GenericPair[string,bool]\u00b785]": "GenericPairIntGenericPairStringBool",
"GenericPair[int,map[string][]*zen.GenericModel]": "GenericPairIntMapStringSlicePointerGenericModel",
"GenericPair[int,struct{ Value zen.GenericModel }]": "GenericPairIntStructValueGenericModel",
}

for input, expected := range tests {
t.Run(input, func(t *testing.T) {
assert.Equal(t, expected, getTypeNameWithGenerics(input))
})
}
}

func TestAddTypeRejectsEmptyNames(t *testing.T) {
t.Run("anonymous struct", func(t *testing.T) {
c := NewConverterWithOpts()
assert.PanicsWithValue(
t,
"input must be a named struct; use AddTypeWithName for anonymous structs",
func() { c.AddType(struct{}{}) },
)
})

t.Run("empty custom name", func(t *testing.T) {
c := NewConverterWithOpts()
assert.PanicsWithValue(
t,
"name must not be empty",
func() { c.AddTypeWithName(struct{ X int }{}, "") },
)
})
}

func TestSliceFields(t *testing.T) {
Expand Down