Skip to content

Commit 6a4d1c8

Browse files
committed
Add @thesis-co/cent-zod package
1 parent eef4b00 commit 6a4d1c8

18 files changed

Lines changed: 1587 additions & 0 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,10 @@ console.log(btcRange.toString({ preferredUnit: "sat" })) // "100,000 sats - 1,00
613613

614614
## Other features
615615

616+
### Zod Integration
617+
618+
For input validation and parsing, see [`@thesis-co/cent-zod`](./packages/cent-zod) which provides Zod schemas for all `cent` types.
619+
616620
### Currency support
617621

618622
`cent` includes comprehensive currency metadata for accurate formatting:

packages/cent-zod/README.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# @thesis-co/cent-zod
2+
3+
Zod schemas for parsing and validating `@thesis-co/cent` types.
4+
5+
```bash
6+
pnpm add @thesis-co/cent-zod
7+
```
8+
9+
## Schemas
10+
11+
### zMoney
12+
13+
```ts
14+
import { zMoney, zMoneyString } from "@thesis-co/cent-zod"
15+
16+
zMoneyString.parse("$100.50") // MoneyClass
17+
18+
// With constraints
19+
zMoney({
20+
currency: "USD",
21+
min: "$0.50",
22+
max: "$10000",
23+
positive: true,
24+
})
25+
```
26+
27+
### zPrice
28+
29+
```ts
30+
import { zPrice } from "@thesis-co/cent-zod"
31+
32+
zPrice().parse({ numerator: "$50,000", denominator: "1 BTC" })
33+
zPrice().parse(["$50,000", "1 BTC"])
34+
35+
// Currency constraints
36+
zPrice("USD", "BTC")
37+
```
38+
39+
### zExchangeRate
40+
41+
```ts
42+
import { zExchangeRate } from "@thesis-co/cent-zod"
43+
44+
zExchangeRate("USD", "EUR").parse({ base: "USD", quote: "EUR", rate: "0.92" })
45+
46+
// With staleness check
47+
zExchangeRate({ base: "BTC", quote: "USD", maxAge: 60000 })
48+
```
49+
50+
### zPriceRange
51+
52+
```ts
53+
import { zPriceRange } from "@thesis-co/cent-zod"
54+
55+
zPriceRange().parse("$50 - $100")
56+
zPriceRange().parse({ min: "$50", max: "$100" })
57+
58+
// With constraints
59+
zPriceRange({
60+
currency: "USD",
61+
bounds: { min: "$0", max: "$10000" },
62+
minSpan: "$10",
63+
})
64+
```
65+
66+
### zCurrency
67+
68+
```ts
69+
import { zCurrency } from "@thesis-co/cent-zod"
70+
71+
zCurrency().parse("USD") // Currency object
72+
zCurrency({ allowed: ["USD", "EUR", "GBP"] })
73+
zCurrency({ type: "crypto" })
74+
```
75+
76+
## Type Inference
77+
78+
```ts
79+
import { z } from "zod"
80+
import { zMoney } from "@thesis-co/cent-zod"
81+
82+
const schema = zMoney("USD")
83+
type USDMoney = z.infer<typeof schema> // MoneyClass
84+
```

packages/cent-zod/jest.config.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
testMatch: ['**/test/**/*.test.ts'],
6+
transform: {
7+
'^.+\\.tsx?$': [
8+
'ts-jest',
9+
{
10+
tsconfig: './tsconfig.json',
11+
},
12+
],
13+
},
14+
}

packages/cent-zod/package.json

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "@thesis-co/cent-zod",
3+
"version": "0.0.1",
4+
"description": "Zod schemas for validating and parsing @thesis-co/cent types",
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"files": [
8+
"dist"
9+
],
10+
"repository": {
11+
"type": "git",
12+
"url": "https://github.com/thesis/cent.git",
13+
"directory": "packages/cent-zod"
14+
},
15+
"keywords": [
16+
"zod",
17+
"validation",
18+
"finance",
19+
"currency",
20+
"money",
21+
"schema"
22+
],
23+
"author": "Matt Luongo (@mhluongo)",
24+
"publishConfig": {
25+
"access": "public"
26+
},
27+
"scripts": {
28+
"lint": "pnpx @biomejs/biome check",
29+
"lint:fix": "pnpx @biomejs/biome check --write",
30+
"build": "tsc",
31+
"test": "jest",
32+
"prepublishOnly": "pnpm run build && pnpm run test && pnpm run lint"
33+
},
34+
"devDependencies": {
35+
"@thesis-co/cent": "workspace:*",
36+
"@types/jest": "^29.5.12",
37+
"@types/node": "^20.11.24",
38+
"jest": "^29.7.0",
39+
"ts-jest": "^29.1.2",
40+
"zod": "^3.25.67"
41+
},
42+
"peerDependencies": {
43+
"@thesis-co/cent": ">=0.0.5",
44+
"zod": ">=3.20.0"
45+
}
46+
}

packages/cent-zod/src/index.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Common schemas and utilities
2+
export {
3+
zBigIntString,
4+
zDecimalString,
5+
zFixedPointJSON,
6+
zNonNegativeBigIntString,
7+
zRationalNumberJSON,
8+
} from "./schemas/common"
9+
export type { ZCurrencyOptions } from "./schemas/currency"
10+
// Currency schemas
11+
export {
12+
getValidCurrencyCodes,
13+
zCurrency,
14+
zCurrencyCode,
15+
zCurrencyObject,
16+
} from "./schemas/currency"
17+
export type { ZExchangeRateOptions } from "./schemas/exchange-rate"
18+
// Exchange rate schemas
19+
export {
20+
zExchangeRate,
21+
zExchangeRateCompact,
22+
zExchangeRateJSON,
23+
zExchangeRateSource,
24+
} from "./schemas/exchange-rate"
25+
export type { ZMoneyOptions } from "./schemas/money"
26+
// Money schemas
27+
export { zMoney, zMoneyJSON, zMoneyString } from "./schemas/money"
28+
export type { ZPriceOptions } from "./schemas/price"
29+
// Price schemas
30+
export { zPrice, zPriceFromObject, zPriceFromTuple } from "./schemas/price"
31+
export type { ZPriceRangeOptions } from "./schemas/price-range"
32+
// Price range schemas
33+
export {
34+
zPriceRange,
35+
zPriceRangeJSON,
36+
zPriceRangeObject,
37+
zPriceRangeString,
38+
} from "./schemas/price-range"
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { z } from "zod"
2+
3+
/**
4+
* Schema for bigint values serialized as strings
5+
*/
6+
export const zBigIntString = z
7+
.string()
8+
.regex(/^-?\d+$/, "Must be a valid integer string")
9+
.transform((val) => BigInt(val))
10+
11+
/**
12+
* Schema for non-negative bigint values
13+
*/
14+
export const zNonNegativeBigIntString = z
15+
.string()
16+
.regex(/^\d+$/, "Must be a valid non-negative integer string")
17+
.transform((val) => BigInt(val))
18+
19+
/**
20+
* Schema for FixedPoint JSON representation
21+
* Transforms to { amount: bigint, decimals: bigint }
22+
*/
23+
export const zFixedPointJSON = z.object({
24+
amount: zBigIntString,
25+
decimals: zNonNegativeBigIntString,
26+
})
27+
28+
/**
29+
* Schema for RationalNumber JSON representation
30+
* Validates { p: string, q: string } (no transform - Money.fromJSON handles conversion)
31+
*/
32+
export const zRationalNumberJSON = z.object({
33+
p: z.string().regex(/^-?\d+$/, "Must be a valid integer string"),
34+
q: z.string().regex(/^-?\d+$/, "Must be a valid integer string"),
35+
})
36+
37+
/**
38+
* Schema for decimal string input (e.g., "123.45")
39+
*/
40+
export const zDecimalString = z
41+
.string()
42+
.regex(/^-?\d+(\.\d+)?$/, "Must be a valid decimal string")
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { type Currency, currencies, getCurrencyFromCode } from "@thesis-co/cent"
2+
import { z } from "zod"
3+
import { zNonNegativeBigIntString } from "./common"
4+
5+
/**
6+
* Schema that validates a currency code string and transforms to Currency object
7+
*/
8+
export const zCurrencyCode = z.string().transform((code, ctx) => {
9+
try {
10+
return getCurrencyFromCode(code)
11+
} catch {
12+
ctx.addIssue({
13+
code: z.ZodIssueCode.custom,
14+
message: `Unknown currency code: ${code}`,
15+
})
16+
return z.NEVER
17+
}
18+
})
19+
20+
/**
21+
* Schema for full Currency object representation (validation only, no transform)
22+
*/
23+
export const zCurrencyObject = z.object({
24+
name: z.string(),
25+
code: z.string(),
26+
decimals: z.union([z.bigint(), zNonNegativeBigIntString]),
27+
symbol: z.string(),
28+
fractionalUnit: z
29+
.union([
30+
z.string(),
31+
z.array(z.string()),
32+
z.record(z.string(), z.union([z.string(), z.array(z.string())])),
33+
])
34+
.optional(),
35+
iso4217Support: z.boolean().optional(),
36+
})
37+
38+
/**
39+
* Options for zCurrency schema
40+
*/
41+
export interface ZCurrencyOptions {
42+
/** Only allow these currency codes */
43+
allowed?: string[]
44+
/** Deny these currency codes */
45+
denied?: string[]
46+
/** Filter by currency type */
47+
type?: "fiat" | "crypto" | "all"
48+
}
49+
50+
/**
51+
* Create a currency validation schema
52+
*
53+
* @example
54+
* ```ts
55+
* // Any valid currency
56+
* const schema = zCurrency()
57+
* schema.parse("USD") // Returns USD Currency object
58+
*
59+
* // Only specific currencies
60+
* const usdEurSchema = zCurrency({ allowed: ["USD", "EUR"] })
61+
*
62+
* // Only fiat currencies
63+
* const fiatSchema = zCurrency({ type: "fiat" })
64+
* ```
65+
*/
66+
export function zCurrency(options?: ZCurrencyOptions) {
67+
return z.string().transform((code, ctx) => {
68+
const upperCode = code.toUpperCase()
69+
70+
// Check allowlist
71+
if (options?.allowed && !options.allowed.includes(upperCode)) {
72+
ctx.addIssue({
73+
code: z.ZodIssueCode.custom,
74+
message: `Currency ${upperCode} is not in allowed list: ${options.allowed.join(", ")}`,
75+
})
76+
return z.NEVER
77+
}
78+
79+
// Check denylist
80+
if (options?.denied?.includes(upperCode)) {
81+
ctx.addIssue({
82+
code: z.ZodIssueCode.custom,
83+
message: `Currency ${upperCode} is not allowed`,
84+
})
85+
return z.NEVER
86+
}
87+
88+
// Get the currency
89+
let currency: Currency
90+
try {
91+
currency = getCurrencyFromCode(upperCode)
92+
} catch {
93+
ctx.addIssue({
94+
code: z.ZodIssueCode.custom,
95+
message: `Unknown currency code: ${code}`,
96+
})
97+
return z.NEVER
98+
}
99+
100+
// Check type filter
101+
if (options?.type && options.type !== "all") {
102+
const isFiat = currency.iso4217Support === true
103+
if (options.type === "fiat" && !isFiat) {
104+
ctx.addIssue({
105+
code: z.ZodIssueCode.custom,
106+
message: `Currency ${upperCode} is not a fiat currency`,
107+
})
108+
return z.NEVER
109+
}
110+
if (options.type === "crypto" && isFiat) {
111+
ctx.addIssue({
112+
code: z.ZodIssueCode.custom,
113+
message: `Currency ${upperCode} is not a cryptocurrency`,
114+
})
115+
return z.NEVER
116+
}
117+
}
118+
119+
return currency
120+
})
121+
}
122+
123+
/**
124+
* Get all valid currency codes
125+
*/
126+
export function getValidCurrencyCodes(): string[] {
127+
return Object.keys(currencies)
128+
}

0 commit comments

Comments
 (0)