Repository: fabien0102/ts-to-zod
Pull request #57 (2021-11-20 18:14:21)

Improve nullable
# Why

Use `nullable()` when this is possible, this is solving half of the case from #54 and this is a bit more semantic.


codecov-commenter: # [Codecov](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) Report
> Merging [#57](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) (9f549f6) into [main](https://codecov.io/gh/fabien0102/ts-to-zod/commit/e5e49615d832129cae8a23cbf5fa0b35024e15e2?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) (e5e4961) will **decrease** coverage by `1.30%`.
> The diff coverage is `95.20%`.

[![Impacted file tree graph](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57/graphs/tree.svg?width=650&height=150&src=pr&token=W5M8UIJ59C&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD)](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD)

```diff
@@            Coverage Diff             @@
##             main      #57      +/-   ##
==========================================
- Coverage   98.49%   97.19%   -1.31%     
==========================================
  Files           8       11       +3     
  Lines         332      463     +131     
  Branches      102      144      +42     
==========================================
+ Hits          327      450     +123     
- Misses          5       10       +5     
- Partials        0        3       +3     
```


| [Impacted Files](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) | Coverage Δ | |
|---|---|---|
| [src/core/generateZodSchema.ts](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD#diff-c3JjL2NvcmUvZ2VuZXJhdGVab2RTY2hlbWEudHM=) | `95.45% <91.07%> (-1.75%)` | :arrow_down: |
| [src/utils/resolveDefaultProperties.ts](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD#diff-c3JjL3V0aWxzL3Jlc29sdmVEZWZhdWx0UHJvcGVydGllcy50cw==) | `95.23% <95.23%> (ø)` | |
| [src/utils/resolveModules.ts](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD#diff-c3JjL3V0aWxzL3Jlc29sdmVNb2R1bGVzLnRz) | `97.61% <97.61%> (ø)` | |
| [src/core/generate.ts](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD#diff-c3JjL2NvcmUvZ2VuZXJhdGUudHM=) | `100.00% <100.00%> (ø)` | |
| [src/core/jsDocTags.ts](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD#diff-c3JjL2NvcmUvanNEb2NUYWdzLnRz) | `98.50% <100.00%> (-1.50%)` | :arrow_down: |
| [src/core/validateGeneratedTypes.ts](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD#diff-c3JjL2NvcmUvdmFsaWRhdGVHZW5lcmF0ZWRUeXBlcy50cw==) | `97.43% <100.00%> (+0.06%)` | :arrow_up: |
| [src/utils/isNotNull.ts](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD#diff-c3JjL3V0aWxzL2lzTm90TnVsbC50cw==) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD). Last update [506e54c...9f549f6](https://codecov.io/gh/fabien0102/ts-to-zod/pull/57?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD).


Patch:
diff --git a/src/core/generateZodSchema.ts b/src/core/generateZodSchema.ts
index 7210a32..0a39819 100644
--- a/src/core/generateZodSchema.ts
+++ b/src/core/generateZodSchema.ts
@@ -8,6 +8,7 @@ import {
 } from "./jsDocTags";
 import uniq from "lodash/uniq";
 import { findNode } from "../utils/findNode";
+import { isNotNull } from "../utils/isNotNull";
 
 const { factory: f } = ts;
 
@@ -180,6 +181,7 @@ function buildZodPrimitive({
   z,
   typeNode,
   isOptional,
+  isNullable,
   isPartial,
   isRequired,
   jsDocTags,
@@ -190,6 +192,7 @@ function buildZodPrimitive({
   z: string;
   typeNode: ts.TypeNode;
   isOptional: boolean;
+  isNullable?: boolean;
   isPartial?: boolean;
   isRequired?: boolean;
   jsDocTags: JSDocTags;
@@ -201,7 +204,8 @@ function buildZodPrimitive({
     jsDocTags,
     isOptional,
     Boolean(isPartial),
-    Boolean(isRequired)
+    Boolean(isRequired),
+    Boolean(isNullable)
   );
 
   if (ts.isParenthesizedTypeNode(typeNode)) {
@@ -395,22 +399,43 @@ function buildZodPrimitive({
   }
 
   if (ts.isUnionTypeNode(typeNode)) {
-    const values = typeNode.types.map((i) =>
+    const hasNull = Boolean(
+      typeNode.types.find(
+        (i) =>
+          ts.isLiteralTypeNode(i) &&
+          i.literal.kind === ts.SyntaxKind.NullKeyword
+      )
+    );
+
+    const nodes = typeNode.types.filter(isNotNull);
+
+    // type A = | 'b' is a valid typescript definition
+    // Zod does not allow `z.union(['b']), so we have to return just the value
+    if (nodes.length === 1) {
+      return buildZodPrimitive({
+        z,
+        typeNode: nodes[0],
+        isOptional: false,
+        isNullable: hasNull,
+        jsDocTags,
+        sourceFile,
+        dependencies,
+        getDependencyName,
+      });
+    }
+
+    const values = nodes.map((i) =>
       buildZodPrimitive({
         z,
         typeNode: i,
         isOptional: false,
+        isNullable: false,
         jsDocTags: {},
         sourceFile,
         dependencies,
         getDependencyName,
       })
     );
-    // type A = | 'b' is a valid typescript definintion
-    // Zod does not allow `z.union(['b']), so we have to return just the value
-    if (values.length === 1) {
-      return values[0];
-    }
     return buildZodSchema(
       z,
       "union",
diff --git a/src/core/jsDocTags.ts b/src/core/jsDocTags.ts
index aa223ac..4283f61 100644
--- a/src/core/jsDocTags.ts
+++ b/src/core/jsDocTags.ts
@@ -133,12 +133,14 @@ export type ZodProperty = {
  * @param isOptional
  * @param isPartial
  * @param isRequired
+ * @param isNullable
  */
 export function jsDocTagToZodProperties(
   jsDocTags: JSDocTags,
   isOptional: boolean,
   isPartial: boolean,
-  isRequired: boolean
+  isRequired: boolean,
+  isNullable: boolean
 ) {
   const zodProperties: ZodProperty[] = [];
   if (jsDocTags.minimum !== undefined) {
@@ -181,6 +183,11 @@ export function jsDocTagToZodProperties(
       identifier: "optional",
     });
   }
+  if (isNullable) {
+    zodProperties.push({
+      identifier: "nullable",
+    });
+  }
   if (isPartial) {
     zodProperties.push({
       identifier: "partial",
diff --git a/src/utils/isNotNull.ts b/src/utils/isNotNull.ts
new file mode 100644
index 0000000..eb3a7a8
--- /dev/null
+++ b/src/utils/isNotNull.ts
@@ -0,0 +1,14 @@
+import ts from "typescript";
+
+/**
+ * Helper to filter out any `null` node
+ *
+ * @param node
+ * @returns
+ */
+export function isNotNull(node: ts.TypeNode) {
+  return (
+    !ts.isLiteralTypeNode(node) ||
+    node.literal.kind !== ts.SyntaxKind.NullKeyword
+  );
+}


Test patch:
diff --git a/src/core/generateZodSchema.test.ts b/src/core/generateZodSchema.test.ts
index 414c5cb..3e9cf11 100644
--- a/src/core/generateZodSchema.test.ts
+++ b/src/core/generateZodSchema.test.ts
@@ -357,7 +357,7 @@ describe("generateZodSchema", () => {
           enemies: z.record(enemySchema),
           age: z.number(),
           underKryptonite: z.boolean().optional(),
-          needGlasses: z.union([z.literal(true), z.null()])
+          needGlasses: z.literal(true).nullable()
       });"
     `);
   });
@@ -643,6 +643,29 @@ describe("generateZodSchema", () => {
     `);
   });
 
+  it("should deal with nullable", () => {
+    const source = `export interface A {
+      /** @minimum 0 */
+      a: number | null;
+      /** @minLength 1 */
+      b: string | null;
+      /** @pattern ^c$ */
+      c: string | null;
+    }
+    `;
+
+    expect(generate(source)).toMatchInlineSnapshot(`
+      "export const aSchema = z.object({
+          /** @minimum 0 */
+          a: z.number().min(0).nullable(),
+          /** @minLength 1 */
+          b: z.string().min(1).nullable(),
+          /** @pattern ^c$ */
+          c: z.string().regex(/^c$/).nullable()
+      });"
+    `);
+  });
+
   it("should deal with @default with all types", () => {
     const source = `export interface WithDefaults {
      /**