Skip to content

Instantly share code, notes, and snippets.

@shawncarr
Last active December 12, 2023 14:48
Show Gist options
  • Save shawncarr/a3819d4a7f98e8f185bc192e89b13de0 to your computer and use it in GitHub Desktop.
Save shawncarr/a3819d4a7f98e8f185bc192e89b13de0 to your computer and use it in GitHub Desktop.
Zod Decimal
import Decimal from 'decimal.js';
import { ZodDecimal } from './zodDecimal.ts';
describe('ZodDecimal', () => {
const schema = ZodDecimal.create();
const gtFive = schema.gt(5);
const gteFive = schema.gte(5);
const minFive = schema.min(5);
const ltFive = schema.lt(5);
const lteFive = schema.lte(5);
const maxFive = schema.max(5);
const positive = schema.positive();
const negative = schema.negative();
const nonpositive = schema.nonpositive();
const nonnegative = schema.nonnegative();
const wholeNumberOf2 = schema.wholeNumber(2);
const precisionOf2 = schema.precision(2);
const finite = schema.finite();
const safe = schema.safe();
const coerce = ZodDecimal.create({ coerce: true });
test('passing validations', () => {
schema.parse(1);
schema.parse(1.5);
schema.parse(0);
schema.parse(-1.5);
schema.parse(-1);
schema.parse(Infinity);
schema.parse(-Infinity);
gtFive.parse(6);
gtFive.parse(Infinity);
gteFive.parse(5);
gteFive.parse(Infinity);
minFive.parse(5);
minFive.parse(Infinity);
ltFive.parse(4);
ltFive.parse(-Infinity);
lteFive.parse(5);
lteFive.parse(-Infinity);
maxFive.parse(5);
maxFive.parse(-Infinity);
positive.parse(1);
positive.parse(Infinity);
negative.parse(-1);
negative.parse(-Infinity);
nonpositive.parse(0);
nonpositive.parse(-1);
nonpositive.parse(-Infinity);
nonnegative.parse(0);
nonnegative.parse(1);
nonnegative.parse(Infinity);
wholeNumberOf2.parse(99);
precisionOf2.parse(123.25);
finite.parse(123);
safe.parse(Number.MIN_SAFE_INTEGER);
safe.parse(Number.MAX_SAFE_INTEGER);
schema.parse(new Decimal(123.4567));
wholeNumberOf2.parse(new Decimal(98.7654));
precisionOf2.parse(new Decimal(17.76));
coerce.parse('123.4567');
});
test('failing validations', () => {
expect(() => ltFive.parse(5)).toThrow();
expect(() => lteFive.parse(6)).toThrow();
expect(() => maxFive.parse(6)).toThrow();
expect(() => gtFive.parse(5)).toThrow();
expect(() => gteFive.parse(4)).toThrow();
expect(() => minFive.parse(4)).toThrow();
expect(() => positive.parse(0)).toThrow();
expect(() => positive.parse(-1)).toThrow();
expect(() => negative.parse(0)).toThrow();
expect(() => negative.parse(1)).toThrow();
expect(() => nonpositive.parse(1)).toThrow();
expect(() => nonnegative.parse(-1)).toThrow();
expect(() => wholeNumberOf2.parse(999)).toThrow();
expect(() => precisionOf2.parse(123.123)).toThrow();
expect(() => finite.parse(Infinity)).toThrow();
expect(() => finite.parse(-Infinity)).toThrow();
expect(() => safe.parse(Number.MIN_SAFE_INTEGER - 1)).toThrow();
expect(() => safe.parse(Number.MAX_SAFE_INTEGER + 1)).toThrow();
});
test('parse NaN', () => {
expect(() => schema.parse(NaN)).toThrow();
});
test('min max getters', () => {
expect(schema.minValue).toBeNull();
expect(ltFive.minValue).toBeNull();
expect(lteFive.minValue).toBeNull();
expect(maxFive.minValue).toBeNull();
expect(negative.minValue).toBeNull();
expect(nonpositive.minValue).toBeNull();
expect(finite.minValue).toBeNull();
expect(gtFive.minValue).toEqual(5);
expect(gteFive.minValue).toEqual(5);
expect(minFive.minValue).toEqual(5);
expect(minFive.min(10).minValue).toEqual(10);
expect(positive.minValue).toEqual(0);
expect(nonnegative.minValue).toEqual(0);
expect(safe.minValue).toEqual(Number.MIN_SAFE_INTEGER);
expect(schema.maxValue).toBeNull();
expect(gtFive.maxValue).toBeNull();
expect(gteFive.maxValue).toBeNull();
expect(minFive.maxValue).toBeNull();
expect(positive.maxValue).toBeNull();
expect(nonnegative.maxValue).toBeNull();
expect(finite.minValue).toBeNull();
expect(ltFive.maxValue).toEqual(5);
expect(lteFive.maxValue).toEqual(5);
expect(maxFive.maxValue).toEqual(5);
expect(maxFive.max(1).maxValue).toEqual(1);
expect(negative.maxValue).toEqual(0);
expect(nonpositive.maxValue).toEqual(0);
expect(safe.maxValue).toEqual(Number.MAX_SAFE_INTEGER);
});
test('finite getter', () => {
expect(schema.isFinite).toEqual(false);
expect(gtFive.isFinite).toEqual(false);
expect(gteFive.isFinite).toEqual(false);
expect(minFive.isFinite).toEqual(false);
expect(positive.isFinite).toEqual(false);
expect(nonnegative.isFinite).toEqual(false);
expect(ltFive.isFinite).toEqual(false);
expect(lteFive.isFinite).toEqual(false);
expect(maxFive.isFinite).toEqual(false);
expect(negative.isFinite).toEqual(false);
expect(nonpositive.isFinite).toEqual(false);
expect(finite.isFinite).toEqual(true);
expect(schema.min(5).max(10).isFinite).toEqual(true);
expect(safe.isFinite).toEqual(true);
});
});
import {
INVALID,
ParseContext,
ParseInput,
ParseReturnType,
ParseStatus,
RawCreateParams,
ZodIssueCode,
ZodParsedType,
ZodType,
ZodTypeDef,
addIssueToContext,
} from 'zod';
export type ZodDecimalCheck =
| { kind: 'precision'; value: number; message?: string }
| { kind: 'wholeNumber'; value: number; message?: string }
| { kind: 'min'; value: number; inclusive: boolean; message?: string }
| { kind: 'max'; value: number; inclusive: boolean; message?: string }
| { kind: 'finite'; message?: string };
const zodDecimalKind = 'ZodDecimal';
export interface ZodDecimalDef extends ZodTypeDef {
checks: ZodDecimalCheck[];
typeName: typeof zodDecimalKind;
coerce: boolean;
}
const precisionRegex = /(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class ZodDecimal extends ZodType<number, ZodDecimalDef, any> {
// eslint-disable-next-line @typescript-eslint/naming-convention
_parse(input: ParseInput): ParseReturnType<number> {
// detect decimal js object
if (input.data !== null && typeof input.data === 'object' && 'toNumber' in input.data) {
input.data = input.data.toNumber();
}
if (this._def.coerce) {
input.data = Number(input.data);
}
const parsedType = this._getType(input);
if (parsedType !== ZodParsedType.number) {
const ctx = this._getOrReturnCtx(input);
addIssueToContext(ctx, {
code: ZodIssueCode.invalid_type,
expected: ZodParsedType.number,
received: ctx.parsedType,
});
return INVALID;
}
let ctx: undefined | ParseContext = undefined;
const status = new ParseStatus();
for (const check of this._def.checks) {
if (check.kind === 'precision') {
const parts = input.data.toString().match(precisionRegex);
const decimals = Math.max(
(parts[1] ? parts[1].length : 0) - (parts[2] ? parseInt(parts[2], 10) : 0),
0
);
if (decimals > check.value) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
code: ZodIssueCode.custom,
message: check.message,
params: {
precision: check.value,
},
});
status.dirty();
}
} else if (check.kind === 'wholeNumber') {
const wholeNumber = input.data.toString().split('.')[0];
const tooLong = wholeNumber.length > check.value;
if (tooLong) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
code: ZodIssueCode.custom,
message: check.message,
params: {
wholeNumber: check.value,
},
});
status.dirty();
}
} else if (check.kind === 'min') {
const tooSmall = check.inclusive ? input.data < check.value : input.data <= check.value;
if (tooSmall) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
code: ZodIssueCode.too_small,
minimum: check.value,
type: 'number',
inclusive: check.inclusive,
exact: false,
message: check.message,
});
status.dirty();
}
} else if (check.kind === 'max') {
const tooBig = check.inclusive ? input.data > check.value : input.data >= check.value;
if (tooBig) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
code: ZodIssueCode.too_big,
maximum: check.value,
type: 'number',
inclusive: check.inclusive,
exact: false,
message: check.message,
});
status.dirty();
}
} else if (check.kind === 'finite') {
if (!Number.isFinite(input.data)) {
ctx = this._getOrReturnCtx(input, ctx);
addIssueToContext(ctx, {
code: ZodIssueCode.not_finite,
message: check.message,
});
status.dirty();
}
}
}
return { status: status.value, value: input.data };
}
static create = (params?: RawCreateParams & { coerce?: true }): ZodDecimal => {
return new ZodDecimal({
checks: [],
typeName: zodDecimalKind,
coerce: params?.coerce ?? false,
});
};
protected setLimit(
kind: 'min' | 'max',
value: number,
inclusive: boolean,
message?: string
): ZodDecimal {
return new ZodDecimal({
...this._def,
checks: [
...this._def.checks,
{
kind,
value,
inclusive,
message,
},
],
});
}
_addCheck(check: ZodDecimalCheck): ZodDecimal {
return new ZodDecimal({
...this._def,
checks: [...this._def.checks, check],
});
}
lte(value: number, message?: string): ZodDecimal {
return this.setLimit('max', value, true, message);
}
lt(value: number, message?: string): ZodDecimal {
return this.setLimit('max', value, false, message);
}
max = this.lte;
gt(value: number, message?: string): ZodDecimal {
return this.setLimit('min', value, false, message);
}
gte(value: number, message?: string): ZodDecimal {
return this.setLimit('min', value, true, message);
}
min = this.gte;
precision(value: number, message?: string): ZodDecimal {
return this._addCheck({
kind: 'precision',
value,
message,
});
}
wholeNumber(value: number, message?: string): ZodDecimal {
return this._addCheck({
kind: 'wholeNumber',
value,
message,
});
}
get minValue() {
let min: number | null = null;
for (const ch of this._def.checks) {
if (ch.kind === 'min') {
if (min === null || ch.value > min) min = ch.value;
}
}
return min;
}
get maxValue() {
let max: number | null = null;
for (const ch of this._def.checks) {
if (ch.kind === 'max') {
if (max === null || ch.value < max) max = ch.value;
}
}
return max;
}
positive(message?: string) {
return this._addCheck({
kind: 'min',
value: 0,
inclusive: false,
message,
});
}
negative(message?: string) {
return this._addCheck({
kind: 'max',
value: 0,
inclusive: false,
message,
});
}
nonpositive(message?: string) {
return this._addCheck({
kind: 'max',
value: 0,
inclusive: true,
message,
});
}
nonnegative(message?: string) {
return this._addCheck({
kind: 'min',
value: 0,
inclusive: true,
message,
});
}
finite(message?: string) {
return this._addCheck({
kind: 'finite',
message,
});
}
safe(message?: string) {
return this._addCheck({
kind: 'min',
inclusive: true,
value: Number.MIN_SAFE_INTEGER,
message,
})._addCheck({
kind: 'max',
inclusive: true,
value: Number.MAX_SAFE_INTEGER,
message,
});
}
get isFinite() {
let max: number | null = null,
min: number | null = null;
for (const ch of this._def.checks) {
if (ch.kind === 'finite') {
return true;
} else if (ch.kind === 'min') {
if (min === null || ch.value > min) min = ch.value;
} else if (ch.kind === 'max') {
if (max === null || ch.value < max) max = ch.value;
}
}
return Number.isFinite(min) && Number.isFinite(max);
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention
export const zodDecimal = ZodDecimal.create;
@shawncarr
Copy link
Author

Needed to have a Decimal schema for use with Prisma so I created this. Attempted to follow how the Zod Team builds their schemas. I might open a PR with this someday but at the moment still proving it out beforehand.

const schema = ZodDecimal.create();

/// should parse
schema.wholeNumber(3).precision(2).parse(123.25);

/// should fail precision
schema.wholeNumber(3).precision(2).parse(123.2514);

/// should fail whole number
schema.wholeNumber(1).precision(2).parse(74.99);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment