Skip to content

Commit

Permalink
feat: support default values such as optional and nullable (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
chimame authored Feb 2, 2024
1 parent 35dbb49 commit ea8b829
Show file tree
Hide file tree
Showing 4 changed files with 201 additions and 75 deletions.
216 changes: 143 additions & 73 deletions coercion.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type {
BaseSchema,
Input,
array,
bigint,
boolean,
Expand Down Expand Up @@ -63,14 +64,12 @@ export function coerceFile(file: unknown) {
return file;
}

type WrapSchema =
| typeof nullish
| typeof optional
| typeof nullable
type WrapWithDefaultSchema = typeof nullish | typeof optional | typeof nullable;
type WrapWithoutDefaultSchema =
| typeof nonNullable
| typeof nonOptional
| typeof nonNullish
| typeof union;
| typeof nonNullish;
type WrapSchema = WrapWithDefaultSchema | WrapWithoutDefaultSchema;

type ValibotSchema =
| ReturnType<typeof object>
Expand All @@ -85,19 +84,31 @@ type ValibotSchema =
| ReturnType<typeof picklist>
| ReturnType<typeof undefined_>;

type AllSchema = ValibotSchema | ReturnType<WrapSchema> | BaseSchema;
type AllSchema =
| ValibotSchema
| ReturnType<WrapSchema>
| ReturnType<typeof union>
| BaseSchema;

type WrapOption<TSchema extends WrapSchema> =
TSchema extends WrapWithDefaultSchema
? {
wrap: WrapWithDefaultSchema;
default: Input<BaseSchema>;
cache?: Map<AllSchema, AllSchema>;
}
: {
wrap?: WrapWithoutDefaultSchema;
cache?: Map<AllSchema, AllSchema>;
};
/**
* Reconstruct the provided schema with additional preprocessing steps
* This coerce empty values to undefined and transform strings to the correct type
*/
export function enableTypeCoercion<Type extends AllSchema>(
type: Type,
options?: {
wrap?: WrapSchema;
cache?: Map<Type, AllSchema>;
},
): AllSchema {
export function enableTypeCoercion<
Type extends AllSchema,
TSchema extends WrapSchema,
>(type: Type, options?: WrapOption<TSchema>): AllSchema {
const cache = options?.cache ?? new Map<Type, AllSchema>();
const result = cache.get(type);

Expand All @@ -123,88 +134,144 @@ export function enableTypeCoercion<Type extends AllSchema>(
type.type === "enum" ||
type.type === "undefined"
) {
// @ts-expect-error
schema = coerce(options?.wrap ? options.wrap(schema) : schema, (output) =>
coerceString(output),
schema = coerce(
options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema,
(output) => coerceString(output),
);
} else if (type.type === "number") {
// @ts-expect-error
schema = coerce(options?.wrap ? options.wrap(schema) : schema, (output) =>
coerceString(output, Number),
schema = coerce(
options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema,
(output) => coerceString(output, Number),
);
} else if (type.type === "boolean") {
// @ts-expect-error
schema = coerce(options?.wrap ? options.wrap(schema) : schema, (output) =>
coerceString(output, (text) => (text === "on" ? true : text)),
schema = coerce(
options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema,
(output) => coerceString(output, (text) => (text === "on" ? true : text)),
);
} else if (type.type === "date") {
// @ts-expect-error
schema = coerce(options?.wrap ? options.wrap(schema) : schema, (output) =>
coerceString(output, (timestamp) => {
const date = new Date(timestamp);

// z.date() does not expose a quick way to set invalid_date error
// This gets around it by returning the original string if it's invalid
// See https://github.com/colinhacks/zod/issues/1526
if (Number.isNaN(date.getTime())) {
return timestamp;
}
schema = coerce(
options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema,
(output) => coerceString(output, (timestamp) => {
const date = new Date(timestamp);

// z.date() does not expose a quick way to set invalid_date error
// This gets around it by returning the original string if it's invalid
// See https://github.com/colinhacks/zod/issues/1526
if (Number.isNaN(date.getTime())) {
return timestamp;
}

return date;
}),
return date;
}),
);
} else if (type.type === "bigint") {
// @ts-expect-error
schema = coerce(options?.wrap ? options.wrap(schema) : schema, (output) =>
coerceString(output, BigInt),
schema = coerce(
options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema,
(output) => coerceString(output, BigInt),
);
} else if (type.type === "array") {
// @ts-expect-error
schema = coerce(options?.wrap ? options.wrap(schema) : schema, (output) => {
// No preprocess needed if the value is already an array
if (Array.isArray(output)) {
return output;
}
schema = coerce(
options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema,
(output) => {
// No preprocess needed if the value is already an array
if (Array.isArray(output)) {
return output;
}

if (
typeof output === "undefined" ||
typeof coerceFile(output) === "undefined"
) {
return [];
}
if (
typeof output === "undefined" ||
typeof coerceFile(output) === "undefined"
) {
return [];
}

// Wrap it in an array otherwise
return [output];
});
// Wrap it in an array otherwise
return [output];
},
);
} else if (type.type === "optional") {
schema = enableTypeCoercion(type.wrapped, { wrap: optional });
// @ts-expect-error
schema = options?.wrap ? options.wrap(schema) : schema;
schema = enableTypeCoercion(type.wrapped, {
wrap: optional,
default: type.getDefault,
});
schema = options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema;
} else if (type.type === "nullish") {
schema = enableTypeCoercion(type.wrapped, { wrap: nullish });
// @ts-expect-error
schema = options?.wrap ? options.wrap(schema) : schema;
schema = enableTypeCoercion(type.wrapped, {
wrap: nullish,
default: type.getDefault,
});
schema = options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema;
} else if (type.type === "nullable") {
schema = enableTypeCoercion(type.wrapped, { wrap: nullable });
// @ts-expect-error
schema = options?.wrap ? options.wrap(schema) : schema;
schema = enableTypeCoercion(type.wrapped, {
wrap: nullable,
default: type.getDefault,
});
schema = options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema;
} else if (type.type === "non_optional") {
schema = enableTypeCoercion(type.wrapped, { wrap: nonOptional });
// @ts-expect-error
schema = options?.wrap ? options.wrap(schema) : schema;
schema = options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema;
} else if (type.type === "non_nullish") {
schema = enableTypeCoercion(type.wrapped, { wrap: nonNullish });
// @ts-expect-error
schema = options?.wrap ? options.wrap(schema) : schema;
schema = options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema;
} else if (type.type === "non_nullable") {
schema = enableTypeCoercion(type.wrapped, { wrap: nonNullable });
// @ts-expect-error
schema = options?.wrap ? options.wrap(schema) : schema;
schema = options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema;
} else if (type.type === "union") {
// @ts-expect-error
schema = union(type.options.map((option) => enableTypeCoercion(option)));
// @ts-expect-error
schema = options?.wrap ? options.wrap(schema) : schema;
schema = options?.wrap
? "default" in options
? options.wrap(schema, options.default)
: options.wrap(schema)
: schema;
} else if (type.type === "object") {
const shape = Object.fromEntries(
// @ts-ignore
Expand All @@ -213,8 +280,11 @@ export function enableTypeCoercion<Type extends AllSchema>(
enableTypeCoercion(def, { cache }),
]),
);
// @ts-expect-error
schema = options?.wrap ? options.wrap(object(shape)) : object(shape);
schema = options?.wrap
? "default" in options
? options.wrap(object(shape), options.default)
: options.wrap(object(shape))
: object(shape);
}

if (type !== schema) {
Expand Down
32 changes: 32 additions & 0 deletions tests/nullable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, test } from "vitest";
import { string, number, object, nullable } from "valibot";
import { parse } from "../parse";
import { createFormData } from "./helpers/FormData";

describe("nullable", () => {
test("should pass also undefined", () => {
const schema = object({ age: nullable(number()) });
const output = parse(createFormData("age", ""), { schema });

expect(output).toMatchObject({ error: {}, value: { age: undefined } });
expect(parse(createFormData("age", "20"), { schema })).toMatchObject({
error: {},
value: { age: 20 },
});
expect(
parse(createFormData("age", "non number"), { schema }),
).toMatchObject({ error: { age: ["Invalid type"] } });
});

test("should use default if required", () => {
const default_ = "default";

const schema1 = object({ name: nullable(string(), default_) });
const output1 = parse(createFormData("name", ""), { schema: schema1 });
expect(output1).toMatchObject({ error: {}, value: { name: "default" } });

const schema2 = object({ name: nullable(string(), () => default_) });
const output2 = parse(createFormData("name", ""), { schema: schema1 });
expect(output2).toMatchObject({ error: {}, value: { name: "default" } });
});
});
14 changes: 13 additions & 1 deletion tests/nullish.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { number, object, nullish } from "valibot";
import { string, number, object, nullish } from "valibot";
import { parse } from "../parse";
import { createFormData } from "./helpers/FormData";

Expand All @@ -17,4 +17,16 @@ describe("nullish", () => {
parse(createFormData("age", "non number"), { schema }),
).toMatchObject({ error: { age: ["Invalid type"] } });
});

test("should use default if required", () => {
const default_ = "default";

const schema1 = object({ name: nullish(string(), default_) });
const output1 = parse(createFormData("name", ""), { schema: schema1 });
expect(output1).toMatchObject({ error: {}, value: { name: "default" } });

const schema2 = object({ name: nullish(string(), () => default_) });
const output2 = parse(createFormData("name", ""), { schema: schema1 });
expect(output2).toMatchObject({ error: {}, value: { name: "default" } });
});
});
14 changes: 13 additions & 1 deletion tests/optional.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { number, object, optional } from "valibot";
import { string, number, object, optional } from "valibot";
import { parse } from "../parse";
import { createFormData } from "./helpers/FormData";

Expand All @@ -17,4 +17,16 @@ describe("optional", () => {
parse(createFormData("age", "non number"), { schema }),
).toMatchObject({ error: { age: ["Invalid type"] } });
});

test("should use default if required", () => {
const default_ = "default";

const schema1 = object({ name: optional(string(), default_) });
const output1 = parse(createFormData("name", ""), { schema: schema1 });
expect(output1).toMatchObject({ error: {}, value: { name: "default" } });

const schema2 = object({ name: optional(string(), () => default_) });
const output2 = parse(createFormData("name", ""), { schema: schema1 });
expect(output2).toMatchObject({ error: {}, value: { name: "default" } });
});
});

0 comments on commit ea8b829

Please sign in to comment.