Last active
April 7, 2025 06:41
-
-
Save shawncarr/a3819d4a7f98e8f185bc192e89b13de0 to your computer and use it in GitHub Desktop.
Zod Decimal
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
}); | |
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Amazing work!
Did you end up opening a PR?
+1
@shawncarr Would you be so kind to specify a license for the code? :)
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.