Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JSDoc @description support #194

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 19 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,33 +147,34 @@ export const heroContactSchema = z.object({

Other JSDoc tags are available:

| JSDoc keyword | JSDoc Example | Description | Generated Zod |
| ------------------ | ------------- | ----------------------------------------- | ------------------------ |
| `@default {value}` | `@default 42` | Sets a default value for the property | `z.number().default(42)` |
| `@strict` | `@strict` | Adds the `strict()` modifier to an object | `z.object().strict()` |
| JSDoc keyword | JSDoc Example | Description | Generated Zod |
| ---------------------- | ------------------------ | ----------------------------------------- | ---------------------------------- |
| `@description {value}` | `@description Full name` | Sets the description of the property | `z.string().describe("Full name")` |
| `@default {value}` | `@default 42` | Sets a default value for the property | `z.number().default(42)` |
| `@strict` | `@strict` | Adds the `strict()` modifier to an object | `z.object().strict()` |

## JSDoc tags for elements of `string` and `number` arrays

Elements of `string` and `number` arrays can be validated using the following JSDoc tags (for details see above).

| JSDoc keyword |
| ---------------------------------------|
| `@elementMinimum {number} [err_msg]` |
| `@elementMaximum {number} [err_msg]` |
| `@elementMinLength {number} [err_msg]` |
| `@elementMaxLength {number} [err_msg]` |
| `@elementFormat {FormatType} [err_msg]`|
| `@elementPattern {regex}` |
| JSDoc keyword |
| --------------------------------------- |
| `@elementDescription {value}` |
| `@elementMinimum {number} [err_msg]` |
| `@elementMaximum {number} [err_msg]` |
| `@elementMinLength {number} [err_msg]` |
| `@elementMaxLength {number} [err_msg]` |
| `@elementFormat {FormatType} [err_msg]` |
| `@elementPattern {regex}` |

Example:

```ts
// source.ts
export interface EnemyContact {

/**
* The names of the enemy.
*
*
* @elementMinLength 5
* @elementMaxLength 10
* @minLength 2
Expand All @@ -184,6 +185,7 @@ export interface EnemyContact {
/**
* The phone numbers of the enemy.
*
* @description Include home and work numbers
* @elementPattern ^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$
*/
phoneNumbers: string[];
Expand All @@ -206,7 +208,9 @@ export const enemyContactSchema = z.object({
*
* @elementPattern ^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$
*/
phoneNumbers: z.array(z.string().regex(/^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$/)),
phoneNumbers: z
.array(z.string().regex(/^([+]?d{1,2}[-s]?|)d{3}[-s]?d{3}[-s]?d{4}$/))
.describe("Include home and work numbers"),
});
```

Expand Down
71 changes: 71 additions & 0 deletions src/core/generateZodSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,77 @@ describe("generateZodSchema", () => {
`);
});

it("should add describe() when @description is used (top-level)", () => {
const source = `/**
* @description Originally Superman could leap, but not fly.
*/
export type Superman = {
name: "superman";
weakness: Kryptonite;
age: number;
enemies: Array<string>;
};`;
expect(generate(source)).toMatchInlineSnapshot(`
"/**
* @description Originally Superman could leap, but not fly.
*/
export const supermanSchema = z.object({
name: z.literal("superman"),
weakness: kryptoniteSchema,
age: z.number(),
enemies: z.array(z.string())
}).describe("Originally Superman could leap, but not fly.");"
`);
});

it("should add describe() when @description is used (property-level)", () => {
const source = `
export type Superman = {
name: "superman";
weakness: Kryptonite;
age: number;
/**
* @description Lex Luthor, Branaic, etc.
*/
enemies: Array<string>;
};`;
expect(generate(source)).toMatchInlineSnapshot(`
"export const supermanSchema = z.object({
name: z.literal("superman"),
weakness: kryptoniteSchema,
age: z.number(),
/**
* @description Lex Luthor, Branaic, etc.
*/
enemies: z.array(z.string()).describe("Lex Luthor, Branaic, etc.")
});"
`);
});

it("should add describe() when @description is used (array elements)", () => {
const source = `
export type Superman = {
name: "superman";
weakness: Kryptonite;
age: number;
/**
* @elementDescription Name of an enemy
*/
enemies: Array<string>;
};`;
expect(generate(source)).toMatchInlineSnapshot(`
"export const supermanSchema = z.object({
name: z.literal("superman"),
weakness: kryptoniteSchema,
age: z.number(),
/**
* @elementDescription Name of an enemy
*/
enemies: z.array(z.string().describe("Name of an enemy"))
});"
`);
});

it("should deal with nullable", () => {
const source = `export interface A {
/** @minimum 0 */
Expand Down
1 change: 1 addition & 0 deletions src/core/generateZodSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,7 @@ function buildZodPrimitive({
typeNode: typeNode.elementType,
isOptional: false,
jsDocTags: {
description: jsDocTags.elementDescription,
minimum: jsDocTags.elementMinimum,
maximum: jsDocTags.elementMaximum,
minLength: jsDocTags.elementMinLength,
Expand Down
24 changes: 23 additions & 1 deletion src/core/jsDocTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ type TagWithError<T> = {
* JSDoc special tags that can be converted in zod flags.
*/
export interface JSDocTagsBase {
description?: string;
minimum?: TagWithError<number>;
maximum?: TagWithError<number>;
default?: number | string | boolean | null;
Expand All @@ -69,21 +70,29 @@ export interface JSDocTagsBase {

export type ElementJSDocTags = Pick<
JSDocTagsBase,
"minimum" | "maximum" | "minLength" | "maxLength" | "pattern" | "format"
| "description"
| "minimum"
| "maximum"
| "minLength"
| "maxLength"
| "pattern"
| "format"
>;

export type JSDocTags = JSDocTagsBase & {
[K in keyof ElementJSDocTags as `element${Capitalize<K>}`]: ElementJSDocTags[K];
};

const jsDocTagKeys: Array<keyof JSDocTags> = [
"description",
"minimum",
"maximum",
"default",
"minLength",
"maxLength",
"format",
"pattern",
"elementDescription",
"elementMinimum",
"elementMaximum",
"elementMinLength",
Expand Down Expand Up @@ -164,6 +173,8 @@ export function getJSDocTags(nodeType: ts.Node, sourceFile: ts.SourceFile) {
jsDocTags[tagName] = { value: parseInt(value), errorMessage };
}
break;
case "description":
case "elementDescription":
case "pattern":
case "elementPattern":
if (tag.comment) {
Expand Down Expand Up @@ -199,6 +210,10 @@ export function getJSDocTags(nodeType: ts.Node, sourceFile: ts.SourceFile) {
jsDocTags[tagName] = tag.comment;
}
break;
case "strict":
break;
default:
tagName satisfies never;
}
});
});
Expand Down Expand Up @@ -306,6 +321,13 @@ export function jsDocTagToZodProperties(
identifier: "required",
});
}
if (jsDocTags.description !== undefined) {
zodProperties.push({
identifier: "describe",
expressions: [f.createStringLiteral(jsDocTags.description)],
});
}

if (jsDocTags.default !== undefined) {
zodProperties.push({
identifier: "default",
Expand Down