Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,10 @@ console.log(btcRange.toString({ preferredUnit: "sat" })) // "100,000 sats - 1,00

## Other features

### Zod Integration

For input validation and parsing, see [`@thesis-co/cent-zod`](./packages/cent-zod) which provides Zod schemas for all `cent` types.

### Currency support

`cent` includes comprehensive currency metadata for accurate formatting:
Expand Down
84 changes: 84 additions & 0 deletions packages/cent-zod/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# @thesis-co/cent-zod

Zod schemas for parsing and validating `@thesis-co/cent` types.

```bash
pnpm add @thesis-co/cent-zod
```

## Schemas

### zMoney

```ts
import { zMoney, zMoneyString } from "@thesis-co/cent-zod"

zMoneyString.parse("$100.50") // MoneyClass

// With constraints
zMoney({
currency: "USD",
min: "$0.50",
max: "$10000",
positive: true,
})
```

### zPrice

```ts
import { zPrice } from "@thesis-co/cent-zod"

zPrice().parse({ numerator: "$50,000", denominator: "1 BTC" })
zPrice().parse(["$50,000", "1 BTC"])

// Currency constraints
zPrice("USD", "BTC")
```

### zExchangeRate

```ts
import { zExchangeRate } from "@thesis-co/cent-zod"

zExchangeRate("USD", "EUR").parse({ base: "USD", quote: "EUR", rate: "0.92" })

// With staleness check
zExchangeRate({ base: "BTC", quote: "USD", maxAge: 60000 })
```

### zPriceRange

```ts
import { zPriceRange } from "@thesis-co/cent-zod"

zPriceRange().parse("$50 - $100")
zPriceRange().parse({ min: "$50", max: "$100" })

// With constraints
zPriceRange({
currency: "USD",
bounds: { min: "$0", max: "$10000" },
minSpan: "$10",
})
```

### zCurrency

```ts
import { zCurrency } from "@thesis-co/cent-zod"

zCurrency().parse("USD") // Currency object
zCurrency({ allowed: ["USD", "EUR", "GBP"] })
zCurrency({ type: "crypto" })
```

## Type Inference

```ts
import { z } from "zod"
import { zMoney } from "@thesis-co/cent-zod"

const schema = zMoney("USD")
type USDMoney = z.infer<typeof schema> // MoneyClass
```
14 changes: 14 additions & 0 deletions packages/cent-zod/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/test/**/*.test.ts'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: './tsconfig.json',
},
],
},
}
46 changes: 46 additions & 0 deletions packages/cent-zod/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "@thesis-co/cent-zod",
"version": "0.0.1",
"description": "Zod schemas for validating and parsing @thesis-co/cent types",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "https://github.com/thesis/cent.git",
"directory": "packages/cent-zod"
},
"keywords": [
"zod",
"validation",
"finance",
"currency",
"money",
"schema"
],
"author": "Matt Luongo (@mhluongo)",
"publishConfig": {
"access": "public"
},
"scripts": {
"lint": "pnpx @biomejs/biome check",
"lint:fix": "pnpx @biomejs/biome check --write",
"build": "tsc",
"test": "jest",
"prepublishOnly": "pnpm run build && pnpm run test && pnpm run lint"
},
"devDependencies": {
"@thesis-co/cent": "workspace:*",
"@types/jest": "^29.5.12",
"@types/node": "^20.11.24",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
"zod": "^4.0.0"
},
"peerDependencies": {
"@thesis-co/cent": ">=0.0.5",
"zod": ">=4.0.0"
}
}
38 changes: 38 additions & 0 deletions packages/cent-zod/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Common schemas and utilities
export {
zBigIntString,
zDecimalString,
zFixedPointJSON,
zNonNegativeBigIntString,
zRationalNumberJSON,
} from "./schemas/common"
export type { ZCurrencyOptions } from "./schemas/currency"
// Currency schemas
export {
getValidCurrencyCodes,
zCurrency,
zCurrencyCode,
zCurrencyObject,
} from "./schemas/currency"
export type { ZExchangeRateOptions } from "./schemas/exchange-rate"
// Exchange rate schemas
export {
zExchangeRate,
zExchangeRateCompact,
zExchangeRateJSON,
zExchangeRateSource,
} from "./schemas/exchange-rate"
export type { ZMoneyOptions } from "./schemas/money"
// Money schemas
export { zMoney, zMoneyJSON, zMoneyString } from "./schemas/money"
export type { ZPriceOptions } from "./schemas/price"
// Price schemas
export { zPrice, zPriceFromObject, zPriceFromTuple } from "./schemas/price"
export type { ZPriceRangeOptions } from "./schemas/price-range"
// Price range schemas
export {
zPriceRange,
zPriceRangeJSON,
zPriceRangeObject,
zPriceRangeString,
} from "./schemas/price-range"
42 changes: 42 additions & 0 deletions packages/cent-zod/src/schemas/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { z } from "zod"

/**
* Schema for bigint values serialized as strings
*/
export const zBigIntString = z
.string()
.regex(/^-?\d+$/, "Must be a valid integer string")
.transform((val) => BigInt(val))

/**
* Schema for non-negative bigint values
*/
export const zNonNegativeBigIntString = z
.string()
.regex(/^\d+$/, "Must be a valid non-negative integer string")
.transform((val) => BigInt(val))

/**
* Schema for FixedPoint JSON representation
* Transforms to { amount: bigint, decimals: bigint }
*/
export const zFixedPointJSON = z.object({
amount: zBigIntString,
decimals: zNonNegativeBigIntString,
})

/**
* Schema for RationalNumber JSON representation
* Validates { p: string, q: string } (no transform - Money.fromJSON handles conversion)
*/
export const zRationalNumberJSON = z.object({
p: z.string().regex(/^-?\d+$/, "Must be a valid integer string"),
q: z.string().regex(/^-?\d+$/, "Must be a valid integer string"),
})

/**
* Schema for decimal string input (e.g., "123.45")
*/
export const zDecimalString = z
.string()
.regex(/^-?\d+(\.\d+)?$/, "Must be a valid decimal string")
128 changes: 128 additions & 0 deletions packages/cent-zod/src/schemas/currency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { type Currency, currencies, getCurrencyFromCode } from "@thesis-co/cent"
import { z } from "zod"
import { zNonNegativeBigIntString } from "./common"

/**
* Schema that validates a currency code string and transforms to Currency object
*/
export const zCurrencyCode = z.string().transform((code, ctx) => {
try {
return getCurrencyFromCode(code)
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
error: `Unknown currency code: ${code}`,
})
return z.NEVER
}
})

/**
* Schema for full Currency object representation (validation only, no transform)
*/
export const zCurrencyObject = z.object({
name: z.string(),
code: z.string(),
decimals: z.union([z.bigint(), zNonNegativeBigIntString]),
symbol: z.string(),
fractionalUnit: z
.union([
z.string(),
z.array(z.string()),
z.record(z.string(), z.union([z.string(), z.array(z.string())])),
])
.optional(),
iso4217Support: z.boolean().optional(),
})

/**
* Options for zCurrency schema
*/
export interface ZCurrencyOptions {
/** Only allow these currency codes */
allowed?: string[]
/** Deny these currency codes */
denied?: string[]
/** Filter by currency type */
type?: "fiat" | "crypto" | "all"
}

/**
* Create a currency validation schema
*
* @example
* ```ts
* // Any valid currency
* const schema = zCurrency()
* schema.parse("USD") // Returns USD Currency object
*
* // Only specific currencies
* const usdEurSchema = zCurrency({ allowed: ["USD", "EUR"] })
*
* // Only fiat currencies
* const fiatSchema = zCurrency({ type: "fiat" })
* ```
*/
export function zCurrency(options?: ZCurrencyOptions) {
return z.string().transform((code, ctx) => {
const upperCode = code.toUpperCase()

// Check allowlist
if (options?.allowed && !options.allowed.includes(upperCode)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
error: `Currency ${upperCode} is not in allowed list: ${options.allowed.join(", ")}`,
})
return z.NEVER
}

// Check denylist
if (options?.denied?.includes(upperCode)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
error: `Currency ${upperCode} is not allowed`,
})
return z.NEVER
}

// Get the currency
let currency: Currency
try {
currency = getCurrencyFromCode(upperCode)
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
error: `Unknown currency code: ${code}`,
})
return z.NEVER
}

// Check type filter
if (options?.type && options.type !== "all") {
const isFiat = currency.iso4217Support === true
if (options.type === "fiat" && !isFiat) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
error: `Currency ${upperCode} is not a fiat currency`,
})
return z.NEVER
}
if (options.type === "crypto" && isFiat) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
error: `Currency ${upperCode} is not a cryptocurrency`,
})
return z.NEVER
}
}

return currency
})
}

/**
* Get all valid currency codes
*/
export function getValidCurrencyCodes(): string[] {
return Object.keys(currencies)
}
Loading