diff --git a/testdata/TestGenerics.golden b/testdata/TestGenerics.golden deleted file mode 100644 index 1d9da4b..0000000 --- a/testdata/TestGenerics.golden +++ /dev/null @@ -1,18 +0,0 @@ -// @typecheck -export const StringIntPairSchema = z.object({ - First: z.string(), - Second: z.number(), -}) -export type StringIntPair = z.infer - -export const GenericPairIntBoolSchema = z.object({ - First: z.number(), - Second: z.boolean(), -}) -export type GenericPairIntBool = z.infer - -export const PairMapStringIntBoolSchema = z.object({ - items: z.record(z.string(), GenericPairIntBoolSchema).nullable(), -}) -export type PairMapStringIntBool = z.infer - diff --git a/testdata/TestGenerics/v3.golden b/testdata/TestGenerics/v3.golden new file mode 100644 index 0000000..83814be --- /dev/null +++ b/testdata/TestGenerics/v3.golden @@ -0,0 +1,54 @@ +// @zod-version: v3 +// @typecheck +export const StringIntPairSchema = z.object({ + First: z.string(), + Second: z.number(), +}) +export type StringIntPair = z.infer + +export const GenericPairIntBoolSchema = z.object({ + First: z.number(), + Second: z.boolean(), +}) +export type GenericPairIntBool = z.infer + +export const PairMapStringIntBoolSchema = z.object({ + items: z.record(z.string(), GenericPairIntBoolSchema).nullable(), +}) +export type PairMapStringIntBool = z.infer + +export const GenericPairIntIntSchema = z.object({ + First: z.number(), + Second: z.number(), +}) +export type GenericPairIntInt = z.infer + +export const EmbeddedIntPairSchema = z.object({ +}).merge(GenericPairIntIntSchema) +export type EmbeddedIntPair = z.infer + +export const GenericPairIntGenericPairIntIntSchema = z.object({ + First: z.number(), + Second: GenericPairIntIntSchema, +}) +export type GenericPairIntGenericPairIntInt = z.infer + +export const EmbeddedIntTripletSchema = z.object({ +}).merge(GenericPairIntGenericPairIntIntSchema) +export type EmbeddedIntTriplet = z.infer + +export const GenericModelSchema = z.object({ + ID: z.string(), +}) +export type GenericModel = z.infer + +export const GenericPairIntGenericModelSchema = z.object({ + First: z.number(), + Second: GenericModelSchema, +}) +export type GenericPairIntGenericModel = z.infer + +export const EmbeddedIntModelPairSchema = z.object({ +}).merge(GenericPairIntGenericModelSchema) +export type EmbeddedIntModelPair = z.infer + diff --git a/testdata/TestGenerics/v4.golden b/testdata/TestGenerics/v4.golden new file mode 100644 index 0000000..dbb46b2 --- /dev/null +++ b/testdata/TestGenerics/v4.golden @@ -0,0 +1,57 @@ +// @zod-version: v4 +// @typecheck +export const StringIntPairSchema = z.object({ + First: z.string(), + Second: z.number(), +}) +export type StringIntPair = z.infer + +export const GenericPairIntBoolSchema = z.object({ + First: z.number(), + Second: z.boolean(), +}) +export type GenericPairIntBool = z.infer + +export const PairMapStringIntBoolSchema = z.object({ + items: z.record(z.string(), GenericPairIntBoolSchema).nullable(), +}) +export type PairMapStringIntBool = z.infer + +export const GenericPairIntIntSchema = z.object({ + First: z.number(), + Second: z.number(), +}) +export type GenericPairIntInt = z.infer + +export const EmbeddedIntPairSchema = z.object({ + ...GenericPairIntIntSchema.shape, +}) +export type EmbeddedIntPair = z.infer + +export const GenericPairIntGenericPairIntIntSchema = z.object({ + First: z.number(), + Second: GenericPairIntIntSchema, +}) +export type GenericPairIntGenericPairIntInt = z.infer + +export const EmbeddedIntTripletSchema = z.object({ + ...GenericPairIntGenericPairIntIntSchema.shape, +}) +export type EmbeddedIntTriplet = z.infer + +export const GenericModelSchema = z.object({ + ID: z.string(), +}) +export type GenericModel = z.infer + +export const GenericPairIntGenericModelSchema = z.object({ + First: z.number(), + Second: GenericModelSchema, +}) +export type GenericPairIntGenericModel = z.infer + +export const EmbeddedIntModelPairSchema = z.object({ + ...GenericPairIntGenericModelSchema.shape, +}) +export type EmbeddedIntModelPair = z.infer + diff --git a/tests/cases.ts b/tests/cases.ts index f96b3a9..3d56822 100644 --- a/tests/cases.ts +++ b/tests/cases.ts @@ -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 --- { diff --git a/zod.go b/zod.go index fe9dfa8..35833f8 100644 --- a/zod.go +++ b/zod.go @@ -10,6 +10,8 @@ import ( "sort" "strconv" "strings" + "unicode" + "unicode/utf8" ) // Opt represents a converter option used to modify its behavior. @@ -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 == "" { + panic("name must not be empty") + } if _, ok := c.outputs[name]; ok { return @@ -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() diff --git a/zod_test.go b/zod_test.go index 938f8db..2822914 100644 --- a/zod_test.go +++ b/zod_test.go @@ -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 @@ -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{ + "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) {