Repository: fabien0102/ts-to-zod
Pull request #275 (2024-10-28 20:53:06)

feat: add support for any JSON object as default
# Why

Following https://github.com/fabien0102/ts-to-zod/issues/111, this adds support for array & object defined using the JSON syntax


Patch:
diff --git a/src/core/generateZodSchema.ts b/src/core/generateZodSchema.ts
index b356d54..4a3a6d1 100644
--- a/src/core/generateZodSchema.ts
+++ b/src/core/generateZodSchema.ts
@@ -311,23 +311,27 @@ function buildZodPrimitive({
   | ts.Identifier
   | ts.PropertyAccessExpression {
   const schema = jsDocTags.schema;
+
+  // Schema override when it doesn't start with a dot, return the schema directly
+  if (schema && !schema.startsWith(".")) {
+    return f.createPropertyAccessExpression(
+      f.createIdentifier(z),
+      f.createIdentifier(schema)
+    );
+  }
+
   delete jsDocTags.schema;
   const generatedSchema = buildZodPrimitiveInternal({ jsDocTags, z, ...rest });
-  // schema not specified? return generated one
+
+  // No schema override? Return generated one
   if (!schema) {
     return generatedSchema;
   }
-  // schema starts with dot? append it
-  if (schema.startsWith(".")) {
-    return f.createPropertyAccessExpression(
-      generatedSchema,
-      f.createIdentifier(schema.slice(1))
-    );
-  }
-  // otherwise use provided schema verbatim
+
+  // Schema override starts with dot? Append it
   return f.createPropertyAccessExpression(
-    f.createIdentifier(z),
-    f.createIdentifier(schema)
+    generatedSchema,
+    f.createIdentifier(schema.slice(1))
   );
 }
 
diff --git a/src/core/jsDocTags.ts b/src/core/jsDocTags.ts
index 029b02b..122aa3f 100644
--- a/src/core/jsDocTags.ts
+++ b/src/core/jsDocTags.ts
@@ -22,6 +22,11 @@ const builtInJSDocFormatsTypes = [
 
 type BuiltInJSDocFormatsType = (typeof builtInJSDocFormatsTypes)[number];
 
+type JsonPrimitive = string | number | boolean | null;
+type JsonArray = JsonValue[];
+type JsonObject = { [key: string]: JsonValue };
+type JsonValue = JsonPrimitive | JsonArray | JsonObject;
+
 /**
  * Type guard to filter supported JSDoc format tag values (built-in).
  *
@@ -58,7 +63,7 @@ export interface JSDocTagsBase {
   description?: string;
   minimum?: TagWithError<number>;
   maximum?: TagWithError<number>;
-  default?: number | string | boolean | null;
+  default?: JsonValue;
   minLength?: TagWithError<number>;
   maxLength?: TagWithError<number>;
   format?: TagWithError<BuiltInJSDocFormatsType | CustomJSDocFormatType>;
@@ -191,28 +196,15 @@ export function getJSDocTags(nodeType: ts.Node, sourceFile: ts.SourceFile) {
             jsDocTags[tagName] = { value, errorMessage };
             break;
           case "default":
-            if (tag.comment === "null") {
-              jsDocTags[tagName] = null;
-            } else if (
-              tag.comment &&
-              !tag.comment.includes('"') &&
-              !Number.isNaN(parseInt(tag.comment))
-            ) {
-              // number
-              jsDocTags[tagName] = parseInt(tag.comment);
-            } else if (tag.comment && ["false", "true"].includes(tag.comment)) {
-              // boolean
-              jsDocTags[tagName] = tag.comment === "true";
-            } else if (
-              tag.comment &&
-              tag.comment.startsWith('"') &&
-              tag.comment.endsWith('"')
-            ) {
-              // string with double quotes
-              jsDocTags[tagName] = tag.comment.slice(1, -1);
-            } else if (tag.comment) {
-              // string without quotes
-              jsDocTags[tagName] = tag.comment;
+            if (tag.comment) {
+              try {
+                // Attempt to parse as JSON
+                const parsedValue = JSON.parse(tag.comment);
+                jsDocTags[tagName] = parsedValue;
+              } catch (e) {
+                // If JSON parsing fails, handle as before
+                jsDocTags[tagName] = tag.comment;
+              }
             }
             break;
           case "strict":
@@ -363,7 +355,11 @@ export function jsDocTagToZodProperties(
             : [f.createNumericLiteral(jsDocTags.default)]
           : jsDocTags.default === null
           ? [f.createNull()]
-          : [f.createStringLiteral(jsDocTags.default)],
+          : Array.isArray(jsDocTags.default)
+          ? [createArrayLiteralExpression(jsDocTags.default)]
+          : typeof jsDocTags.default === "object"
+          ? [createObjectLiteralExpression(jsDocTags.default)]
+          : [f.createStringLiteral(String(jsDocTags.default))],
     });
   }
 
@@ -494,3 +490,61 @@ function withErrorMessage(expression: ts.Expression, errorMessage?: string) {
   }
   return [expression];
 }
+
+// Helper function to create an array literal expression
+function createArrayLiteralExpression(
+  arr: JsonValue[]
+): ts.ArrayLiteralExpression {
+  const elements = arr.map((item) => {
+    if (typeof item === "string") return f.createStringLiteral(item);
+    if (typeof item === "number") return f.createNumericLiteral(item);
+    if (typeof item === "boolean")
+      return item ? f.createTrue() : f.createFalse();
+    if (item === null) return f.createNull();
+    if (Array.isArray(item)) return createArrayLiteralExpression(item);
+    if (typeof item === "object") return createObjectLiteralExpression(item);
+    return f.createStringLiteral(String(item));
+  });
+  return f.createArrayLiteralExpression(elements);
+}
+
+// Helper function to create an object literal expression
+function createObjectLiteralExpression(
+  obj: Record<string, JsonValue>
+): ts.ObjectLiteralExpression {
+  const properties = Object.entries(obj).map(([key, value]) => {
+    const propertyName = f.createStringLiteral(key);
+    if (typeof value === "string")
+      return f.createPropertyAssignment(
+        propertyName,
+        f.createStringLiteral(value)
+      );
+    if (typeof value === "number")
+      return f.createPropertyAssignment(
+        propertyName,
+        f.createNumericLiteral(value)
+      );
+    if (typeof value === "boolean")
+      return f.createPropertyAssignment(
+        propertyName,
+        value ? f.createTrue() : f.createFalse()
+      );
+    if (value === null)
+      return f.createPropertyAssignment(propertyName, f.createNull());
+    if (Array.isArray(value))
+      return f.createPropertyAssignment(
+        propertyName,
+        createArrayLiteralExpression(value)
+      );
+    if (typeof value === "object")
+      return f.createPropertyAssignment(
+        propertyName,
+        createObjectLiteralExpression(value)
+      );
+    return f.createPropertyAssignment(
+      propertyName,
+      f.createStringLiteral(String(value))
+    );
+  });
+  return f.createObjectLiteralExpression(properties);
+}


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index cb7844d..9a461c3 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -30,5 +30,4 @@ jobs:
       - run: chmod +x ./bin/run
       - run: yarn build
       - run: yarn test:ci
-      - run: yarn codecov
       - run: yarn lint
diff --git a/src/core/generateZodSchema.test.ts b/src/core/generateZodSchema.test.ts
index 5ca0a5f..76067a5 100644
--- a/src/core/generateZodSchema.test.ts
+++ b/src/core/generateZodSchema.test.ts
@@ -1582,6 +1582,67 @@ describe("generateZodSchema", () => {
     `);
   });
 
+  it("should deal with @default with array value", () => {
+    const source = `export interface WithArrayDefault {
+      /**
+       * @default ["superman", "batman", "wonder woman"]
+       */
+      justiceLeague: string[];
+    }`;
+    expect(generate(source)).toMatchInlineSnapshot(`
+      "export const withArrayDefaultSchema = z.object({
+          /**
+           * @default ["superman", "batman", "wonder woman"]
+           */
+          justiceLeague: z.array(z.string()).default(["superman", "batman", "wonder woman"])
+      });"
+    `);
+  });
+
+  it("should deal with @default with object value", () => {
+    const source = `export interface WithObjectDefault {
+      /**
+       * @default { "name": "Clark Kent", "age": 30, "isHero": true }
+       */
+      superman: { name: string; age: number; isHero: boolean };
+    }`;
+    expect(generate(source)).toMatchInlineSnapshot(`
+      "export const withObjectDefaultSchema = z.object({
+          /**
+           * @default { "name": "Clark Kent", "age": 30, "isHero": true }
+           */
+          superman: z.object({
+              name: z.string(),
+              age: z.number(),
+              isHero: z.boolean()
+          }).default({ "name": "Clark Kent", "age": 30, "isHero": true })
+      });"
+    `);
+  });
+
+  it("should deal with @default with nested array and object values", () => {
+    const source = `export interface WithNestedDefault {
+      /**
+       * @default { "heroes": ["superman", "batman"], "villains": [{ "name": "Lex Luthor", "age": 40 }] }
+       */
+      dcUniverse: { heroes: string[]; villains: { name: string; age: number }[] };
+    }`;
+    expect(generate(source)).toMatchInlineSnapshot(`
+      "export const withNestedDefaultSchema = z.object({
+          /**
+           * @default { "heroes": ["superman", "batman"], "villains": [{ "name": "Lex Luthor", "age": 40 }] }
+           */
+          dcUniverse: z.object({
+              heroes: z.array(z.string()),
+              villains: z.array(z.object({
+                  name: z.string(),
+                  age: z.number()
+              }))
+          }).default({ "heroes": ["superman", "batman"], "villains": [{ "name": "Lex Luthor", "age": 40 }] })
+      });"
+    `);
+  });
+
   it("should ignore unknown/broken jsdoc format", () => {
     const source = `export interface Hero {
      /**