Skip to content

Commit

Permalink
support for Maybe<T> special case
Browse files Browse the repository at this point in the history
- we can configure if Maybe represents nullable, optional, or both.
- we can define which generic types to use as Maybe (e.g. Maybe, InputMaybe, etc). Can be multiple.
- when ts-to-zod encounters a generic type in the list of "Maybe"s, it skips the schema generation for them.
- when it encounters them as being used, it makes a call to `maybe()` function.
- the `maybe` function is defined depending on the nullable/optional config.

This is useful to work in conjunction with other codegen tools, like graphql codegens.

e.g.

```ts
// config
/**
 * ts-to-zod configuration.
 *
 * @type {import("./src/config").TsToZodConfig}
 */
module.exports = [
  {
    name: "example",
    input: "example/heros.ts",
    output: "example/heros.zod.ts",
    maybeTypeNames: ["Maybe"],
  }
];

// input

export type Maybe<T> = T | null | "whatever really"; // this is actually ignored

export interface Superman {
  age: number;
  aliases: Maybe<string[]>;
}

// output

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

export const supermanSchema = z.object({
    age: z.number(),
    alias: maybe(z.array(z.string()))
});
```

Configuration:

By default, this feature is turned off. When adding the list of type names to be considered 'Maybe's, we turn it on.
Maybe is nullable and optional by default, unless specified otherwise.

We can set this in CLI options...

- `maybeOptional`: boolean, defaults to true
- `maybeNullable`: boolean, defaults to true
- `maybeTypeName`: string, multiple. List of type names.

…as well as in the ts-to-zod config file.
- `maybeOptional`: boolean
- `maybeNullable`: boolean
- `maybeTypeNames`: string[]. list of type names.
  • Loading branch information
eturino committed Jul 26, 2022
1 parent b002277 commit 7b8fd1f
Show file tree
Hide file tree
Showing 12 changed files with 450 additions and 35 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Expand Up @@ -7,3 +7,5 @@ dist
build
lib
.vscode
.idea
.history
3 changes: 3 additions & 0 deletions example/heros.ts
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
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
79 changes: 66 additions & 13 deletions src/cli.ts
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 optional (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
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
@@ -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
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

0 comments on commit 7b8fd1f

Please sign in to comment.