Repository: fabien0102/ts-to-zod
Pull request #145 (2023-07-05 04:14:26)

feat: Support for custom `@format` types
### See #144.
**TL;DR**: Added support for more built-in `@format` types, as well as the ability to define custom string validation formats via the config file's `customJSDocFormats` property:

```json
{
  "customJSDocFormats": {
    "phone-number": "^\\d{3}-\\d{3}-\\d{4}$",
    "date": {
      "regex": "^\\d{4}-\\d{2}-\\d{2}$",
      "errorMessage": "Must be in YYYY-MM-DD format."
    }
  }
}
```

A nice side-effect of this implementation is that custom format types are actually able to override the output of built-ins. For example, one can override the "email" format type like so:
```js
// ts-to-zod.config.js
{ 
  // ...
  customJSDocFormats: {
    email: "^[A-Za-z]+@my_domain.com$",
  }
}
```
Then as a result, the generated Zod schema for properties with `@format email` will look like:
```js
{
  /**
   * @format email
   */
  email: z.string().regex(/^[A-Za-z]+@my_domain.com$/)
}
```

### What's left: 
- [X] Built-in support for format types `date-time`, `ip`, `ipv4`, & `ipv6`.
- [X] Support for defining custom format types via the config file.
- [X] Updating the README with documentation for the new features.
- [X] A little cleanup and better code coverage.

fabien0102: Very cool, I can't wait to see this PR merge! Good job 👌 
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/145?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) Report
> Merging [#145](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/145?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) (01d4fc6) into [main](https://app.codecov.io/gh/fabien0102/ts-to-zod/commit/8d3b297e4131ef105bfe155b038e4b4f76a7d5fc?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD) (8d3b297) will **decrease** coverage by `0.40%`.
> The diff coverage is `94.94%`.

:exclamation: Your organization is not using the GitHub App Integration. As a result you may experience degraded service beginning May 15th. Please [install the Github App Integration](https://github.com/apps/codecov) for your organization. [Read more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD).

```diff
@@            Coverage Diff             @@
##             main     #145      +/-   ##
==========================================
- Coverage   97.33%   96.94%   -0.40%     
==========================================
  Files          14       14              
  Lines         676      719      +43     
  Branches      275      296      +21     
==========================================
+ Hits          658      697      +39     
- Misses         18       22       +4     
```


| [Impacted Files](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/145?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://app.codecov.io/gh/fabien0102/ts-to-zod/pull/145?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD#diff-c3JjL2NvcmUvZ2VuZXJhdGVab2RTY2hlbWEudHM=) | `95.16% <66.66%> (-1.04%)` | :arrow_down: |
| [src/core/jsDocTags.ts](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/145?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% <97.72%> (-0.48%)` | :arrow_down: |
| [src/core/generate.ts](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/145?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD#diff-c3JjL2NvcmUvZ2VuZXJhdGUudHM=) | `96.15% <100.00%> (+0.03%)` | :arrow_up: |
| [src/utils/resolveDefaultProperties.ts](https://app.codecov.io/gh/fabien0102/ts-to-zod/pull/145?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Fabien+BERNARD#diff-c3JjL3V0aWxzL3Jlc29sdmVEZWZhdWx0UHJvcGVydGllcy50cw==) | `95.45% <100.00%> (ø)` | |



Patch:
diff --git a/README.md b/README.md
index c70b2bf..56de124 100644
--- a/README.md
+++ b/README.md
@@ -29,20 +29,35 @@ Notes:
 - Only exported types/interface are tested (so you can have some private types/interface and just exports the composed type)
 - Even if this is not recommended, you can skip this validation step with `--skipValidation`. (At your own risk!)
 
-## JSDoc tags Validators
+## JSDoc Tag Validators
 
-This tool supports some JSDoc tags inspired from openapi to generate zod validator.
+This tool supports some JSDoc tags (inspired by OpenAPI) to generate additional Zod schema validators.
 
 List of supported keywords:
 
-| JSDoc keyword                      | JSDoc Example     | Generated Zod validator      |
-| ---------------------------------- | ----------------- | ---------------------------- |
-| `@minimum {number}`                | `@minimum 42`     | `z.number().min(42)`         |
-| `@maximum {number}`                | `@maximum 42`     | `z.number().max(42)`         |
-| `@minLength {number}`              | `@minLength 42`   | `z.string().min(42)`         |
-| `@maxLength {number}`              | `@maxLength 42`   | `z.string().max(42)`         |
-| `@format {"email"\|"uuid"\|"url"}` | `@format email`   | `z.string().email()`         |
-| `@pattern {regex}`                 | `@pattern ^hello` | `z.string().regex(/^hello/)` |
+| JSDoc keyword                                                                                                              | JSDoc Example              | Generated Zod validator              |
+| -------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------------------------------ |
+| `@minimum {number} [err_msg]`                                                                                              | `@minimum 42`              | `z.number().min(42)`                 |
+| `@maximum {number} [err_msg]`                                                                                              | `@maximum 42 Must be < 42` | `z.number().max(42, "Must be < 42")` |
+| `@minLength {number} [err_msg]`                                                                                            | `@minLength 42`            | `z.string().min(42)`                 |
+| `@maxLength {number} [err_msg]`                                                                                            | `@maxLength 42`            | `z.string().max(42)`                 |
+| `@format {FormatType} [err_msg]`                                                                                           | `@format email`            | `z.string().email()`                 |
+| `@pattern {regex}` <br><br> **Note**: Due to parsing ambiguities, `@pattern` does _not_ support generating error messages. | `@pattern ^hello`          | `z.string().regex(/^hello/)`         |
+
+By default, `FormatType` is defined as:
+
+```ts
+type FormatType =
+  | "date-time"
+  | "email"
+  | "ip"
+  | "ipv4"
+  | "ipv6"
+  | "url"
+  | "uuid";
+```
+
+However, see the section on [Custom JSDoc Format Types](#custom-jsdoc-format-types) to learn more about defining other types of formats for string validation.
 
 Those validators can be combined:
 
@@ -139,7 +154,7 @@ Other JSDoc tags are available:
 
 ## Advanced configuration
 
-If you want to customized the schema name or restrict the exported schemas, you can do this by adding a `ts-to-zod.config.js` at the root of your project.
+If you want to customize the schema name or restrict the exported schemas, you can do this by adding a `ts-to-zod.config.js` at the root of your project.
 
 Just run `yarn ts-to-zod --init` and you will have a ready to use configuration file (with a bit of typesafety).
 
@@ -188,7 +203,92 @@ export interface Superman {
 }
 ```
 
-/!\ Please note: if your exported interface/type have a reference to a non-exported interface/type, ts-to-zod will not be able to generate anything (missing dependencies will be reported).
+**Please note**: if your exported interface/type have a reference to a non-exported interface/type, ts-to-zod will not be able to generate anything (missing dependencies will be reported).
+
+### Custom JSDoc Format Types
+
+`ts-to-zod` already supports converting several `@format` types such as `email` and `ip` to built-in Zod string validation functions. However, the types supported out of the box are only a subset of those recognized by the OpenAPI specification, which doesn't fit every use case. Thus, you can use the config file to define additional format types using the `customJSDocFormats` property like so:
+
+```ts
+{
+  "customJSDocFormats": {
+    [formatTypeNoSpaces]:
+      | string
+      | {regex: string, errorMessage: string}
+  }
+}
+```
+
+Here is an example configuration:
+
+```json
+{
+  "customJSDocFormats": {
+    "phone-number": "^\\d{3}-\\d{3}-\\d{4}$",
+    "date": {
+      "regex": "^\\d{4}-\\d{2}-\\d{2}$",
+      "errorMessage": "Must be in YYYY-MM-DD format."
+    }
+  }
+}
+```
+
+As a result, `ts-to-zod` will perform the following transformation:
+
+<table>
+<thead>
+<tr>
+
+<th>TypeScript</th>
+<th>Zod</th>
+
+</tr>
+</thead>
+
+<tbody>
+<tr>
+
+<td>
+
+```ts
+interface Info {
+  /**
+   * @format date
+   */
+  birthdate: string;
+  /**
+   * @format phone-number Must be a valid phone number.
+   */
+  phoneNumber: string;
+}
+```
+
+</td>
+
+<td>
+
+```ts
+const infoSchema = z.object({
+  /**
+   * @format date
+   */
+  birthdate: z
+    .string()
+    .regex(/^\d{4}-\d{2}-\d{2}$/, "Must be in YYYY-MM-DD format."),
+  /**
+   * @format phone-number
+   */
+  phoneNumber: z
+    .string()
+    .regex(/^\d{3}-\d{3}-\d{4}$/, "Must be a valid phone number."),
+});
+```
+
+</td>
+
+</tr>
+</tbody>
+</table>
 
 ## Limitation
 
diff --git a/example/heros.ts b/example/heros.ts
index 5cfb90f..585a56a 100644
--- a/example/heros.ts
+++ b/example/heros.ts
@@ -114,7 +114,7 @@ export interface HeroContact {
   /**
    * The phone number of the hero.
    *
-   * @pattern ^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$
+   * @pattern ^\d{3}-\d{3}-\d{4}$
    */
   phoneNumber: string;
 
@@ -132,4 +132,11 @@ export interface HeroContact {
    * @maximum 500
    */
   age: number;
+
+  /**
+   * The hero's birthday.
+   *
+   * @format date
+   */
+  birthday: string;
 }
diff --git a/example/heros.types.ts b/example/heros.types.ts
index 0b39fb5..44c8489 100644
--- a/example/heros.types.ts
+++ b/example/heros.types.ts
@@ -34,3 +34,7 @@ export type SupermanName = z.infer<typeof generated.supermanNameSchema>;
 export type SupermanInvinciblePower = z.infer<
   typeof generated.supermanInvinciblePowerSchema
 >;
+
+export type EvilPlan = z.infer<typeof generated.evilPlanSchema>;
+
+export type EvilPlanDetails = z.infer<typeof generated.evilPlanDetailsSchema>;
diff --git a/example/heros.zod.ts b/example/heros.zod.ts
index a5ac122..762ad6b 100644
--- a/example/heros.zod.ts
+++ b/example/heros.zod.ts
@@ -78,9 +78,12 @@ export const getSupermanSkillSchema = z
 export const heroContactSchema = z.object({
   email: z.string().email(),
   name: z.string().min(2).max(50),
-  phoneNumber: z.string().regex(/^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$/),
+  phoneNumber: z.string().regex(/^\d{3}-\d{3}-\d{4}$/),
   hasSuperPower: z.boolean().optional().default(true),
   age: z.number().min(0).max(500),
+  birthday: z
+    .string()
+    .regex(/^\d{4}-\d{2}-\d{2}$/, "Must be in YYYY-MM-DD format."),
 });
 
 export const supermanEnemySchema = supermanSchema.shape.enemies.valueSchema;
diff --git a/package.json b/package.json
index 75b571f..3358ced 100644
--- a/package.json
+++ b/package.json
@@ -57,7 +57,7 @@
     "tslib": "^2.3.1",
     "tsutils": "^3.21.0",
     "typescript": "^4.5.2",
-    "zod": "^3.11.6"
+    "zod": "^3.21.0"
   },
   "devDependencies": {
     "@babel/core": "^7.17.5",
diff --git a/src/cli.ts b/src/cli.ts
index a915462..5658d05 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,25 +1,25 @@
 import { Command, flags } from "@oclif/command";
-import { OutputFlags } from "@oclif/parser";
 import { error as oclifError } from "@oclif/errors";
-import { readFile, outputFile, existsSync } from "fs-extra";
-import { join, relative, parse } from "path";
+import { OutputFlags } from "@oclif/parser";
+import { eachSeries } from "async";
+import chokidar from "chokidar";
+import { existsSync, outputFile, readFile } from "fs-extra";
+import inquirer from "inquirer";
+import ora from "ora";
+import { join, parse, relative } from "path";
+import prettier from "prettier";
 import slash from "slash";
 import ts from "typescript";
-import { generate, GenerateProps } from "./core/generate";
-import { TsToZodConfig, Config } from "./config";
+import { Config, TsToZodConfig } from "./config";
 import {
-  tsToZodConfigSchema,
   getSchemaNameSchema,
   nameFilterSchema,
+  tsToZodConfigSchema,
 } from "./config.zod";
+import { GenerateProps, generate } from "./core/generate";
+import { createConfig } from "./createConfig";
 import { getImportPath } from "./utils/getImportPath";
-import ora from "ora";
-import prettier from "prettier";
 import * as worker from "./worker";
-import inquirer from "inquirer";
-import { eachSeries } from "async";
-import { createConfig } from "./createConfig";
-import chokidar from "chokidar";
 
 // Try to load `ts-to-zod.config.js`
 // We are doing this here to be able to infer the `flags` & `usage` in the cli help
diff --git a/src/config.ts b/src/config.ts
index 6c78770..38c47d4 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -18,6 +18,35 @@ export type GetSchemaName = (identifier: string) => string;
 export type NameFilter = (name: string) => boolean;
 export type JSDocTagFilter = (tags: SimplifiedJSDocTag[]) => boolean;
 
+/**
+ * @example
+ *  {
+ *    regex: "^\\d{4}-\\d{2}-\\d{2}$",
+ *    errorMessage: "Must be in YYYY-MM-DD format."
+ *  }
+ */
+export type CustomJSDocFormatTypeAttributes = {
+  regex: string;
+  errorMessage?: string;
+};
+
+export type CustomJSDocFormatType = string;
+
+/**
+ * @example
+ *  {
+ *    "phone-number": "^\\d{3}-\\d{3}-\\d{4}$",
+ *    date: {
+ *      regex: "^\\d{4}-\\d{2}-\\d{2}$",
+ *      errorMessage: "Must be in YYYY-MM-DD format."
+ *    }
+ * }
+ */
+export type CustomJSDocFormatTypes = Record<
+  CustomJSDocFormatType,
+  string | CustomJSDocFormatTypeAttributes
+>;
+
 export type Config = {
   /**
    * Path of the input file (types source)
@@ -66,6 +95,11 @@ export type Config = {
    * Path of z.infer<> types file.
    */
   inferredTypes?: string;
+
+  /**
+   * A record of custom `@format` types with their corresponding regex patterns.
+   */
+  customJSDocFormats?: CustomJSDocFormatTypes;
 };
 
 export type Configs = Array<
diff --git a/src/config.zod.ts b/src/config.zod.ts
index 6910164..54875a4 100644
--- a/src/config.zod.ts
+++ b/src/config.zod.ts
@@ -21,6 +21,15 @@ export const jSDocTagFilterSchema = z
   .args(z.array(simplifiedJSDocTagSchema))
   .returns(z.boolean());
 
+export const customJSDocFormatTypeAttributesSchema = z.object({
+  regex: z.string(),
+  errorMessage: z.string().optional(),
+});
+
+export const customJSDocFormatTypesSchema = z.record(
+  z.union([z.string(), customJSDocFormatTypeAttributesSchema])
+);
+
 export const configSchema = z.object({
   input: z.string(),
   output: z.string(),
@@ -31,6 +40,7 @@ export const configSchema = z.object({
   keepComments: z.boolean().optional().default(false),
   skipParseJSDoc: z.boolean().optional().default(false),
   inferredTypes: z.string().optional(),
+  customJSDocFormats: customJSDocFormatTypesSchema.optional(),
 });
 
 export const configsSchema = z.array(
diff --git a/src/core/generate.ts b/src/core/generate.ts
index 660ae45..99f2655 100644
--- a/src/core/generate.ts
+++ b/src/core/generate.ts
@@ -1,7 +1,7 @@
 import { camel } from "case";
 import { getJsDoc } from "tsutils";
 import ts from "typescript";
-import { JSDocTagFilter, NameFilter } from "../config";
+import { JSDocTagFilter, NameFilter, CustomJSDocFormatTypes } from "../config";
 import { getSimplifiedJsDocTags } from "../utils/getSimplifiedJsDocTags";
 import { resolveModules } from "../utils/resolveModules";
 import {
@@ -52,6 +52,11 @@ export interface GenerateProps {
    * Path of z.infer<> types file.
    */
   inferredTypes?: string;
+
+  /**
+   * Custom JSDoc format types.
+   */
+  customJSDocFormatTypes?: CustomJSDocFormatTypes;
 }
 
 /**
@@ -66,6 +71,7 @@ export function generate({
   getSchemaName = (id) => camel(id) + "Schema",
   keepComments = false,
   skipParseJSDoc = false,
+  customJSDocFormatTypes = {},
 }: GenerateProps) {
   // Create a source file and deal with modules
   const sourceFile = resolveModules(sourceText);
@@ -121,6 +127,7 @@ export function generate({
       varName,
       getDependencyName: getSchemaName,
       skipParseJSDoc,
+      customJSDocFormatTypes,
     });
 
     return { typeName, varName, ...zodSchema };
diff --git a/src/core/generateZodSchema.ts b/src/core/generateZodSchema.ts
index fe3861d..6eddb0d 100644
--- a/src/core/generateZodSchema.ts
+++ b/src/core/generateZodSchema.ts
@@ -1,13 +1,14 @@
 import { camel, lower } from "case";
 import uniq from "lodash/uniq";
 import * as ts from "typescript";
+import { CustomJSDocFormatTypes } from "../config";
 import { findNode } from "../utils/findNode";
 import { isNotNull } from "../utils/isNotNull";
 import {
-  getJSDocTags,
   JSDocTags,
-  jsDocTagToZodProperties,
   ZodProperty,
+  getJSDocTags,
+  jsDocTagToZodProperties,
 } from "./jsDocTags";
 
 const { factory: f } = ts;
@@ -48,6 +49,11 @@ export interface GenerateZodSchemaProps {
    * @default false
    */
   skipParseJSDoc?: boolean;
+
+  /**
+   * Custom JSDoc format types.
+   */
+  customJSDocFormatTypes: CustomJSDocFormatTypes;
 }
 
 /**
@@ -64,6 +70,7 @@ export function generateZodSchemaVariableStatement({
   zodImportValue = "z",
   getDependencyName = (identifierName) => camel(`${identifierName}Schema`),
   skipParseJSDoc = false,
+  customJSDocFormatTypes,
 }: GenerateZodSchemaProps) {
   let schema:
     | ts.CallExpression
@@ -107,6 +114,7 @@ export function generateZodSchemaVariableStatement({
       getDependencyName,
       schemaExtensionClauses,
       skipParseJSDoc,
+      customJSDocFormatTypes,
     });
 
     if (!skipParseJSDoc) {
@@ -135,6 +143,7 @@ export function generateZodSchemaVariableStatement({
       typeNode: node.type,
       isOptional: false,
       jsDocTags,
+      customJSDocFormatTypes,
       sourceFile,
       dependencies,
       getDependencyName,
@@ -174,6 +183,7 @@ function buildZodProperties({
   dependencies,
   getDependencyName,
   skipParseJSDoc,
+  customJSDocFormatTypes,
 }: {
   members: ts.NodeArray<ts.TypeElement> | ts.PropertySignature[];
   zodImportValue: string;
@@ -181,6 +191,7 @@ function buildZodProperties({
   dependencies: string[];
   getDependencyName: (identifierName: string) => string;
   skipParseJSDoc: boolean;
+  customJSDocFormatTypes: CustomJSDocFormatTypes;
 }) {
   const properties = new Map<
     ts.Identifier | ts.StringLiteral | ts.NumericLiteral,
@@ -209,6 +220,7 @@ function buildZodProperties({
         typeNode: member.type,
         isOptional,
         jsDocTags,
+        customJSDocFormatTypes,
         sourceFile,
         dependencies,
         getDependencyName,
@@ -227,6 +239,7 @@ function buildZodPrimitive({
   isPartial,
   isRequired,
   jsDocTags,
+  customJSDocFormatTypes,
   sourceFile,
   dependencies,
   getDependencyName,
@@ -239,6 +252,7 @@ function buildZodPrimitive({
   isPartial?: boolean;
   isRequired?: boolean;
   jsDocTags: JSDocTags;
+  customJSDocFormatTypes: CustomJSDocFormatTypes;
   sourceFile: ts.SourceFile;
   dependencies: string[];
   getDependencyName: (identifierName: string) => string;
@@ -246,6 +260,7 @@ function buildZodPrimitive({
 }): ts.CallExpression | ts.Identifier | ts.PropertyAccessExpression {
   const zodProperties = jsDocTagToZodProperties(
     jsDocTags,
+    customJSDocFormatTypes,
     isOptional,
     Boolean(isPartial),
     Boolean(isRequired),
@@ -258,6 +273,7 @@ function buildZodPrimitive({
       typeNode: typeNode.type,
       isOptional,
       jsDocTags,
+      customJSDocFormatTypes,
       sourceFile,
       dependencies,
       getDependencyName,
@@ -280,6 +296,7 @@ function buildZodPrimitive({
         dependencies,
         getDependencyName,
         skipParseJSDoc,
+        customJSDocFormatTypes,
       });
     }
 
@@ -291,6 +308,7 @@ function buildZodPrimitive({
         isOptional,
         isNullable,
         jsDocTags,
+        customJSDocFormatTypes,
         sourceFile,
         isPartial: true,
         dependencies,
@@ -307,6 +325,7 @@ function buildZodPrimitive({
         isOptional,
         isNullable,
         jsDocTags,
+        customJSDocFormatTypes,
         sourceFile,
         isRequired: true,
         dependencies,
@@ -323,6 +342,7 @@ function buildZodPrimitive({
         isOptional,
         isNullable,
         jsDocTags,
+        customJSDocFormatTypes,
         sourceFile,
         dependencies,
         getDependencyName,
@@ -345,6 +365,7 @@ function buildZodPrimitive({
             dependencies,
             getDependencyName,
             skipParseJSDoc,
+            customJSDocFormatTypes,
           }),
         ],
         zodProperties
@@ -364,6 +385,7 @@ function buildZodPrimitive({
               typeNode: typeNode.typeArguments[1],
               isOptional: false,
               jsDocTags,
+              customJSDocFormatTypes,
               sourceFile,
               isPartial: false,
               dependencies,
@@ -385,6 +407,7 @@ function buildZodPrimitive({
             typeNode: typeNode.typeArguments[0],
             isOptional: false,
             jsDocTags,
+            customJSDocFormatTypes,
             sourceFile,
             isPartial: false,
             dependencies,
@@ -396,6 +419,7 @@ function buildZodPrimitive({
             typeNode: typeNode.typeArguments[1],
             isOptional: false,
             jsDocTags,
+            customJSDocFormatTypes,
             sourceFile,
             isPartial: false,
             dependencies,
@@ -423,6 +447,7 @@ function buildZodPrimitive({
             typeNode: i,
             isOptional: false,
             jsDocTags,
+            customJSDocFormatTypes,
             sourceFile,
             dependencies,
             getDependencyName,
@@ -444,6 +469,7 @@ function buildZodPrimitive({
             typeNode: i,
             isOptional: false,
             jsDocTags,
+            customJSDocFormatTypes,
             sourceFile,
             dependencies,
             getDependencyName,
@@ -504,6 +530,7 @@ function buildZodPrimitive({
             dependencies,
             getDependencyName,
             skipParseJSDoc,
+            customJSDocFormatTypes,
           }),
           f.createIdentifier(lower(identifierName))
         ),
@@ -540,6 +567,7 @@ function buildZodPrimitive({
         isOptional,
         isNullable: hasNull,
         jsDocTags,
+        customJSDocFormatTypes,
         sourceFile,
         dependencies,
         getDependencyName,
@@ -558,6 +586,7 @@ function buildZodPrimitive({
         dependencies,
         getDependencyName,
         skipParseJSDoc,
+        customJSDocFormatTypes,
       })
     );
 
@@ -587,6 +616,7 @@ function buildZodPrimitive({
         dependencies,
         getDependencyName,
         skipParseJSDoc,
+        customJSDocFormatTypes,
       })
     );
     return buildZodSchema(
@@ -674,6 +704,7 @@ function buildZodPrimitive({
           dependencies,
           getDependencyName,
           skipParseJSDoc,
+          customJSDocFormatTypes,
         }),
       ],
       zodProperties
@@ -689,6 +720,7 @@ function buildZodPrimitive({
         dependencies,
         getDependencyName,
         skipParseJSDoc,
+        customJSDocFormatTypes,
       }),
       zodProperties
     );
@@ -705,6 +737,7 @@ function buildZodPrimitive({
       dependencies,
       getDependencyName,
       skipParseJSDoc,
+      customJSDocFormatTypes,
     });
 
     return rest.reduce(
@@ -725,6 +758,7 @@ function buildZodPrimitive({
               dependencies,
               getDependencyName,
               skipParseJSDoc,
+              customJSDocFormatTypes,
             }),
           ]
         ),
@@ -755,6 +789,7 @@ function buildZodPrimitive({
               typeNode:
                 p.type || f.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword),
               jsDocTags,
+              customJSDocFormatTypes,
               sourceFile,
               dependencies,
               getDependencyName,
@@ -770,6 +805,7 @@ function buildZodPrimitive({
               z,
               typeNode: typeNode.type,
               jsDocTags,
+              customJSDocFormatTypes,
               sourceFile,
               dependencies,
               getDependencyName,
@@ -912,6 +948,7 @@ function buildZodObject({
   getDependencyName,
   schemaExtensionClauses,
   skipParseJSDoc,
+  customJSDocFormatTypes,
 }: {
   typeNode: ts.TypeLiteralNode | ts.InterfaceDeclaration;
   z: string;
@@ -920,6 +957,7 @@ function buildZodObject({
   getDependencyName: Required<GenerateZodSchemaProps>["getDependencyName"];
   schemaExtensionClauses?: string[];
   skipParseJSDoc: boolean;
+  customJSDocFormatTypes: CustomJSDocFormatTypes;
 }) {
   const { properties, indexSignature } = typeNode.members.reduce<{
     properties: ts.PropertySignature[];
@@ -954,6 +992,7 @@ function buildZodObject({
           dependencies,
           getDependencyName,
           skipParseJSDoc,
+          customJSDocFormatTypes,
         })
       : new Map();
 
@@ -999,6 +1038,7 @@ function buildZodObject({
         dependencies,
         getDependencyName,
         skipParseJSDoc,
+        customJSDocFormatTypes,
       }),
     ]);
 
diff --git a/src/core/jsDocTags.ts b/src/core/jsDocTags.ts
index 1ab3117..a0d9c71 100644
--- a/src/core/jsDocTags.ts
+++ b/src/core/jsDocTags.ts
@@ -1,18 +1,49 @@
-import ts from "typescript";
 import { getJsDoc } from "tsutils";
+import ts from "typescript";
+import type { ZodString } from "zod";
+import { CustomJSDocFormatType, CustomJSDocFormatTypes } from "../config";
 const { factory: f } = ts;
 
 /**
  * List of formats that can be translated in zod functions.
  */
-const formats = [
-  "email" as const,
-  "uuid" as const,
-  // "uri" as const,
-  "url" as const,
-  // "date" as const,
-  // "date-time" as const,
-];
+const builtInJSDocFormatsTypes = [
+  "date-time",
+  "email",
+  "ip",
+  "ipv4",
+  "ipv6",
+  "url",
+  "uuid",
+  // "uri",
+  // "date",
+] as const;
+
+type BuiltInJSDocFormatsType = typeof builtInJSDocFormatsTypes[number];
+
+/**
+ * Type guard to filter supported JSDoc format tag values (built-in).
+ *
+ * @param formatType
+ */
+function isBuiltInFormatType(
+  formatType = ""
+): formatType is BuiltInJSDocFormatsType {
+  return builtInJSDocFormatsTypes.map(String).includes(formatType);
+}
+
+/**
+ * Type guard to filter supported JSDoc format tag values (custom).
+ *
+ * @param formatType
+ * @param customFormatTypes
+ */
+function isCustomFormatType(
+  formatType = "",
+  customFormatTypes: Array<keyof CustomJSDocFormatTypes>
+): formatType is CustomJSDocFormatType {
+  return customFormatTypes.includes(formatType);
+}
 
 type TagWithError<T> = {
   value: T;
@@ -28,38 +59,32 @@ export interface JSDocTags {
   default?: number | string | boolean;
   minLength?: TagWithError<number>;
   maxLength?: TagWithError<number>;
-  format?: TagWithError<typeof formats[-1]>;
+  format?: TagWithError<BuiltInJSDocFormatsType | CustomJSDocFormatType>;
+  /**
+   * Due to parsing ambiguities, `@pattern`
+   * does not support custom error messages.
+   */
   pattern?: string;
   strict?: boolean;
 }
 
+const jsDocTagKeys: Array<keyof JSDocTags> = [
+  "minimum",
+  "maximum",
+  "default",
+  "minLength",
+  "maxLength",
+  "format",
+  "pattern",
+];
+
 /**
- * Typeguard to filter supported JSDoc tag key.
+ * Type guard to filter supported JSDoc tag key.
  *
  * @param tagName
  */
 function isJSDocTagKey(tagName: string): tagName is keyof JSDocTags {
-  const keys: Array<keyof JSDocTags> = [
-    "minimum",
-    "maximum",
-    "default",
-    "minLength",
-    "maxLength",
-    "format",
-    "pattern",
-  ];
-  return (keys as string[]).includes(tagName);
-}
-
-/**
- * Typeguard to filter supported JSDoc format tag values.
- *
- * @param format
- */
-function isSupportedFormat(
-  format = ""
-): format is Required<JSDocTags>["format"]["value"] {
-  return (formats as string[]).includes(format);
+  return jsDocTagKeys.map(String).includes(tagName);
 }
 
 /**
@@ -87,6 +112,9 @@ function parseJsDocComment(
 /**
  * Return parsed JSTags.
  *
+ * This function depends on `customJSDocFormatTypeContext`. Before
+ * calling it, make sure the context has been supplied the expected value.
+ *
  * @param nodeType
  * @param sourceFile
  * @returns Tags list
@@ -123,9 +151,7 @@ export function getJSDocTags(nodeType: ts.Node, sourceFile: ts.SourceFile) {
             }
             break;
           case "format":
-            if (isSupportedFormat(value)) {
-              jsDocTags[tagName] = { value, errorMessage };
-            }
+            jsDocTags[tagName] = { value, errorMessage };
             break;
           case "default":
             if (
@@ -164,9 +190,10 @@ export type ZodProperty = {
 };
 
 /**
- * Convert a set of jsDocTags to zod properties
+ * Convert a set of JSDoc tags to zod properties.
  *
  * @param jsDocTags
+ * @param customJSDocFormats
  * @param isOptional
  * @param isPartial
  * @param isRequired
@@ -174,6 +201,7 @@ export type ZodProperty = {
  */
 export function jsDocTagToZodProperties(
   jsDocTags: JSDocTags,
+  customJSDocFormats: CustomJSDocFormatTypes,
   isOptional: boolean,
   isPartial: boolean,
   isRequired: boolean,
@@ -216,19 +244,20 @@ export function jsDocTagToZodProperties(
       ),
     });
   }
-  if (jsDocTags.format) {
-    zodProperties.push({
-      identifier: jsDocTags.format.value,
-      expressions: jsDocTags.format.errorMessage
-        ? [f.createStringLiteral(jsDocTags.format.errorMessage)]
-        : undefined,
-    });
+  if (
+    jsDocTags.format &&
+    (isBuiltInFormatType(jsDocTags.format.value) ||
+      isCustomFormatType(
+        jsDocTags.format.value,
+        Object.keys(customJSDocFormats)
+      ))
+  ) {
+    zodProperties.push(
+      formatToZodProperty(jsDocTags.format, customJSDocFormats)
+    );
   }
   if (jsDocTags.pattern) {
-    zodProperties.push({
-      identifier: "regex",
-      expressions: [f.createRegularExpressionLiteral(`/${jsDocTags.pattern}/`)],
-    });
+    zodProperties.push(createZodRegexProperty(jsDocTags.pattern));
   }
   // strict() must be before optional() and nullable()
   if (jsDocTags.strict) {
@@ -271,6 +300,124 @@ export function jsDocTagToZodProperties(
   return zodProperties;
 }
 
+/**
+ * Converts the given JSDoc format to the corresponding Zod
+ * string validation function call represented by a {@link ZodProperty}.
+ *
+ * @param format The format to be converted.
+ * @returns A ZodProperty representing a Zod string validation function call.
+ */
+function formatToZodProperty(
+  format: Required<JSDocTags>["format"],
+  customFormatTypes: CustomJSDocFormatTypes
+): ZodProperty {
+  if (isCustomFormatType(format.value, Object.keys(customFormatTypes))) {
+    const rule = customFormatTypes[format.value];
+    const regex = typeof rule === "string" ? rule : rule.regex;
+    const errorMessage =
+      typeof rule === "string" ? undefined : rule.errorMessage;
+
+    return createZodRegexProperty(regex, format.errorMessage ?? errorMessage);
+  }
+
+  const identifier = builtInFormatTypeToZodPropertyIdentifier(format.value);
+  const expressions = builtInFormatTypeToZodPropertyArguments(
+    format.value,
+    format.errorMessage
+  );
+  return { identifier, expressions };
+}
+
+/**
+ * Maps the given JSDoc format type to its corresponding
+ * Zod string validation function name.
+ *
+ * @param formatType The format type to be converted.
+ * @returns The name of a Zod string validation function.
+ */
+function builtInFormatTypeToZodPropertyIdentifier(
+  formatType: BuiltInJSDocFormatsType
+): keyof ZodString {
+  switch (formatType) {
+    case "date-time":
+      return "datetime";
+    case "ipv4":
+    case "ipv6":
+    case "ip":
+      return "ip";
+    default:
+      return formatType as keyof ZodString;
+  }
+}
+
+/**
+ * Maps the given JSDoc format type and error message to the
+ * expected Zod string validation function arguments.
+ *
+ * @param formatType The format type to be converted.
+ * @param errorMessage The error message to display if validation fails.
+ * @returns A list of expressions which represent function arguments.
+ */
+function builtInFormatTypeToZodPropertyArguments(
+  formatType: BuiltInJSDocFormatsType,
+  errorMessage?: string
+): ts.Expression[] | undefined {
+  switch (formatType) {
+    case "ipv4":
+      return createZodStringIpArgs("v4", errorMessage);
+    case "ipv6":
+      return createZodStringIpArgs("v6", errorMessage);
+    default:
+      return errorMessage ? [f.createStringLiteral(errorMessage)] : undefined;
+  }
+}
+
+/**
+ * Constructs a list of expressions which represent the arguments
+ * for `ip()` string validation function.
+ *
+ * @param version The IP version to use.
+ * @param errorMessage The error message to display if validation fails.
+ * @returns A list of expressions which represent the function arguments.
+ */
+function createZodStringIpArgs(
+  version: "v4" | "v6",
+  errorMessage?: string
+): ts.Expression[] {
+  const propertyAssignments: ts.ObjectLiteralElementLike[] = [
+    f.createPropertyAssignment("version", f.createStringLiteral(version)),
+  ];
+
+  if (errorMessage) {
+    propertyAssignments.push(
+      f.createPropertyAssignment("message", f.createStringLiteral(errorMessage))
+    );
+  }
+
+  return [f.createObjectLiteralExpression(propertyAssignments)];
+}
+
+/**
+ * Constructs a ZodProperty that represents a call to
+ * `.regex()` with the given regular expression.
+ *
+ * @param regex The regular expression to match.
+ * @param errorMessage The error message to display if validation fails.
+ * @returns A ZodProperty representing a `.regex()` call.
+ */
+function createZodRegexProperty(
+  regex: string,
+  errorMessage?: string
+): ZodProperty {
+  return {
+    identifier: "regex",
+    expressions: withErrorMessage(
+      f.createRegularExpressionLiteral(`/${regex}/`),
+      errorMessage
+    ),
+  };
+}
+
 function withErrorMessage(expression: ts.Expression, errorMessage?: string) {
   if (errorMessage) {
     return [expression, f.createStringLiteral(errorMessage)];
diff --git a/ts-to-zod.config.js b/ts-to-zod.config.js
index d9b9759..a7a9c58 100644
--- a/ts-to-zod.config.js
+++ b/ts-to-zod.config.js
@@ -9,6 +9,12 @@ module.exports = [
     input: "example/heros.ts",
     output: "example/heros.zod.ts",
     inferredTypes: "example/heros.types.ts",
+    customJSDocFormats: {
+      date: {
+        regex: "^\\d{4}-\\d{2}-\\d{2}$",
+        errorMessage: "Must be in YYYY-MM-DD format.",
+      },
+    },
   },
   { name: "config", input: "src/config.ts", output: "src/config.zod.ts" },
 ];
diff --git a/yarn.lock b/yarn.lock
index 67f6054..0ecbfb9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5590,7 +5590,7 @@ yocto-queue@^0.1.0:
   resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
   integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
 
-zod@^3.11.6:
-  version "3.11.6"
-  resolved "https://registry.yarnpkg.com/zod/-/zod-3.11.6.tgz#e43a5e0c213ae2e02aefe7cb2b1a6fa3d7f1f483"
-  integrity sha512-daZ80A81I3/9lIydI44motWe6n59kRBfNzTuS2bfzVh1nAXi667TOTWWtatxyG+fwgNUiagSj/CWZwRRbevJIg==
+zod@^3.21.0:
+  version "3.21.4"
+  resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db"
+  integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==


Test patch:
diff --git a/src/core/generateZodSchema.test.ts b/src/core/generateZodSchema.test.ts
index 6962493..e502bb1 100644
--- a/src/core/generateZodSchema.test.ts
+++ b/src/core/generateZodSchema.test.ts
@@ -1,5 +1,6 @@
 import { camel } from "case";
 import ts from "typescript";
+import type { CustomJSDocFormatTypes } from "../config";
 import { findNode } from "../utils/findNode";
 import { generateZodSchemaVariableStatement } from "./generateZodSchema";
 
@@ -699,6 +700,34 @@ describe("generateZodSchema", () => {
        * @maximum 500
        */
       age: number;
+
+      /**
+       * The hero's birthday.
+       *
+       * @format date-time
+       */
+      birthday: string;
+
+      /**
+       * The hero's ipv4 address.
+       *
+       * @format ipv4
+       */
+      ipv4: string;
+
+      /**
+       * The hero's ipv6 address.
+       *
+       * @format ipv6
+       */
+      ipv6: string;
+
+      /**
+       * The hero's ip address.
+       *
+       * @format ip
+       */
+      ip: string;
     }`;
     expect(generate(source)).toMatchInlineSnapshot(`
       "export const heroContactSchema = z.object({
@@ -733,7 +762,31 @@ describe("generateZodSchema", () => {
            * @minimum 0
            * @maximum 500
            */
-          age: z.number().min(0).max(500)
+          age: z.number().min(0).max(500),
+          /**
+           * The hero's birthday.
+           *
+           * @format date-time
+           */
+          birthday: z.string().datetime(),
+          /**
+           * The hero's ipv4 address.
+           *
+           * @format ipv4
+           */
+          ipv4: z.string().ip({ version: \\"v4\\" }),
+          /**
+           * The hero's ipv6 address.
+           *
+           * @format ipv6
+           */
+          ipv6: z.string().ip({ version: \\"v6\\" }),
+          /**
+           * The hero's ip address.
+           *
+           * @format ip
+           */
+          ip: z.string().ip()
       });"
     `);
   });
@@ -760,6 +813,20 @@ describe("generateZodSchema", () => {
        * @format email "Should be an email"
        */
       supermanEmail: string;
+
+      /**
+       * The hero's ipv6 address.
+       *
+       * @format ipv6 Must be an ipv6 address
+       */
+      ipv6: string;
+
+      /**
+       * The hero's ip address.
+       *
+       * @format ip "Must be a ipv4 or an ipv6 address"
+       */
+      ip: string;
     }`;
     expect(generate(source)).toMatchInlineSnapshot(`
       "export const heroContactSchema = z.object({
@@ -780,7 +847,19 @@ describe("generateZodSchema", () => {
            *
            * @format email \\"Should be an email\\"
            */
-          supermanEmail: z.string().email(\\"Should be an email\\")
+          supermanEmail: z.string().email(\\"Should be an email\\"),
+          /**
+           * The hero's ipv6 address.
+           *
+           * @format ipv6 Must be an ipv6 address
+           */
+          ipv6: z.string().ip({ version: \\"v6\\", message: \\"Must be an ipv6 address\\" }),
+          /**
+           * The hero's ip address.
+           *
+           * @format ip \\"Must be a ipv4 or an ipv6 address\\"
+           */
+          ip: z.string().ip(\\"Must be a ipv4 or an ipv6 address\\")
       });"
     `);
   });
@@ -836,6 +915,63 @@ describe("generateZodSchema", () => {
     `);
   });
 
+  it("should generate custom error messages for custom jsdoc format types", () => {
+    const source = `export interface Info {
+      /**
+       * A birthday.
+       *
+       * @format date
+       */
+      birthday: string;
+
+      /**
+       * A phone number.
+       *
+       * @format phone-number
+       */
+      phoneNumber: string;
+
+      /**
+       * A price.
+       *
+       * @format price Must start with "$" and end with cents.
+       */
+      cost: string;
+    }`;
+    expect(
+      generate(source, undefined, undefined, {
+        date: {
+          regex: "^\\d{4}-\\d{2}-\\d{2}$",
+          errorMessage: "Must be in YYYY-MM-DD format.",
+        },
+
+        "phone-number": "^\\d{3}-\\d{3}-\\d{4}$",
+        price: "^\\$\\d+.\\d{2}$",
+      })
+    ).toMatchInlineSnapshot(`
+      "export const infoSchema = z.object({
+          /**
+           * A birthday.
+           *
+           * @format date
+           */
+          birthday: z.string().regex(/^\\\\d{4}-\\\\d{2}-\\\\d{2}$/, \\"Must be in YYYY-MM-DD format.\\"),
+          /**
+           * A phone number.
+           *
+           * @format phone-number
+           */
+          phoneNumber: z.string().regex(/^\\\\d{3}-\\\\d{3}-\\\\d{4}$/),
+          /**
+           * A price.
+           *
+           * @format price Must start with \\"$\\" and end with cents.
+           */
+          cost: z.string().regex(/^\\\\$\\\\d+.\\\\d{2}$/, \\"Must start with \\\\\\"$\\\\\\" and end with cents.\\")
+      });"
+    `);
+  });
+
   it("should generate validator on top-level types", () => {
     const source = `/**
     * @minLength 1
@@ -1196,7 +1332,12 @@ describe("generateZodSchema", () => {
  * @param sourceText Typescript interface or type
  * @returns Generated Zod schema
  */
-function generate(sourceText: string, z?: string, skipParseJSDoc?: boolean) {
+function generate(
+  sourceText: string,
+  z?: string,
+  skipParseJSDoc?: boolean,
+  customJSDocFormatTypes: CustomJSDocFormatTypes = {}
+) {
   const sourceFile = ts.createSourceFile(
     "index.ts",
     sourceText,
@@ -1226,7 +1367,9 @@ function generate(sourceText: string, z?: string, skipParseJSDoc?: boolean) {
     sourceFile,
     varName: zodConstName,
     skipParseJSDoc,
+    customJSDocFormatTypes,
   });
+
   return ts
     .createPrinter({ newLine: ts.NewLineKind.LineFeed })
     .printNode(ts.EmitHint.Unspecified, zodSchema.statement, sourceFile);