Skip to content
New issue

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

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

Already on GitHub? # to your account

support for Maybe<T> special case #91

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
module.exports = {
parser: "@typescript-eslint/parser",

ignorePatterns: [".idea/**/*", ".history/**/*"],

parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ dist
build
lib
.vscode
.idea
.history
10 changes: 10 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.DS_Store
node_modules
coverage
.nyc_output
dist
build
lib
.vscode
.idea
.history
3 changes: 3 additions & 0 deletions example/heros.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ export type SupermanEnemy = Superman["enemies"][-1];
export type SupermanName = Superman["name"];
export type SupermanInvinciblePower = Superman["powers"][2];

export type Maybe<T> = T | null | undefined;

export interface Superman {
name: "superman" | "clark kent" | "kal-l";
enemies: Record<string, Enemy>;
age: number;
underKryptonite?: boolean;
powers: ["fly", "laser", "invincible"];
counters?: Maybe<EnemyPower[]>;
}

export interface Villain {
Expand Down
5 changes: 5 additions & 0 deletions example/heros.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
import { z } from "zod";
import { EnemyPower, Villain } from "./heros";

export const maybe = <T extends z.ZodTypeAny>(schema: T) => {
return schema.nullable().optional();
};

export const enemyPowerSchema = z.nativeEnum(EnemyPower);

export const skillsSpeedEnemySchema = z.object({
Expand All @@ -28,6 +32,7 @@ export const supermanSchema = z.object({
z.literal("laser"),
z.literal("invincible"),
]),
counters: maybe(z.array(enemyPowerSchema)).optional(),
});

export const villainSchema: z.ZodSchema<Villain> = z.lazy(() =>
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@
"scripts": {
"build": "tsc -p tsconfig.package.json",
"prepublishOnly": "yarn test:ci && rimraf lib && yarn build",
"format": "eslint **/*.{js,jsx,ts,tsx} --fix && prettier **/*.{js,jsx,ts,tsx,json} --write",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"test": "jest",
"test:ci": "jest --ci --coverage && yarn gen:all && tsc --noEmit",
"type-check": "tsc --noEmit",
"gen:all": "./bin/run --all",
"gen:example": "./bin/run --config example",
"gen:config": "./bin/run --config config",
Expand Down
79 changes: 66 additions & 13 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { join, relative, parse } from "path";
import slash from "slash";
import ts from "typescript";
import { generate, GenerateProps } from "./core/generate";
import { TsToZodConfig, Config } from "./config";
import { TsToZodConfig, Config, MaybeConfig } from "./config";
import {
tsToZodConfigSchema,
getSchemaNameSchema,
Expand Down Expand Up @@ -76,6 +76,19 @@ class TsToZod extends Command {
char: "k",
description: "Keep parameters comments",
}),
maybeOptional: flags.boolean({
description:
"treat Maybe<T> as optional (can be undefined). Can be combined with maybeNullable",
}),
maybeNullable: flags.boolean({
description:
"treat Maybe<T> as nullable (can be null). Can be combined with maybeOptional",
}),
maybeTypeName: flags.string({
multiple: true,
description:
"determines the name of the types to treat as 'Maybe'. Can be multiple.",
}),
init: flags.boolean({
char: "i",
description: "Create a ts-to-zod.config.js file",
Expand Down Expand Up @@ -234,19 +247,11 @@ See more help with --help`,

const sourceText = await readFile(inputPath, "utf-8");

const generateOptions: GenerateProps = {
const generateOptions = this.extractGenerateOptions(
sourceText,
...fileConfig,
};
if (typeof flags.maxRun === "number") {
generateOptions.maxRun = flags.maxRun;
}
if (typeof flags.keepComments === "boolean") {
generateOptions.keepComments = flags.keepComments;
}
if (typeof flags.skipParseJSDoc === "boolean") {
generateOptions.skipParseJSDoc = flags.skipParseJSDoc;
}
fileConfig,
flags
);

const {
errors,
Expand Down Expand Up @@ -329,6 +334,54 @@ See more help with --help`,
return { success: true };
}

private extractGenerateOptions(
sourceText: string,
givenFileConfig: Config | undefined,
flags: OutputFlags<typeof TsToZod.flags>
) {
const { maybeOptional, maybeNullable, maybeTypeNames, ...fileConfig } =
givenFileConfig || {};

const maybeConfig: MaybeConfig = {
optional: maybeOptional ?? true,
nullable: maybeNullable ?? true,
typeNames: new Set(maybeTypeNames ?? []),
};
if (typeof flags.maybeTypeName === "string" && flags.maybeTypeName) {
maybeConfig.typeNames = new Set([flags.maybeTypeName]);
}
if (
flags.maybeTypeName &&
Array.isArray(flags.maybeTypeName) &&
flags.maybeTypeName.length
) {
maybeConfig.typeNames = new Set(flags.maybeTypeName);
}
if (typeof flags.maybeOptional === "boolean") {
maybeConfig.optional = flags.maybeOptional;
}
if (typeof flags.maybeNullable === "boolean") {
maybeConfig.nullable = flags.maybeNullable;
}

const generateOptions: GenerateProps = {
sourceText,
maybeConfig,
...fileConfig,
};

if (typeof flags.maxRun === "number") {
generateOptions.maxRun = flags.maxRun;
}
if (typeof flags.keepComments === "boolean") {
generateOptions.keepComments = flags.keepComments;
}
if (typeof flags.skipParseJSDoc === "boolean") {
generateOptions.skipParseJSDoc = flags.skipParseJSDoc;
}
return generateOptions;
}

/**
* Load user config from `ts-to-zod.config.js`
*/
Expand Down
54 changes: 54 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ export type GetSchemaName = (identifier: string) => string;
export type NameFilter = (name: string) => boolean;
export type JSDocTagFilter = (tags: SimplifiedJSDocTag[]) => boolean;

export type MaybeConfig = {
typeNames: Set<string>;
optional: boolean;
nullable: boolean;
};

export const DefaultMaybeConfig: MaybeConfig = {
typeNames: new Set([]),
optional: true,
nullable: true,
};

export type Config = {
/**
* Path of the input file (types source)
Expand Down Expand Up @@ -66,6 +78,48 @@ export type Config = {
* @default false
*/
skipParseJSDoc?: boolean;

/**
* If present, it will enable the Maybe special case for each of the given type names.
* They can be names of interfaces or types.
*
* e.g.
* - maybeTypeNames: ["Maybe"]
* - maybeOptional: true
* - maybeNullable: true
*
* ```ts
* // input:
* export type X = { a: string; b: Maybe<string> };
*
* // output:
* const maybe = <T extends z.ZodTypeAny>(schema: T) => {
* return schema.optional().nullable();
* };
*
* export const xSchema = zod.object({
* a: zod.string(),
* b: maybe(zod.string())
* })
* ```
*/
maybeTypeNames?: string[];

/**
* determines if the Maybe special case is optional (can be treated as undefined) or not
*
* @see maybeTypeNames
* @default true
*/
maybeOptional?: boolean;

/**
* determines if the Maybe special case is nullable (can be treated as null) or not
*
* @see maybeTypeNames
* @default true
*/
maybeNullable?: boolean;
};

export type Configs = Array<
Expand Down
7 changes: 7 additions & 0 deletions src/config.zod.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Generated by ts-to-zod
import { z } from "zod";

export const maybe = <T extends z.ZodTypeAny>(schema: T) => {
return schema.nullable().optional();
};

export const simplifiedJSDocTagSchema = z.object({
name: z.string(),
value: z.string().optional(),
Expand Down Expand Up @@ -31,6 +35,9 @@ export const configSchema = z.object({
getSchemaName: getSchemaNameSchema.optional(),
keepComments: z.boolean().optional().default(false),
skipParseJSDoc: z.boolean().optional().default(false),
maybeTypeNames: z.array(z.string()).optional(),
maybeOptional: z.boolean().optional().default(true),
maybeNullable: z.boolean().optional().default(true),
});

export const configsSchema = z.array(
Expand Down
89 changes: 89 additions & 0 deletions src/core/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,95 @@ describe("generate", () => {
});
});

describe("with maybe", () => {
const sourceText = `
export type Name = "superman" | "clark kent" | "kal-l";

export type Maybe<T> = "this is actually ignored";

// Note that the Superman is declared after
export type BadassSuperman = Omit<Superman, "underKryptonite">;

export interface Superman {
name: Name;
age: number;
underKryptonite?: boolean;
/**
* @format email
**/
email: string;
alias: Maybe<string>;
}

const fly = () => console.log("I can fly!");
`;

const { getZodSchemasFile, getIntegrationTestFile, errors } = generate({
sourceText,
maybeConfig: {
optional: false,
nullable: true,
typeNames: new Set(["Maybe"]),
},
});

it("should generate the zod schemas", () => {
expect(getZodSchemasFile("./hero")).toMatchInlineSnapshot(`
"// Generated by ts-to-zod
import { z } from \\"zod\\";

export const maybe = <T extends z.ZodTypeAny>(schema: T) => {
return schema.nullable();
};

export const nameSchema = z.union([z.literal(\\"superman\\"), z.literal(\\"clark kent\\"), z.literal(\\"kal-l\\")]);

export const supermanSchema = z.object({
name: nameSchema,
age: z.number(),
underKryptonite: z.boolean().optional(),
email: z.string().email(),
alias: maybe(z.string())
});

export const badassSupermanSchema = supermanSchema.omit({ \\"underKryptonite\\": true });
"
`);
});

it("should generate the integration tests", () => {
expect(getIntegrationTestFile("./hero", "hero.zod"))
.toMatchInlineSnapshot(`
"// Generated by ts-to-zod
import { z } from \\"zod\\";

import * as spec from \\"./hero\\";
import * as generated from \\"hero.zod\\";

// eslint-disable-next-line @typescript-eslint/no-unused-vars
function expectType<T>(_: T) {
/* noop */
}

export type nameSchemaInferredType = z.infer<typeof generated.nameSchema>;

export type supermanSchemaInferredType = z.infer<typeof generated.supermanSchema>;

export type badassSupermanSchemaInferredType = z.infer<typeof generated.badassSupermanSchema>;
expectType<spec.Name>({} as nameSchemaInferredType)
expectType<nameSchemaInferredType>({} as spec.Name)
expectType<spec.Superman>({} as supermanSchemaInferredType)
expectType<supermanSchemaInferredType>({} as spec.Superman)
expectType<spec.BadassSuperman>({} as badassSupermanSchemaInferredType)
expectType<badassSupermanSchemaInferredType>({} as spec.BadassSuperman)
"
`);
});
it("should not have any errors", () => {
expect(errors.length).toBe(0);
});
});

describe("with enums", () => {
const sourceText = `
export enum Superhero {
Expand Down
Loading