@zyno-io/ts-reflection 26.803.2224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +13 -0
  2. package/dist/index.cjs +1 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +966 -0
  6. package/dist/reflection/annotations.d.ts +15 -0
  7. package/dist/reflection/annotations.d.ts.map +1 -0
  8. package/dist/reflection/compact-metadata.d.ts +18 -0
  9. package/dist/reflection/compact-metadata.d.ts.map +1 -0
  10. package/dist/reflection/conversion.d.ts +15 -0
  11. package/dist/reflection/conversion.d.ts.map +1 -0
  12. package/dist/reflection/deserializer.d.ts +15 -0
  13. package/dist/reflection/deserializer.d.ts.map +1 -0
  14. package/dist/reflection/errors.d.ts +8 -0
  15. package/dist/reflection/errors.d.ts.map +1 -0
  16. package/dist/reflection/index.d.ts +10 -0
  17. package/dist/reflection/index.d.ts.map +1 -0
  18. package/dist/reflection/metadata-store.d.ts +20 -0
  19. package/dist/reflection/metadata-store.d.ts.map +1 -0
  20. package/dist/reflection/model.d.ts +273 -0
  21. package/dist/reflection/model.d.ts.map +1 -0
  22. package/dist/reflection/primitive-conversion.d.ts +2 -0
  23. package/dist/reflection/primitive-conversion.d.ts.map +1 -0
  24. package/dist/reflection/reflection-class.d.ts +60 -0
  25. package/dist/reflection/reflection-class.d.ts.map +1 -0
  26. package/dist/reflection/type-utils.d.ts +34 -0
  27. package/dist/reflection/type-utils.d.ts.map +1 -0
  28. package/dist/type-compiler/download-prebuilt.cjs +114 -0
  29. package/dist/type-compiler/go/ast_expression.go +187 -0
  30. package/dist/type-compiler/go/ast_metadata.go +388 -0
  31. package/dist/type-compiler/go/collect.go +963 -0
  32. package/dist/type-compiler/go/compact_metadata.go +553 -0
  33. package/dist/type-compiler/go/emission_plan.go +340 -0
  34. package/dist/type-compiler/go/emit_ast.go +557 -0
  35. package/dist/type-compiler/go/emit_ast_test.go +558 -0
  36. package/dist/type-compiler/go/go.mod +10 -0
  37. package/dist/type-compiler/go/plugin.go +359 -0
  38. package/dist/type-compiler/go/plugin_test.go +1206 -0
  39. package/dist/type-compiler/go/precompute.go +86 -0
  40. package/dist/type-compiler/go/receive_type.go +912 -0
  41. package/dist/type-compiler/go/resolve.go +265 -0
  42. package/dist/type-compiler/go/source_scan.go +51 -0
  43. package/dist/type-compiler/go/text_parse.go +734 -0
  44. package/dist/type-compiler/go/type_expr.go +1291 -0
  45. package/dist/type-compiler/go/typia_expr.go +2316 -0
  46. package/dist/type-compiler/index.cjs +43 -0
  47. package/dist/type-compiler/pnp.cjs +474 -0
  48. package/dist/type-compiler/prebuilt.cjs +324 -0
  49. package/dist/type-metadata-runtime.cjs +1 -0
  50. package/dist/type-metadata-runtime.d.ts +2 -0
  51. package/dist/type-metadata-runtime.d.ts.map +1 -0
  52. package/dist/type-metadata-runtime.js +107 -0
  53. package/dist/types/index.d.ts +4 -0
  54. package/dist/types/index.d.ts.map +1 -0
  55. package/dist/types/primitives.d.ts +33 -0
  56. package/dist/types/primitives.d.ts.map +1 -0
  57. package/dist/types/runtime.d.ts +2 -0
  58. package/dist/types/runtime.d.ts.map +1 -0
  59. package/dist/types/type-annotations.d.ts +28 -0
  60. package/dist/types/type-annotations.d.ts.map +1 -0
  61. package/package.json +47 -0
@@ -0,0 +1,1206 @@
1
+ package main
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "reflect"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/samchon/ttsc/packages/ttsc/driver"
11
+ schemametadata "github.com/samchon/typia/packages/typia/native/core/schemas/metadata"
12
+ )
13
+
14
+ func testTypeInfo() (*fileInfo, *registry) {
15
+ info := &fileInfo{
16
+ moduleKey: "test/module",
17
+ aliases: map[string]aliasInfo{},
18
+ interfaces: map[string][]interfaceInfo{},
19
+ enums: map[string]enumInfo{},
20
+ classes: []*classInfo{},
21
+ functions: map[string][]functionInfo{},
22
+ imports: map[string]importRef{},
23
+ reexports: map[string]importRef{},
24
+ }
25
+ reg := &registry{
26
+ files: map[string]*fileInfo{info.moduleKey: info},
27
+ byPath: map[string]*fileInfo{},
28
+ classes: map[string]*classInfo{},
29
+ external: map[string]map[string][]functionInfo{},
30
+ externalPackageRoots: map[string]string{},
31
+ }
32
+ return info, reg
33
+ }
34
+
35
+ func TestExportedTypeAlgebraAliasesUseCheckerMetadata(t *testing.T) {
36
+ root := t.TempDir()
37
+ if err := os.WriteFile(filepath.Join(root, "tsconfig.json"), []byte(`{
38
+ "compilerOptions": {"strict": true, "target": "ESNext"},
39
+ "include": ["*.ts"],
40
+ "reflection": true
41
+ }`), 0o600); err != nil {
42
+ t.Fatal(err)
43
+ }
44
+ if err := os.WriteFile(filepath.Join(root, "source.ts"), []byte(`
45
+ export type SourceUnion = 'keep-first' | 'remove' | 'keep-second';
46
+ `), 0o600); err != nil {
47
+ t.Fatal(err)
48
+ }
49
+ if err := os.WriteFile(filepath.Join(root, "aliases.ts"), []byte(`
50
+ import type { SourceUnion } from './source';
51
+ export type ConditionalAlias = Exclude<SourceUnion, 'remove'>;
52
+ export type MappedAlias = Readonly<{ id: string; enabled: boolean }>;
53
+ `), 0o600); err != nil {
54
+ t.Fatal(err)
55
+ }
56
+
57
+ program, diagnostics, err := driver.LoadProgram(root, "tsconfig.json", driver.LoadProgramOptions{SingleThreaded: true})
58
+ if err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ if len(diagnostics) != 0 {
62
+ t.Fatalf("unexpected diagnostics: %v", diagnostics)
63
+ }
64
+ defer func() { _ = program.Close() }()
65
+
66
+ reg := collectRegistry(program, root, true, true)
67
+ var info *fileInfo
68
+ for _, candidate := range reg.files {
69
+ if candidate != nil && candidate.file != nil && filepath.Base(candidate.file.FileName()) == "aliases.ts" {
70
+ info = candidate
71
+ break
72
+ }
73
+ }
74
+ if info == nil {
75
+ t.Fatal("aliases.ts was not collected")
76
+ }
77
+ conditionalAlias := info.aliases["ConditionalAlias"]
78
+ if !shouldResolveTypeAlgebraWithChecker(reg, conditionalAlias.body, conditionalAlias.typeNode) {
79
+ t.Fatal("checker did not recognize Exclude as type algebra")
80
+ }
81
+ if !shouldPreferTypiaAliasMetadata(info, reg, conditionalAlias.body, conditionalAlias.typeNode, conditionalAlias.pos) {
82
+ t.Fatal("checker-recognized Exclude alias did not prefer checker metadata")
83
+ }
84
+ conditional := info.aliases["ConditionalAlias"].metadataText
85
+ assertContainsAll(t, conditional, `literal: "keep-first"`, `literal: "keep-second"`)
86
+ assertNotContains(t, conditional, `literal: "remove"`)
87
+ assertNotContains(t, conditional, "classType")
88
+ nonPreferredConditional := typeExprForNode(info, reg, conditionalAlias.body, conditionalAlias.typeNode, conditionalAlias.pos)
89
+ assertContainsAll(t, nonPreferredConditional, `literal: "keep-first"`, `literal: "keep-second"`)
90
+ assertNotContains(t, nonPreferredConditional, `literal: "remove"`)
91
+ assertNotContains(t, nonPreferredConditional, "classType")
92
+ mapped := info.aliases["MappedAlias"].metadataText
93
+ assertContainsAll(t, mapped, `name: "id"`, `name: "enabled"`)
94
+ assertNotContains(t, mapped, "classType")
95
+ }
96
+
97
+ func TestTypiaObjectTypeNameKeepsGenericDisplayName(t *testing.T) {
98
+ info, reg := testTypeInfo()
99
+ info.interfaces["GenericEnvelope"] = []interfaceInfo{{body: "{ value: T }", pos: 1}}
100
+
101
+ name, ok := typiaObjectTypeName(info, reg, &schemametadata.MetadataObjectType{
102
+ Name: "GenericEnvelope",
103
+ DisplayName: "GenericEnvelope<string, Record<string, unknown>>",
104
+ }, 10)
105
+
106
+ if !ok {
107
+ t.Fatal("declared generic object display name should be stable")
108
+ }
109
+ if name != "GenericEnvelope<string, Record<string, unknown>>" {
110
+ t.Fatalf("name = %q", name)
111
+ }
112
+
113
+ nestedName, nestedOk := typiaObjectTypeName(info, reg, &schemametadata.MetadataObjectType{
114
+ Name: "NestedEnvelope",
115
+ DisplayName: "NestedEnvelope<string>",
116
+ }, 10)
117
+
118
+ if !nestedOk {
119
+ t.Fatal("generic object display name should stay stable even when only the checker exposed it")
120
+ }
121
+ if nestedName != "NestedEnvelope<string>" {
122
+ t.Fatalf("nestedName = %q", nestedName)
123
+ }
124
+ }
125
+
126
+ func TestTypeExprInstantiatesGenericInterfaceProperties(t *testing.T) {
127
+ info, reg := testTypeInfo()
128
+ info.interfaces["GenericContainer"] = []interfaceInfo{{
129
+ params: []string{"T"},
130
+ properties: []utilityProperty{
131
+ {name: "items", typeText: "T[]", optional: true},
132
+ {name: "alternatives", typeText: "T[]", optional: true},
133
+ },
134
+ pos: 1,
135
+ }}
136
+ info.aliases["GenericVariant"] = aliasInfo{body: "{ kind: 'alpha' } | { kind: 'beta'; mode: 'first' | 'second' }"}
137
+
138
+ got := typeExpr(info, reg, "GenericContainer<GenericVariant>")
139
+
140
+ assertContainsAll(t, got,
141
+ `kind: 18, typeName: "GenericContainer"`,
142
+ `name: "items", type: {kind: 12, types: [{kind: 14, type: {kind: 12`,
143
+ `literal: "alpha"`,
144
+ `literal: "beta"`,
145
+ `literal: "first"`,
146
+ `literal: "second"`,
147
+ )
148
+ assertNotContains(t, got, `kind: 16, typeName: "GenericContainer"`)
149
+ }
150
+
151
+ func TestTypeExprBoundsRecursiveGenericInterfaceExpansion(t *testing.T) {
152
+ info, reg := testTypeInfo()
153
+ info.interfaces["RecursiveContainer"] = []interfaceInfo{{
154
+ params: []string{"T"},
155
+ properties: []utilityProperty{
156
+ {name: "value", typeText: "T"},
157
+ {name: "next", typeText: "RecursiveContainer<T[]>", optional: true},
158
+ },
159
+ pos: 1,
160
+ }}
161
+
162
+ got := typeExpr(info, reg, "RecursiveContainer<string>")
163
+
164
+ assertContainsAll(t, got,
165
+ `kind: 18, typeName: "RecursiveContainer"`,
166
+ `name: "value", type: {kind: 6}`,
167
+ `name: "next", type: {kind: 12, types: [{kind: 2, typeName: "RecursiveContainer"}`,
168
+ )
169
+ if len(got) > 2000 {
170
+ t.Fatalf("recursive generic metadata should stay bounded, got %d bytes", len(got))
171
+ }
172
+ }
173
+
174
+ func TestSplitTopIgnoresNestedSyntax(t *testing.T) {
175
+ input := "Pick<User, 'id' | 'email'> | { value: string | number } | `x${a | b}` | Array<boolean>"
176
+ got := splitTop(input, "|")
177
+ want := []string{
178
+ "Pick<User, 'id' | 'email'>",
179
+ "{ value: string | number }",
180
+ "`x${a | b}`",
181
+ "Array<boolean>",
182
+ }
183
+ if !reflect.DeepEqual(got, want) {
184
+ t.Fatalf("splitTop() = %#v, want %#v", got, want)
185
+ }
186
+ }
187
+
188
+ func TestSplitTopPreservesCommentMarkersInsideLiterals(t *testing.T) {
189
+ input := `'https://example.com/a/*b*/' | /* actual comment */ ` + "`route//segment/${string}`" + ` | 'done'`
190
+ got := splitTop(input, "|")
191
+ want := []string{
192
+ `'https://example.com/a/*b*/'`,
193
+ "`route//segment/${string}`",
194
+ `'done'`,
195
+ }
196
+ if !reflect.DeepEqual(got, want) {
197
+ t.Fatalf("splitTop() = %#v, want %#v", got, want)
198
+ }
199
+ }
200
+
201
+ func TestTypeExprIgnoresCommentsInsideUnions(t *testing.T) {
202
+ file := parseTestSourceFile(t, "/project/commented-union.ts", `
203
+ type Status =
204
+ | 'draft'
205
+ /* client's legacy { branch */
206
+ | 'published'
207
+ // closing ( branch
208
+ | 'archived';
209
+ `)
210
+ alias := aliasFromNode(file, file.Statements.Nodes[0])
211
+ info, reg := testTypeInfo()
212
+
213
+ got := typeExpr(info, reg, alias.body)
214
+ assertContainsAll(t, got,
215
+ `literal: "draft"`,
216
+ `literal: "published"`,
217
+ `literal: "archived"`,
218
+ )
219
+ }
220
+
221
+ func TestTypeExprForNodeUsesAstUnionMemberOrder(t *testing.T) {
222
+ file := parseTestSourceFile(t, "/project/ordered-union.ts", `
223
+ type Status =
224
+ | 'draft'
225
+ /* punctuation that would poison text splitting: client's { ( */
226
+ | 'published'
227
+ | 'archived';
228
+ `)
229
+ alias := aliasFromNode(file, file.Statements.Nodes[0])
230
+ info, reg := testTypeInfo()
231
+ info.file = file
232
+
233
+ got := typeExprForNode(info, reg, "'not-from-the-node'", alias.typeNode, alias.pos)
234
+ assertContainsAll(t, got,
235
+ `literal: "draft"`,
236
+ `literal: "published"`,
237
+ `literal: "archived"`,
238
+ )
239
+ assertNotContains(t, got, `literal: "not-from-the-node"`)
240
+ draft := strings.Index(got, `literal: "draft"`)
241
+ published := strings.Index(got, `literal: "published"`)
242
+ archived := strings.Index(got, `literal: "archived"`)
243
+ if draft > published || published > archived {
244
+ t.Fatalf("AST union order was not preserved: %s", got)
245
+ }
246
+ }
247
+
248
+ func TestTypeExprForNodeUsesAstIntersectionMemberOrder(t *testing.T) {
249
+ file := parseTestSourceFile(t, "/project/ordered-intersection.ts", `
250
+ type Name =
251
+ string
252
+ & MinLength<2>
253
+ /* punctuation that would poison text splitting: { */
254
+ & MaxLength<20>;
255
+ `)
256
+ alias := aliasFromNode(file, file.Statements.Nodes[0])
257
+ info, reg := testTypeInfo()
258
+ info.file = file
259
+
260
+ got := typeExprForNode(info, reg, "string", alias.typeNode, alias.pos)
261
+ assertContainsAll(t, got,
262
+ `typeName: "MinLength"`,
263
+ `typeName: "MaxLength"`,
264
+ )
265
+ minimum := strings.Index(got, `typeName: "MinLength"`)
266
+ maximum := strings.Index(got, `typeName: "MaxLength"`)
267
+ if minimum > maximum {
268
+ t.Fatalf("AST intersection order was not preserved: %s", got)
269
+ }
270
+ }
271
+
272
+ func TestIndexedAccessAliasPrefersCheckerMetadata(t *testing.T) {
273
+ info, reg := testTypeInfo()
274
+
275
+ if !shouldPreferTypiaAliasMetadata(info, reg, "(typeof PublishableKeyFeatures)[number]", nil, 1) {
276
+ t.Fatal("indexed-access aliases should be resolved by checker-backed metadata")
277
+ }
278
+ }
279
+
280
+ func TestRenderUtilityPropertyUsesAstUnionMembers(t *testing.T) {
281
+ file := parseTestSourceFile(t, "/project/utility-union.ts", `
282
+ type Status =
283
+ | 'draft'
284
+ /* punctuation that would poison text splitting: { */
285
+ | 'published';
286
+ `)
287
+ alias := aliasFromNode(file, file.Statements.Nodes[0])
288
+ info, reg := testTypeInfo()
289
+ info.file = file
290
+
291
+ got := renderUtilityProperty(info, reg, utilityProperty{
292
+ name: "status",
293
+ typeText: "'not-from-the-node'",
294
+ typeNode: alias.typeNode,
295
+ }, &typeContext{seen: map[string]bool{}})
296
+ assertContainsAll(t, got,
297
+ `literal: "draft"`,
298
+ `literal: "published"`,
299
+ )
300
+ assertNotContains(t, got, `literal: "not-from-the-node"`)
301
+ }
302
+
303
+ func TestUtilityClassPropertyRetainsAstUnionMembers(t *testing.T) {
304
+ file := parseTestSourceFile(t, "/project/class-utility-union.ts", `
305
+ type Status =
306
+ | 'draft'
307
+ /* punctuation that would poison text splitting: { */
308
+ | 'published';
309
+ `)
310
+ alias := aliasFromNode(file, file.Statements.Nodes[0])
311
+ info, reg := testTypeInfo()
312
+ info.file = file
313
+ info.classes = []*classInfo{{
314
+ name: "User",
315
+ properties: []propertyInfo{{
316
+ name: "status",
317
+ typeText: "'not-from-the-node'",
318
+ typeNode: alias.typeNode,
319
+ }},
320
+ }}
321
+
322
+ got := typeExpr(info, reg, "Pick<User, 'status'>")
323
+ assertContainsAll(t, got,
324
+ `literal: "draft"`,
325
+ `literal: "published"`,
326
+ )
327
+ assertNotContains(t, got, `literal: "not-from-the-node"`)
328
+ }
329
+
330
+ func TestMetadataCallNamesIncludeValidatedDeserialize(t *testing.T) {
331
+ for _, name := range []string{"deserialize", "validate", "validatedDeserialize", "typeOf"} {
332
+ if !isMetadataCallName(name) {
333
+ t.Fatalf("%s should be collected as a metadata call", name)
334
+ }
335
+ }
336
+ for _, name := range []string{"cast", "assert", "is"} {
337
+ if isMetadataCallName(name) {
338
+ t.Fatalf("collision-prone compatibility helper %s must require explicit metadata", name)
339
+ }
340
+ }
341
+ }
342
+
343
+ func TestCompatibilityMetadataCallsRequireFoundationImports(t *testing.T) {
344
+ for _, ref := range []importRef{
345
+ {spec: foundationPackageSpec, exportName: "assert"},
346
+ {source: "/workspace/ts-server-foundation/src/index", spec: "../src", exportName: "is"},
347
+ {source: "/workspace/ts-server-foundation/src/reflection/conversion", spec: "./conversion", exportName: "cast"},
348
+ } {
349
+ if !isFoundationCompatibilityImport(ref) {
350
+ t.Fatalf("foundation compatibility import was not recognized: %#v", ref)
351
+ }
352
+ }
353
+
354
+ for _, ref := range []importRef{
355
+ {spec: "node:assert/strict", exportName: "assert"},
356
+ {spec: "@scope/application-helpers", exportName: "is"},
357
+ {source: "/workspace/application/cast", spec: "./cast", exportName: "cast"},
358
+ {source: "/workspace/application/src/index", spec: "../src", exportName: "cast"},
359
+ } {
360
+ if isFoundationCompatibilityImport(ref) {
361
+ t.Fatalf("non-foundation compatibility import was recognized: %#v", ref)
362
+ }
363
+ }
364
+ }
365
+
366
+ func TestReceiveTypeMethodCandidatesIncludeStaticMethods(t *testing.T) {
367
+ info, reg := testTypeInfo()
368
+ info.classes = []*classInfo{{
369
+ name: "FiltersHelpers",
370
+ staticMethods: []methodInfo{{
371
+ name: "extractFilters",
372
+ typeParams: []string{"T"},
373
+ params: []paramInfo{{name: "input", typeText: "string"}, {name: "type", typeText: "ReceiveType<T>"}},
374
+ }},
375
+ }}
376
+ reg.files[info.moduleKey] = info
377
+
378
+ if got := receiveTypeMethodCandidates(reg)["extractFilters"]; len(got) != 1 || got[0].owner != "FiltersHelpers" {
379
+ t.Fatalf("static receive-type candidates = %#v", got)
380
+ }
381
+ }
382
+
383
+ func TestCollectRegistryHandlesReceiveTypeCallsToComputedMethods(t *testing.T) {
384
+ root := t.TempDir()
385
+ if err := os.WriteFile(filepath.Join(root, "tsconfig.json"), []byte(`{
386
+ "compilerOptions": {"strict": true, "target": "ESNext"},
387
+ "include": ["*.ts"],
388
+ "reflection": true
389
+ }`), 0o600); err != nil {
390
+ t.Fatal(err)
391
+ }
392
+ if err := os.WriteFile(filepath.Join(root, "source.ts"), []byte(`
393
+ type ReceiveType<T> = { readonly type?: T };
394
+ const method = 'receive';
395
+
396
+ class Receiver {
397
+ [method]<T>(value: T, type?: ReceiveType<T>): T {
398
+ return value;
399
+ }
400
+ }
401
+
402
+ new Receiver()[method]<{ id: string }>({ id: 'one' });
403
+ `), 0o600); err != nil {
404
+ t.Fatal(err)
405
+ }
406
+
407
+ program, diagnostics, err := driver.LoadProgram(root, "tsconfig.json", driver.LoadProgramOptions{SingleThreaded: true})
408
+ if err != nil {
409
+ t.Fatal(err)
410
+ }
411
+ if len(diagnostics) != 0 {
412
+ t.Fatalf("unexpected diagnostics: %v", diagnostics)
413
+ }
414
+ defer func() { _ = program.Close() }()
415
+
416
+ reg := collectRegistry(program, root, true, true)
417
+ var info *fileInfo
418
+ for _, candidate := range reg.files {
419
+ if candidate != nil && candidate.file != nil && filepath.Base(candidate.file.FileName()) == "source.ts" {
420
+ info = candidate
421
+ break
422
+ }
423
+ }
424
+ if info == nil {
425
+ t.Fatal("source.ts was not collected")
426
+ }
427
+ if len(info.calls) != 1 {
428
+ t.Fatalf("receive-type calls = %#v", info.calls)
429
+ }
430
+ if info.calls[0].metadataArgIndex != 1 || info.calls[0].typeText != "{ id: string }" {
431
+ t.Fatalf("receive-type call = %#v", info.calls[0])
432
+ }
433
+ }
434
+
435
+ func TestReceiveTypeArgumentAcceptsQualifiedImports(t *testing.T) {
436
+ for _, input := range []string{
437
+ "ReceiveType<T>",
438
+ "reflection.ReceiveType<T>",
439
+ `import("@zyno-io/ts-server-foundation").ReceiveType<T>`,
440
+ } {
441
+ got, ok := receiveTypeArgument(input)
442
+ if !ok || got != "T" {
443
+ t.Fatalf("receiveTypeArgument(%q) = %q, %v", input, got, ok)
444
+ }
445
+ }
446
+ }
447
+
448
+ func TestExternalPackageRootUsesMaterializedPnpPackage(t *testing.T) {
449
+ root := t.TempDir()
450
+ materialized := filepath.Join(root, "materialized-package")
451
+ if err := os.MkdirAll(materialized, 0o755); err != nil {
452
+ t.Fatal(err)
453
+ }
454
+ reg := &registry{externalPackageRoots: map[string]string{"@fixture/receive-types": materialized}}
455
+ got := externalPackageRoot(filepath.Join(root, "src", "service.ts"), "@fixture/receive-types/client", reg)
456
+ if got != materialized {
457
+ t.Fatalf("externalPackageRoot() = %q, want %q", got, materialized)
458
+ }
459
+ }
460
+
461
+ func TestPropertiesFromBodyParsesFieldsAndIgnoresMembers(t *testing.T) {
462
+ body := `
463
+ id: string;
464
+ optional?: number
465
+ tuple: [string, number]
466
+ method(value: string): void
467
+ [key: string]: boolean
468
+ // comment: ignored
469
+ nested: { count: number; label: string }
470
+ `
471
+ got := propertiesFromBody(body)
472
+ want := []utilityProperty{
473
+ {name: "id", typeText: "string"},
474
+ {name: "optional", typeText: "number", optional: true},
475
+ {name: "tuple", typeText: "[string, number]"},
476
+ {name: "nested", typeText: "{ count: number; label: string }"},
477
+ }
478
+ if !reflect.DeepEqual(got, want) {
479
+ t.Fatalf("propertiesFromBody() = %#v, want %#v", got, want)
480
+ }
481
+ }
482
+
483
+ func TestTypeExprRendersObjectLiteralsAndUtilityTypes(t *testing.T) {
484
+ info, reg := testTypeInfo()
485
+ info.aliases["User"] = aliasInfo{body: "{ id: string; email?: string; count: number }"}
486
+
487
+ objectExpr := typeExpr(info, reg, "{ id: string; count?: number; [key: string]: boolean }")
488
+ for _, want := range []string{
489
+ "kind: 18",
490
+ "index: {kind: 8}",
491
+ "name: \"id\", type: {kind: 6}",
492
+ "name: \"count\", type: {kind: 12, types: [{kind: 7}, {kind: 4}]}, optional: true",
493
+ } {
494
+ if !strings.Contains(objectExpr, want) {
495
+ t.Fatalf("object expression %q does not contain %q", objectExpr, want)
496
+ }
497
+ }
498
+
499
+ pickExpr := typeExpr(info, reg, "Pick<User, 'id' | 'email'>")
500
+ for _, want := range []string{
501
+ "typeName: \"Pick\"",
502
+ "utilityType: \"Pick\"",
503
+ "utilityKeys: [\"id\", \"email\"]",
504
+ "name: \"id\", type: {kind: 6}",
505
+ "name: \"email\", type: {kind: 12, types: [{kind: 6}, {kind: 4}]}, optional: true",
506
+ } {
507
+ if !strings.Contains(pickExpr, want) {
508
+ t.Fatalf("pick expression %q does not contain %q", pickExpr, want)
509
+ }
510
+ }
511
+
512
+ lengthExpr := typeExpr(info, reg, "Length<4>")
513
+ assertContainsAll(t, lengthExpr,
514
+ `kind: 13, typeName: "Length"`,
515
+ `kind: 6`,
516
+ `typeName: "MinLength", validation: [{name: "minLength", args: [{kind: 10, literal: 4}]}]`,
517
+ `typeName: "MaxLength", validation: [{name: "maxLength", args: [{kind: 10, literal: 4}]}]`,
518
+ )
519
+
520
+ unionReturnExpr := typeExpr(info, reg, "Promise<{ result: 'token' } | { result: 'login'; jwt: string }>")
521
+ assertContainsAll(t, unionReturnExpr,
522
+ "kind: 22",
523
+ "literal: \"token\"",
524
+ "literal: \"login\"",
525
+ "name: \"jwt\", type: {kind: 6}",
526
+ )
527
+ assertNotContains(t, unionReturnExpr, "literal: 'token'")
528
+ assertNotContains(t, unionReturnExpr, "typeName: \"{ result:")
529
+ }
530
+
531
+ func TestTypeExprRendersUtilityTypeMatrix(t *testing.T) {
532
+ info, reg := testTypeInfo()
533
+ info.aliases["User"] = aliasInfo{body: "{ id: string; email?: string; count: number; active: boolean }"}
534
+ info.aliases["Container"] = aliasInfo{body: "{ items: Array<{ id: string; count: number }>; maybe: { enabled: boolean } | null }"}
535
+
536
+ omitExpr := typeExpr(info, reg, "Omit<User, 'email' | 'active'>")
537
+ assertContainsAll(t, omitExpr,
538
+ "typeName: \"Omit\"",
539
+ "name: \"id\", type: {kind: 6}",
540
+ "name: \"count\", type: {kind: 7}",
541
+ "types: [{kind: 20, name: \"id\", type: {kind: 6}, optional: false}, {kind: 20, name: \"count\", type: {kind: 7}, optional: false}]",
542
+ )
543
+
544
+ partialExpr := typeExpr(info, reg, "Partial<Pick<User, 'id' | 'count'>>")
545
+ assertContainsAll(t, partialExpr,
546
+ "typeName: \"Partial\"",
547
+ "name: \"id\", type: {kind: 12, types: [{kind: 6}, {kind: 4}]}, optional: true",
548
+ "name: \"count\", type: {kind: 12, types: [{kind: 7}, {kind: 4}]}, optional: true",
549
+ )
550
+
551
+ requiredExpr := typeExpr(info, reg, "Required<Pick<User, 'email'>>")
552
+ assertContainsAll(t, requiredExpr,
553
+ "typeName: \"Required\"",
554
+ "name: \"email\", type: {kind: 6}, optional: false",
555
+ )
556
+
557
+ recordExpr := typeExpr(info, reg, "Record<'email' | 'phone', string | null>")
558
+ assertContainsAll(t, recordExpr,
559
+ "typeName: \"Record\"",
560
+ "utilityType: \"Record\"",
561
+ "typeArguments: [{kind: 12, types: [{kind: 10, literal: \"email\"}, {kind: 10, literal: \"phone\"}]}, {kind: 12, types: [{kind: 6}, {kind: 5}]}]",
562
+ "index: {kind: 12, types: [{kind: 6}, {kind: 5}]}",
563
+ )
564
+
565
+ extractExpr := typeExpr(info, reg, "Extract<{ type: 'blank' } | { type: 'webView'; url: string } | { type: 'mediaRef'; mediaId: string }, { type: 'blank' } | { type: 'webView' }>")
566
+ assertContainsAll(t, extractExpr,
567
+ "typeName: \"Extract\"",
568
+ "utilityType: \"Extract\"",
569
+ "literal: \"mediaRef\"",
570
+ "literal: \"webView\"",
571
+ )
572
+
573
+ indexedExpr := typeExpr(info, reg, "Pick<Container['items'][number], 'id'>")
574
+ assertContainsAll(t, indexedExpr,
575
+ "typeName: \"Pick\"",
576
+ "name: \"id\", type: {kind: 6}",
577
+ "types: [{kind: 20, name: \"id\", type: {kind: 6}, optional: false}]",
578
+ )
579
+
580
+ markerIntersectionExpr := typeExpr(info, reg, "Pick<User & TypeAnnotation<'example:marker'>, 'id'>")
581
+ assertContainsAll(t, markerIntersectionExpr,
582
+ "typeName: \"Pick\"",
583
+ "name: \"id\", type: {kind: 6}",
584
+ )
585
+ }
586
+
587
+ func TestShouldUseTypiaTypeForNullableMappedAliases(t *testing.T) {
588
+ info, reg := testTypeInfo()
589
+ info.aliases["NullableKeys"] = aliasInfo{
590
+ body: "{ [K in keyof T]-?: null extends T[K] ? K : never }[keyof T]",
591
+ params: []string{"T"},
592
+ }
593
+ info.aliases["NullableOptionals"] = aliasInfo{
594
+ body: "Omit<T, NullableKeys<T>> & Partial<Pick<T, NullableKeys<T>>>",
595
+ params: []string{"T"},
596
+ }
597
+
598
+ if !shouldUseTypiaType(info, reg, "NullableOptionals<Pick<User, 'name' | 'color'>>") {
599
+ t.Fatal("nullable mapped alias should route through Typia metadata")
600
+ }
601
+ if !canPreferTypiaType(info, reg, "NullableOptionals<Pick<User, 'name' | 'color'>>") {
602
+ t.Fatal("plain nullable mapped alias should be safe for Typia metadata")
603
+ }
604
+ if !receiveTypeMetadataResolvable(info, reg, "NullableOptionals<Pick<User, 'name' | 'color'>>") {
605
+ t.Fatal("nullable mapped alias should be accepted by receive-type metadata preflight")
606
+ }
607
+ if shouldUseTypiaType(info, reg, "Partial<Pick<User, 'name'>>") {
608
+ t.Fatal("ordinary utility type should stay on the internal encoder")
609
+ }
610
+ if !canPreferTypiaType(info, reg, "Partial<Pick<User, 'name'>>") {
611
+ t.Fatal("ordinary utility type should be safe when a preferred metadata surface requests Typia")
612
+ }
613
+ if canPreferTypiaType(info, reg, "HttpBody<NullableOptionals<Pick<User, 'name' | 'color'>>>") {
614
+ t.Fatal("outer HTTP marker should be preserved by the internal encoder")
615
+ }
616
+ if canPreferTypiaType(info, reg, "string & DatabaseField<{ type: 'CHAR(36)' }>") {
617
+ t.Fatal("database markers should stay on the internal encoder")
618
+ }
619
+ if canPreferTypiaType(info, reg, "string & TypiaFormat<'date'> & TsfDatabaseFieldTag<{ type: 'DATE' }> & TsfTypeTag<'string', 'date'>") {
620
+ t.Fatal("database field tags should stay on the internal encoder even when combined with Typia-compatible tags")
621
+ }
622
+ if !canPreferTypiaType(info, reg, "string & MinLength<2>") {
623
+ t.Fatal("Typia-compatible validation marker should be safe for Typia metadata")
624
+ }
625
+ if !canPreferTypiaType(info, reg, "number & GreaterThan<0>") {
626
+ t.Fatal("Typia-compatible greater-than marker should be safe for Typia metadata")
627
+ }
628
+
629
+ info.aliases["UserWithDatabaseField"] = aliasInfo{body: "{ name: string; color: string | null; note: string & DatabaseField<{ type: 'TEXT' }> | null; status: 'active' | 'inactive'; createdAt: Date }"}
630
+ if !canPreferTypiaTypeOnPreferredSurface(info, reg, "NullableOptionals<Pick<UserWithDatabaseField, 'name' | 'color' | 'note'>>") {
631
+ t.Fatal("preferred structural metadata should allow Typia to resolve nested database tags")
632
+ }
633
+ info.imports["FoundationUtility"] = importRef{source: "", exportName: "FoundationUtility", spec: foundationPackageSpec}
634
+ if !canPreferTypiaTypeOnPreferredSurface(info, reg, "FoundationUtility<Pick<UserWithDatabaseField, 'name' | 'color' | 'note' | 'status'>>") {
635
+ t.Fatal("imported foundation utility helpers should be resolved by Typia on preferred metadata surfaces")
636
+ }
637
+ if !canPreferTypiaTypeOnPreferredSurface(info, reg, "Pick<UserWithDatabaseField, 'createdAt'>") {
638
+ t.Fatal("selected Date properties should be rendered by Typia on preferred metadata surfaces")
639
+ }
640
+ if canPreferTypiaTypeOnPreferredSurface(info, reg, "HttpBody<NullableOptionals<Pick<UserWithDatabaseField, 'name' | 'color' | 'note'>>>") {
641
+ t.Fatal("outer HTTP marker should still be preserved on preferred metadata surfaces")
642
+ }
643
+ if canPreferTypiaTypeOnPreferredSurface(info, reg, "string & DatabaseField<{ type: 'CHAR(36)' }>") {
644
+ t.Fatal("direct database marker payload should stay on the internal encoder")
645
+ }
646
+ if !canPreferTypiaTypeOnPreferredSurface(info, reg, "Record<string, string>") {
647
+ t.Fatal("Record/index metadata should be resolved by Typia on preferred structural surfaces")
648
+ }
649
+ info.aliases["IndexedAccessSource"] = aliasInfo{body: "{ items: Array<{ id: string; label: string }> }"}
650
+ if !canPreferTypiaTypeOnPreferredSurface(info, reg, "IndexedAccessSource['items'][number]") {
651
+ t.Fatal("indexed access should be resolved by Typia on preferred structural surfaces")
652
+ }
653
+ info.aliases["IndexedAccessLiteralSource"] = aliasInfo{body: "{ status: 'open' | 'closed' | 'voided' }"}
654
+ if !sourceTypeNeedsInternalPropertyMetadata(info, reg, "IndexedAccessLiteralSource['status'] | null", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
655
+ t.Fatal("indexed access to literal unions should preserve source union order at property level")
656
+ }
657
+ info.interfaces["IndexedAccessParams"] = []interfaceInfo{{properties: []utilityProperty{{name: "platform", typeText: "IndexedAccessLiteralSource['status']"}}, pos: 1}}
658
+ if !sourceTypeNeedsInternalPropertyMetadata(info, reg, "IndexedAccessParams['platform']", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
659
+ t.Fatal("indexed access chains to literal unions should preserve source union order at property level")
660
+ }
661
+ serviceInfo := &fileInfo{
662
+ moduleKey: "test/service",
663
+ aliases: map[string]aliasInfo{},
664
+ interfaces: map[string][]interfaceInfo{"CreateParams": {{properties: []utilityProperty{{name: "platform", typeText: "DeviceEntity['platform']"}}, pos: 1}}},
665
+ enums: map[string]enumInfo{},
666
+ classes: []*classInfo{{name: "DeviceEntity", properties: []propertyInfo{{name: "platform", typeText: "'ios' | 'android'"}}}},
667
+ functions: map[string][]functionInfo{},
668
+ imports: map[string]importRef{},
669
+ reexports: map[string]importRef{},
670
+ }
671
+ info.imports["CreateParams"] = importRef{source: "test/service", exportName: "CreateParams", spec: "test/service"}
672
+ reg.files[serviceInfo.moduleKey] = serviceInfo
673
+ reg.byPath[serviceInfo.moduleKey] = serviceInfo
674
+ if !sourceTypeNeedsInternalPropertyMetadata(info, reg, "CreateParams['platform']", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
675
+ t.Fatal("cross-file indexed access chains to literal unions should preserve source union order at property level")
676
+ }
677
+ info.interfaces["RequestWithIndexedPlatform"] = []interfaceInfo{{properties: []utilityProperty{{name: "platform", typeText: "CreateParams['platform']"}}, pos: 1}}
678
+ override, ok := typiaSourcePropertyOverrideExpr(info, reg, "RequestWithIndexedPlatform", "platform", 10)
679
+ if !ok {
680
+ t.Fatal("cross-file indexed access chains should trigger property-level internal metadata")
681
+ }
682
+ assertContainsAll(t, override, "literal: \"ios\"", "literal: \"android\"")
683
+ objectOverride, ok := typiaNamedObjectInternalOverrideExpr(info, reg, "RequestWithIndexedPlatform", 10)
684
+ if !ok {
685
+ t.Fatal("named objects with indexed access properties should use internal metadata")
686
+ }
687
+ iosIndex := strings.Index(objectOverride, "literal: \"ios\"")
688
+ androidIndex := strings.Index(objectOverride, "literal: \"android\"")
689
+ if iosIndex < 0 || androidIndex < 0 || iosIndex > androidIndex {
690
+ t.Fatalf("object override did not preserve literal order: %s", objectOverride)
691
+ }
692
+ preferredNamed, ok := preferredNamedInterfaceTypeExpr(info, reg, "RequestWithIndexedPlatform", 10)
693
+ if !ok {
694
+ t.Fatal("preferred named interface path should preserve indexed-access literal metadata")
695
+ }
696
+ iosIndex = strings.Index(preferredNamed, "literal: \"ios\"")
697
+ androidIndex = strings.Index(preferredNamed, "literal: \"android\"")
698
+ if iosIndex < 0 || androidIndex < 0 || iosIndex > androidIndex {
699
+ t.Fatalf("preferred named interface path did not preserve literal order: %s", preferredNamed)
700
+ }
701
+ entityInfo := &fileInfo{
702
+ moduleKey: "test/entity",
703
+ aliases: map[string]aliasInfo{},
704
+ interfaces: map[string][]interfaceInfo{},
705
+ enums: map[string]enumInfo{},
706
+ classes: []*classInfo{{name: "ImportedDeviceEntity", properties: []propertyInfo{{name: "platform", typeText: "'ios' | 'android'"}}}},
707
+ functions: map[string][]functionInfo{},
708
+ imports: map[string]importRef{},
709
+ reexports: map[string]importRef{},
710
+ }
711
+ importedServiceInfo := &fileInfo{
712
+ moduleKey: "test/imported-service",
713
+ aliases: map[string]aliasInfo{},
714
+ interfaces: map[string][]interfaceInfo{"ImportedCreateParams": {{
715
+ properties: []utilityProperty{{name: "platform", typeText: "ImportedDeviceEntity['platform']"}},
716
+ pos: 1,
717
+ }}},
718
+ enums: map[string]enumInfo{},
719
+ classes: []*classInfo{},
720
+ functions: map[string][]functionInfo{},
721
+ imports: map[string]importRef{"ImportedDeviceEntity": {source: "test/entity", exportName: "ImportedDeviceEntity", spec: "test/entity"}},
722
+ reexports: map[string]importRef{},
723
+ }
724
+ info.imports["ImportedCreateParams"] = importRef{source: "test/imported-service", exportName: "ImportedCreateParams", spec: "test/imported-service"}
725
+ reg.files[entityInfo.moduleKey] = entityInfo
726
+ reg.byPath[entityInfo.moduleKey] = entityInfo
727
+ reg.files[importedServiceInfo.moduleKey] = importedServiceInfo
728
+ reg.byPath[importedServiceInfo.moduleKey] = importedServiceInfo
729
+ info.interfaces["RequestWithImportedIndexedPlatform"] = []interfaceInfo{{properties: []utilityProperty{{name: "platform", typeText: "ImportedCreateParams['platform']"}}, pos: 1}}
730
+ if !sourceTypeNeedsInternalPropertyMetadata(info, reg, "ImportedCreateParams['platform']", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
731
+ t.Fatal("imported indexed access chains to imported class literal unions should preserve source order")
732
+ }
733
+ preferredNamed, ok = preferredNamedInterfaceTypeExpr(info, reg, "RequestWithImportedIndexedPlatform", 10)
734
+ if !ok {
735
+ t.Fatal("preferred named interface path should preserve imported indexed-access literal metadata")
736
+ }
737
+ iosIndex = strings.Index(preferredNamed, "literal: \"ios\"")
738
+ androidIndex = strings.Index(preferredNamed, "literal: \"android\"")
739
+ if iosIndex < 0 || androidIndex < 0 || iosIndex > androidIndex {
740
+ t.Fatalf("preferred imported named interface path did not preserve literal order: %s", preferredNamed)
741
+ }
742
+
743
+ info.aliases["DateString"] = aliasInfo{body: "string & TypiaFormat<'date'> & TsfTypeTag<'string', 'date'>"}
744
+ info.aliases["UuidString"] = aliasInfo{body: "string & TypiaFormat<'uuid'> & TsfTypeTag<'string', 'uuidString'>"}
745
+ if canPreferTypiaType(info, reg, "DateString | UuidString") {
746
+ t.Fatal("tagged string unions should stay on the internal encoder to preserve alternatives")
747
+ }
748
+ if canPreferTypiaTypeOnPreferredSurface(info, reg, "DateString | UuidString") {
749
+ t.Fatal("tagged string unions should stay on the internal encoder on preferred metadata surfaces")
750
+ }
751
+ if canPreferTypiaType(info, reg, "{ startsAt: Date }") {
752
+ t.Fatal("Date should stay on the internal encoder to preserve runtime Date deserialization")
753
+ }
754
+ if !canPreferTypiaTypeOnPreferredSurface(info, reg, "{ startsAt: Date }") {
755
+ t.Fatal("Date should not force a whole object onto the internal encoder on preferred metadata surfaces")
756
+ }
757
+ if canPreferTypiaType(info, reg, "Date & TsfValidatorTag<'object', 'validDate'>") {
758
+ t.Fatal("Date validator intersections should stay on the internal encoder to preserve Date semantics")
759
+ }
760
+ if canPreferTypiaTypeOnPreferredSurface(info, reg, "{ startsAt: Date & TsfValidatorTag<'object', 'validDate'> }") {
761
+ t.Fatal("object-target validator tags should stay on the internal encoder because Typia does not preserve them on Date metadata")
762
+ }
763
+ info.interfaces["AnnotatedDto"] = []interfaceInfo{{body: "name: string & MinLength<2>; count: number"}}
764
+ if !canPreferTypiaType(info, reg, "AnnotatedDto") {
765
+ t.Fatal("referenced DTO with Typia-compatible validation should be safe for Typia metadata")
766
+ }
767
+ }
768
+
769
+ func TestPreferredInterfacePreservesNamedAliasProperties(t *testing.T) {
770
+ info, reg := testTypeInfo()
771
+ info.aliases["NamedDetails"] = aliasInfo{
772
+ body: "Partial<Pick<SourceEntity, 'left' | 'right'>>",
773
+ metadataText: "{kind: 18, typeName: \"NamedDetails\", types: [{kind: 20, name: \"left\", type: {kind: 6}, optional: true}]}",
774
+ }
775
+ info.interfaces["CreateRequest"] = []interfaceInfo{{
776
+ body: "name: string; details?: NamedDetails; optionalDetails?: NamedDetails | null",
777
+ properties: []utilityProperty{
778
+ {name: "name", typeText: "string"},
779
+ {name: "details", typeText: "NamedDetails", optional: true},
780
+ {name: "optionalDetails", typeText: "NamedDetails | null", optional: true},
781
+ },
782
+ }}
783
+
784
+ expr := typeExprForNodePreferred(info, reg, "CreateRequest", nil, 0, true)
785
+ assertContainsAll(t, expr,
786
+ "typeName: \"CreateRequest\"",
787
+ "name: \"details\"",
788
+ "name: \"optionalDetails\"",
789
+ "typeName: \"NamedDetails\"",
790
+ )
791
+ if strings.Contains(expr, "Partial<Pick") {
792
+ t.Fatalf("preferred interface expression leaked utility display name: %s", expr)
793
+ }
794
+ }
795
+
796
+ func TestTypeExprRendersFoundationAnnotations(t *testing.T) {
797
+ info, reg := testTypeInfo()
798
+
799
+ got := typeExpr(info, reg, "string & MinLength<2> & TypeAnnotation<'tsf:type', 'custom'>")
800
+ for _, want := range []string{
801
+ "kind: 13",
802
+ "typeName: \"MinLength\"",
803
+ "validation: [{name: \"minLength\", args: [{kind: 10, literal: 2}]}]",
804
+ "annotations: {\"tsf:type\": {kind: 10, literal: \"custom\"}}",
805
+ } {
806
+ if !strings.Contains(got, want) {
807
+ t.Fatalf("annotation expression %q does not contain %q", got, want)
808
+ }
809
+ }
810
+
811
+ got = typeExpr(info, reg, "Date & TsfValidatorTag<'object', 'validDate'>")
812
+ assertContainsAll(t, got,
813
+ "typeName: \"Date\"",
814
+ "classType: () =>",
815
+ "typeName: \"Validator\"",
816
+ "validation: [{name: \"validator\", args: [{kind: 10, literal: \"validDate\"}]}]",
817
+ )
818
+ }
819
+
820
+ func TestTypiaSourcePropertyOverridePreservesDateProperties(t *testing.T) {
821
+ info, reg := testTypeInfo()
822
+ info.aliases["DateString"] = aliasInfo{body: "string & TypiaFormat<'date'> & TsfTypeTag<'string', 'date'>"}
823
+ info.aliases["NamedSchedule"] = aliasInfo{
824
+ body: "Pick<EntitySource, 'id' | 'createdAt'>",
825
+ metadataText: "{kind: 18, typeName: \"NamedSchedule\", types: [{kind: 20, name: \"id\", type: {kind: 13}, optional: false}, {kind: 20, name: \"createdAt\", type: {kind: 16, typeName: \"Date\", classType: () => Date}, optional: false}]}",
826
+ }
827
+ info.aliases["EntityPick"] = aliasInfo{body: "Pick<EntitySource, 'id' | 'createdAt' | 'deletedAt'>"}
828
+ info.classes = append(info.classes, &classInfo{
829
+ name: "EntitySource",
830
+ properties: []propertyInfo{
831
+ {name: "id", typeText: "string"},
832
+ {name: "createdAt", typeText: "Date"},
833
+ {name: "deletedAt", typeText: "Date | null"},
834
+ },
835
+ })
836
+ info.interfaces["BaseResponse"] = []interfaceInfo{{body: "updatedAt: Date | null; birthday: DateString"}}
837
+ info.interfaces["Response"] = []interfaceInfo{{body: "createdAt: Date", extends: []string{"BaseResponse"}}}
838
+ info.interfaces["ScheduleResponse"] = []interfaceInfo{{body: "schedules: NamedSchedule[]"}}
839
+ info.interfaces["CreateResponse"] = []interfaceInfo{{body: "key: string", extends: []string{"EntityPick"}}}
840
+
841
+ assertTypiaPropertyOverride := func(source string, property string, want ...string) {
842
+ t.Helper()
843
+ got, ok := typiaSourcePropertyOverrideExpr(info, reg, source, property, 0)
844
+ if !ok {
845
+ t.Fatalf("expected Date override for %s.%s", source, property)
846
+ }
847
+ assertContainsAll(t, got, want...)
848
+ }
849
+
850
+ assertTypiaPropertyOverride("Response", "createdAt", "typeName: \"Date\"", "classType: () =>")
851
+ assertTypiaPropertyOverride("Response", "updatedAt", "typeName: \"Date\"", "{kind: 5}")
852
+ assertTypiaPropertyOverride("EntityPick", "createdAt", "typeName: \"Date\"", "classType: () =>")
853
+ assertTypiaPropertyOverride("EntityPick", "deletedAt", "typeName: \"Date\"", "{kind: 5}")
854
+ assertTypiaPropertyOverride("Promise<Response[]>", "updatedAt", "typeName: \"Date\"", "{kind: 5}")
855
+ assertTypiaPropertyOverride("CreateResponse", "createdAt", "typeName: \"Date\"", "classType: () =>")
856
+ assertTypiaPropertyOverride("CreateResponse", "deletedAt", "typeName: \"Date\"", "{kind: 5}")
857
+ if !sourceTypeIsDateRootType(info, reg, "Date | null", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
858
+ t.Fatal("Date nullish roots should stay on the internal encoder")
859
+ }
860
+ if sourceTypeIsDateRootType(info, reg, "Response", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
861
+ t.Fatal("DTOs containing Date should not force whole-object internal encoding")
862
+ }
863
+ if got, ok := preferredDateRootTypeExpr(info, reg, "Date | null", nil, 0); !ok {
864
+ t.Fatal("preferred Date roots should render through the internal encoder")
865
+ } else {
866
+ assertContainsAll(t, got, "typeName: \"Date\"", "{kind: 5}")
867
+ }
868
+ assertContainsAll(t,
869
+ typeExprForNodePreferred(info, reg, "Promise<Response[]>", nil, 0, true),
870
+ "kind: 22",
871
+ "kind: 14",
872
+ "typeName: \"Response\"",
873
+ "name: \"updatedAt\", type: {kind: 12, types: [{kind: 16, typeName: \"Date\"",
874
+ )
875
+
876
+ if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "Response", "birthday", 0); !ok {
877
+ t.Fatal("named scalar aliases should preserve property source metadata")
878
+ } else {
879
+ assertContainsAll(t, got, "typeName: \"DateString\"")
880
+ }
881
+ if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "ScheduleResponse", "schedules", 0); !ok {
882
+ t.Fatal("named array aliases should preserve property source metadata")
883
+ } else {
884
+ assertContainsAll(t, got, "kind: 14", "typeName: \"NamedSchedule\"")
885
+ }
886
+ info.aliases["MetadataString"] = aliasInfo{body: "string & DatabaseField<{ type: 'TEXT' }>"}
887
+ info.interfaces["MetadataResponse"] = []interfaceInfo{{body: "note: MetadataString"}}
888
+ if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "MetadataResponse", "note", 0); !ok {
889
+ t.Fatal("database metadata aliases should preserve property source metadata")
890
+ } else {
891
+ assertContainsAll(t, got, "typeName: \"MetadataString\"", "typeName: \"DatabaseField\"", "database: {\"*\": {type: \"TEXT\"}}")
892
+ }
893
+ info.imports["ValidDate"] = importRef{spec: foundationPackageSpec, exportName: "ValidDate"}
894
+ if !sourceTypeContainsDateType(info, reg, "ValidDate", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
895
+ t.Fatal("foundation ValidDate should be preserved as Date property metadata")
896
+ }
897
+ if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "__type", "updatedAt", 0, "Promise<Response[]>"); !ok {
898
+ t.Fatal("alternate root source should preserve Date metadata")
899
+ } else {
900
+ assertContainsAll(t, got, "typeName: \"Date\"", "{kind: 5}")
901
+ }
902
+ if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "__type", "result", 0, "Promise<{ result: 'token' } | { result: 'login'; jwt: string }>"); ok {
903
+ t.Fatalf("object-union root source should not override branch properties: %s", got)
904
+ }
905
+ }
906
+
907
+ func TestPreferredExternalImportedAliasesUseRuntimeMetadata(t *testing.T) {
908
+ info, reg := testTypeInfo()
909
+ info.imports["ExternalStatus"] = importRef{spec: "@scope/shared", exportName: "ExternalStatus"}
910
+ info.imports["FoundationAlias"] = importRef{spec: foundationPackageSpec, exportName: "FoundationAlias"}
911
+ info.interfaces["Response"] = []interfaceInfo{{body: "status: ExternalStatus"}}
912
+ sharedInfo := &fileInfo{
913
+ moduleKey: "shared/status",
914
+ aliases: map[string]aliasInfo{"WorkspaceStatus": {body: "'ready' | 'busy'"}},
915
+ interfaces: map[string][]interfaceInfo{},
916
+ enums: map[string]enumInfo{},
917
+ classes: []*classInfo{},
918
+ functions: map[string][]functionInfo{},
919
+ imports: map[string]importRef{},
920
+ reexports: map[string]importRef{},
921
+ }
922
+ reg.files[sharedInfo.moduleKey] = sharedInfo
923
+ reg.byPath["/workspace/shared/status.ts"] = sharedInfo
924
+ info.imports["WorkspaceStatus"] = importRef{source: "/workspace/shared/status.ts", spec: "@scope/shared", exportName: "WorkspaceStatus"}
925
+ info.imports["HasDefault"] = importRef{spec: foundationPackageSpec, exportName: "HasDefault"}
926
+ info.imports["NullableMySQLCoordinate"] = importRef{spec: foundationPackageSpec, exportName: "NullableMySQLCoordinate"}
927
+ decl := info.interfaces["Response"][0]
928
+
929
+ if !interfaceNeedsPreferredSourceMetadata(info, reg, decl, map[string]bool{}) {
930
+ t.Fatal("interfaces containing external imported aliases should preserve source metadata")
931
+ }
932
+
933
+ statusExpr := typeExprForNodePreferred(info, reg, "ExternalStatus", nil, 0, true)
934
+ assertContainsAll(t, statusExpr, "__tsf_runtime_alias__(\"@scope/shared\"", "\"ExternalStatus\"")
935
+
936
+ nullableStatusExpr := typeExprForNodePreferred(info, reg, "ExternalStatus | null", nil, 0, true)
937
+ assertContainsAll(t, nullableStatusExpr, "__tsf_runtime_alias__(\"@scope/shared\"", "\"ExternalStatus\"", "{kind: 5}")
938
+
939
+ statusArrayExpr := typeExprForNodePreferred(info, reg, "ExternalStatus[]", nil, 0, true)
940
+ assertContainsAll(t, statusArrayExpr, "kind: 14", "__tsf_runtime_alias__(\"@scope/shared\"", "\"ExternalStatus\"")
941
+
942
+ workspaceStatusExpr := typeExpr(info, reg, "WorkspaceStatus[]")
943
+ assertContainsAll(t, workspaceStatusExpr, "kind: 14", "__tsf_runtime_alias__(\"@scope/shared\"", "\"WorkspaceStatus\"")
944
+ assertNotContains(t, workspaceStatusExpr, "literal: \"ready\"")
945
+ if canPreferTypiaType(info, reg, "WorkspaceStatus[] & HasDefault") {
946
+ t.Fatal("non-preferred metadata should not route package aliases through Typia just because a TSF marker is present")
947
+ }
948
+
949
+ info.interfaces["CreateRequest"] = []interfaceInfo{{body: "status?: ExternalStatus | null"}}
950
+ createExpr := typeExprForNodePreferred(info, reg, "CreateRequest", nil, 0, true)
951
+ assertContainsAll(t, createExpr, "typeName: \"CreateRequest\"", "name: \"status\"", "__tsf_runtime_alias__(\"@scope/shared\"", "\"ExternalStatus\"")
952
+
953
+ responseExpr := typeExprForNodePreferred(info, reg, "Response", nil, 0, true)
954
+ assertContainsAll(t, responseExpr, "name: \"status\"", "__tsf_runtime_alias__(\"@scope/shared\"")
955
+
956
+ if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "{ status: ExternalStatus }", "status", 0); !ok {
957
+ t.Fatal("Typia property overrides should preserve external imported aliases")
958
+ } else {
959
+ assertContainsAll(t, got, "__tsf_runtime_alias__(\"@scope/shared\"", "\"ExternalStatus\"")
960
+ }
961
+ if typeContainsExternalImportReference(info, reg, "FoundationAlias", map[string]bool{}) {
962
+ t.Fatal("foundation aliases should not trigger external shared-package preservation")
963
+ }
964
+ if !sourceTypeNeedsInternalPropertyMetadata(info, reg, "NullableMySQLCoordinate", &typeContext{seen: map[string]bool{}}, map[string]bool{}) {
965
+ t.Fatal("foundation aliases should preserve runtime alias metadata when Typia expands the source type")
966
+ }
967
+ if got := typeExprForNodePreferred(info, reg, "NullableMySQLCoordinate", nil, 0, true); !strings.Contains(got, runtimeAliasPlaceholderName) {
968
+ t.Fatalf("preferred foundation aliases should use runtime alias metadata: %s", got)
969
+ }
970
+ info.interfaces["LocationResponse"] = []interfaceInfo{{body: "zipGeo: NullableMySQLCoordinate"}}
971
+ if got, ok := typiaSourcePropertyOverrideExpr(info, reg, "LocationResponse", "zipGeo", 0); !ok {
972
+ t.Fatal("Typia property overrides should preserve foundation alias metadata")
973
+ } else {
974
+ assertContainsAll(t, got, "__tsf_runtime_alias__(\"@zyno-io/ts-server-foundation\"", "\"NullableMySQLCoordinate\"")
975
+ }
976
+ }
977
+
978
+ func TestTypeExprUnescapesStringLiteralTypeValues(t *testing.T) {
979
+ info, reg := testTypeInfo()
980
+
981
+ got := typeExpr(info, reg, `string & Pattern<'^\\d+$'>`)
982
+ assertContainsAll(t, got, `validation: [{name: "pattern", args: [{kind: 10, literal: "^\\d+$"}]}]`)
983
+ assertNotContains(t, got, `literal: "^\\\\d+$"`)
984
+ }
985
+
986
+ func TestPatchAliasMetadataSkipsOversizedAliases(t *testing.T) {
987
+ info, reg := testTypeInfo()
988
+ info.aliases["HugeAlias"] = aliasInfo{metadataText: strings.Repeat("x", 1000001), exported: true}
989
+
990
+ got := aliasMetadataExpression(info, reg)
991
+
992
+ assertNotContains(t, got, "HugeAlias")
993
+ assertNotContains(t, got, "__tsfTypeAliases")
994
+ }
995
+
996
+ func TestAliasMetadataOnlyIncludesExportedDeclarations(t *testing.T) {
997
+ info, reg := testTypeInfo()
998
+ info.aliases["PrivateAlias"] = aliasInfo{body: "string"}
999
+ info.aliases["PublicAlias"] = aliasInfo{body: "number", exported: true}
1000
+ info.interfaces["PrivateInterface"] = []interfaceInfo{{body: "value: string"}}
1001
+ info.interfaces["PublicInterface"] = []interfaceInfo{{body: "value: number", exported: true}}
1002
+
1003
+ got := aliasMetadataExpression(info, reg)
1004
+
1005
+ assertContainsAll(t, got, "PublicAlias", "PublicInterface")
1006
+ assertNotContains(t, got, "PrivateAlias")
1007
+ assertNotContains(t, got, "PrivateInterface")
1008
+ }
1009
+
1010
+ func TestGenericInterfaceAliasMetadataRemainsPureJSON(t *testing.T) {
1011
+ info, reg := testTypeInfo()
1012
+ info.interfaces["ManagerApiError"] = []interfaceInfo{{
1013
+ body: "error: string; code?: Code; result?: T",
1014
+ params: []string{"Code", "T"},
1015
+ exported: true,
1016
+ }}
1017
+
1018
+ got := aliasMetadataExpression(info, reg)
1019
+ assertContainsAll(t, got, `typeParameters: ["Code", "T"]`, `{kind: 2, typeName: "Code"}`, `{kind: 2, typeName: "T"}`)
1020
+ assertNotContains(t, got, "classType")
1021
+ template, err := parseExpressionTemplate(got)
1022
+ if err != nil {
1023
+ t.Fatal(err)
1024
+ }
1025
+ if !compactMetadataIsPureJSON(template.parsed) {
1026
+ t.Fatal("generic interface alias metadata should not require the TSF metadata runtime")
1027
+ }
1028
+ }
1029
+
1030
+ func TestTypeAliasEmissionDefaultsOnAndCanBeDisabled(t *testing.T) {
1031
+ if !shouldEmitTypeAliases("") {
1032
+ t.Fatal("alias metadata should remain enabled when no plugin config is supplied")
1033
+ }
1034
+ if shouldEmitTypeAliases(`[{"name":"tsf-type-metadata","config":{"emitTypeAliases":false}}]`) {
1035
+ t.Fatal("emitTypeAliases=false should disable alias metadata")
1036
+ }
1037
+ if !shouldEmitTypeAliases(`[{"name":"tsf-type-metadata","config":{"emitTypeAliases":true}}]`) {
1038
+ t.Fatal("emitTypeAliases=true should enable alias metadata")
1039
+ }
1040
+ }
1041
+
1042
+ func TestMetadataRuntimeImportDefaultsOnAndCanBeDisabled(t *testing.T) {
1043
+ defaultConfig := readTypeCompilerPluginConfig("")
1044
+ if defaultConfig.EmitMetadataRuntimeImport != nil {
1045
+ t.Fatal("metadata runtime imports should default to enabled")
1046
+ }
1047
+
1048
+ disabled := readTypeCompilerPluginConfig(`[{"name":"tsf-type-metadata","config":{"emitMetadataRuntimeImport":false}}]`)
1049
+ if disabled.EmitMetadataRuntimeImport == nil || *disabled.EmitMetadataRuntimeImport {
1050
+ t.Fatal("emitMetadataRuntimeImport=false should disable metadata runtime imports")
1051
+ }
1052
+
1053
+ enabled := readTypeCompilerPluginConfig(`[{"name":"tsf-type-metadata","config":{"emitMetadataRuntimeImport":true}}]`)
1054
+ if enabled.EmitMetadataRuntimeImport == nil || !*enabled.EmitMetadataRuntimeImport {
1055
+ t.Fatal("emitMetadataRuntimeImport=true should enable metadata runtime imports")
1056
+ }
1057
+ }
1058
+
1059
+ func TestUndecoratedMethodEmissionDefaultsOnAndCanBeDisabled(t *testing.T) {
1060
+ defaultConfig := readTypeCompilerPluginConfig("")
1061
+ if defaultConfig.EmitUndecoratedMethods != nil {
1062
+ t.Fatal("undecorated method metadata should default to enabled")
1063
+ }
1064
+ disabled := readTypeCompilerPluginConfig(`[{"name":"tsf-type-metadata","config":{"emitUndecoratedMethods":false}}]`)
1065
+ if disabled.EmitUndecoratedMethods == nil || *disabled.EmitUndecoratedMethods {
1066
+ t.Fatal("emitUndecoratedMethods=false should disable undecorated method metadata")
1067
+ }
1068
+ }
1069
+
1070
+ func TestMetadataTypeInternerDeduplicatesExpressionsAndAvoidsSourceNames(t *testing.T) {
1071
+ interner := newMetadataTypeInterner("const __tsf_metadata_type_0 = 'application value'")
1072
+ first := interner.reference("{kind: 6}")
1073
+ repeated := interner.reference("{kind: 6}")
1074
+ second := interner.reference("{kind: 7}")
1075
+
1076
+ if first != repeated {
1077
+ t.Fatalf("repeated metadata expression names differ: %q != %q", first, repeated)
1078
+ }
1079
+ if first == second {
1080
+ t.Fatalf("different metadata expressions share name %q", first)
1081
+ }
1082
+ if first != "___tsf_metadata_type(0)" {
1083
+ t.Fatalf("collision-safe metadata name = %q", first)
1084
+ }
1085
+ if len(interner.expressions) != 2 {
1086
+ t.Fatalf("interned expression count = %d, want 2", len(interner.expressions))
1087
+ }
1088
+ }
1089
+
1090
+ func TestTypeExprResolvesReexportedGenericAliases(t *testing.T) {
1091
+ consumer, reg := testTypeInfo()
1092
+ consumer.moduleKey = "test/consumer"
1093
+ consumer.imports["Length"] = importRef{source: "test/index", exportName: "Length", spec: "../src"}
1094
+ index := &fileInfo{
1095
+ moduleKey: "test/index",
1096
+ aliases: map[string]aliasInfo{},
1097
+ imports: map[string]importRef{},
1098
+ reexports: map[string]importRef{
1099
+ "Length": {source: "test/types", exportName: "Length", spec: "./types"},
1100
+ },
1101
+ }
1102
+ types := &fileInfo{
1103
+ moduleKey: "test/types",
1104
+ aliases: map[string]aliasInfo{
1105
+ "Length": {body: "string & MinLength<T> & MaxLength<T> & TypeAnnotation<'tsf:length', T>", params: []string{"T"}},
1106
+ },
1107
+ imports: map[string]importRef{},
1108
+ reexports: map[string]importRef{},
1109
+ }
1110
+ reg.files = map[string]*fileInfo{consumer.moduleKey: consumer, index.moduleKey: index, types.moduleKey: types}
1111
+ reg.byPath = map[string]*fileInfo{consumer.moduleKey: consumer, index.moduleKey: index, types.moduleKey: types}
1112
+
1113
+ got := typeExpr(consumer, reg, "Length<4>")
1114
+ assertContainsAll(t, got,
1115
+ "typeName: \"Length\"",
1116
+ "typeName: \"MinLength\"",
1117
+ "validation: [{name: \"minLength\", args: [{kind: 10, literal: 4}]}]",
1118
+ "typeName: \"MaxLength\"",
1119
+ "validation: [{name: \"maxLength\", args: [{kind: 10, literal: 4}]}]",
1120
+ "annotations: {\"tsf:length\": {kind: 10, literal: 4}}",
1121
+ )
1122
+
1123
+ bounded := typeExpr(consumer, reg, "number & GreaterThan<0> & LessThan<100>")
1124
+ assertContainsAll(t, bounded,
1125
+ "typeName: \"GreaterThan\"",
1126
+ "validation: [{name: \"greaterThan\", args: [{kind: 10, literal: 0}]}]",
1127
+ "typeName: \"LessThan\"",
1128
+ "validation: [{name: \"lessThan\", args: [{kind: 10, literal: 100}]}]",
1129
+ )
1130
+ }
1131
+
1132
+ func assertContainsAll(t *testing.T, got string, wants ...string) {
1133
+ t.Helper()
1134
+ for _, want := range wants {
1135
+ if !strings.Contains(got, want) {
1136
+ t.Fatalf("expression %q does not contain %q", got, want)
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ func assertNotContains(t *testing.T, got string, unwanted string) {
1142
+ t.Helper()
1143
+ if strings.Contains(got, unwanted) {
1144
+ t.Fatalf("expression %q unexpectedly contains %q", got, unwanted)
1145
+ }
1146
+ }
1147
+
1148
+ func TestClassMetadataRendersPropertiesMethodsAndConstructor(t *testing.T) {
1149
+ info, reg := testTypeInfo()
1150
+ class := &classInfo{
1151
+ name: "User",
1152
+ properties: []propertyInfo{
1153
+ {name: "id", typeText: "number & PrimaryKey & AutoIncrement"},
1154
+ {name: "email", typeText: "string & Unique<{ name: 'users_email_unique' }>"},
1155
+ },
1156
+ methods: []methodInfo{
1157
+ {name: "rename", description: "Rename the user.", params: []paramInfo{{name: "email", typeText: "string"}}, returnType: "void"},
1158
+ },
1159
+ ctor: []paramInfo{{name: "email", typeText: "string", hasDefault: true}},
1160
+ hasCtor: true,
1161
+ }
1162
+
1163
+ got := classMetadata(info, reg, class, nil)
1164
+ for _, want := range []string{
1165
+ "name: \"User\"",
1166
+ "primaryKey: true",
1167
+ "autoIncrement: true",
1168
+ "unique: {name: \"users_email_unique\"}",
1169
+ "name: \"rename\", parameters: [{name: \"email\", type: {kind: 6}, optional: false, default: false}], returnType: {kind: 3}",
1170
+ "description: \"Rename the user.\"",
1171
+ "constructorParameters: [{name: \"email\", type: {kind: 6}, optional: false, default: true}]",
1172
+ } {
1173
+ if !strings.Contains(got, want) {
1174
+ t.Fatalf("class metadata %q does not contain %q", got, want)
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ func TestClassMetadataCanOmitUndecoratedMethods(t *testing.T) {
1180
+ info, reg := testTypeInfo()
1181
+ class := &classInfo{
1182
+ name: "Controller",
1183
+ decoratedMethodsOnly: true,
1184
+ methods: []methodInfo{
1185
+ {name: "index", returnType: "string", decorated: true},
1186
+ {name: "helper", returnType: "number"},
1187
+ },
1188
+ }
1189
+
1190
+ got := classMetadata(info, reg, class, nil)
1191
+ assertContainsAll(t, got, "name: \"index\"")
1192
+ assertNotContains(t, got, "name: \"helper\"")
1193
+ }
1194
+
1195
+ func TestCleanJsDocDescriptionUsesTheFirstParagraph(t *testing.T) {
1196
+ got := cleanJsDocDescription(`/**
1197
+ * List users using the documented summary.
1198
+ * Continues on the next line.
1199
+ *
1200
+ * This detail is not part of the summary.
1201
+ * @returns users
1202
+ */`)
1203
+ if got != "List users using the documented summary. Continues on the next line." {
1204
+ t.Fatalf("description = %q", got)
1205
+ }
1206
+ }