Skip to content

Commit

Permalink
feat: Support for custom @format types (#145)
Browse files Browse the repository at this point in the history
* added support for "date-time", "ipv4", "ipv6", & "ip" `@format` types

* added support for specifying custom `@format` types in config file

* Updated README and code cleanup

* feat: Support Record<T, U> (#161)

* chore: update eslint

* feat: support Record<T, U>

* Refactor to avoid side-effects

* Avoid type assertion

---------

Co-authored-by: Fabien BERNARD <fabien0102@gmail.com>
Co-authored-by: Fabien BERNARD <fabien0102@hotmail.com>
  • Loading branch information
3 people committed Oct 20, 2023
1 parent ac24784 commit acd9444
Show file tree
Hide file tree
Showing 14 changed files with 585 additions and 84 deletions.
124 changes: 112 additions & 12 deletions README.md
Expand Up @@ -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:

Expand Down Expand Up @@ -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).

Expand Down Expand Up @@ -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

Expand Down
9 changes: 8 additions & 1 deletion example/heros.ts
Expand Up @@ -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;

Expand All @@ -132,4 +132,11 @@ export interface HeroContact {
* @maximum 500
*/
age: number;

/**
* The hero's birthday.
*
* @format date
*/
birthday: string;
}
4 changes: 4 additions & 0 deletions example/heros.types.ts
Expand Up @@ -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>;
5 changes: 4 additions & 1 deletion example/heros.zod.ts
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -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",
Expand Down
24 changes: 12 additions & 12 deletions 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
Expand Down
34 changes: 34 additions & 0 deletions src/config.ts
Expand Up @@ -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)
Expand Down Expand Up @@ -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<
Expand Down
10 changes: 10 additions & 0 deletions src/config.zod.ts
Expand Up @@ -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(),
Expand All @@ -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(
Expand Down
9 changes: 8 additions & 1 deletion 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 {
Expand Down Expand Up @@ -52,6 +52,11 @@ export interface GenerateProps {
* Path of z.infer<> types file.
*/
inferredTypes?: string;

/**
* Custom JSDoc format types.
*/
customJSDocFormatTypes?: CustomJSDocFormatTypes;
}

/**
Expand All @@ -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);
Expand Down Expand Up @@ -121,6 +127,7 @@ export function generate({
varName,
getDependencyName: getSchemaName,
skipParseJSDoc,
customJSDocFormatTypes,
});

return { typeName, varName, ...zodSchema };
Expand Down

0 comments on commit acd9444

Please sign in to comment.