From b015176691a92cc231b7a8e69a288225ba338492 Mon Sep 17 00:00:00 2001 From: Loris Leiva Date: Thu, 23 Apr 2026 10:25:56 +0100 Subject: [PATCH] Add fixed-point types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is part of the stack implementing the fixed-point number types proposed in #1545. It lands the public type surface that every follow-up PR will build on. Adds the four core types: - `Signedness` — `'signed' | 'unsigned'`. - `RoundingMode` — `'floor' | 'ceil' | 'trunc' | 'round' | 'strict'`. The `'round'` mode breaks ties away from zero (e.g. `0.5 → 1`, `-0.5 → -1`, `-1.5 → -2`), which is symmetric around zero and differs from `Math.round`. The `'strict'` mode rejects inputs that would require rounding and throws instead. - `BinaryFixedPoint` — a fixed-point value whose scale is a power of 2. - `DecimalFixedPoint` — a fixed-point value whose scale is a power of 10. --- .../src/__tests__/placeholder-test.ts | 8 ++--- .../src/__typetests__/binary-core-typetest.ts | 35 ++++++++++++++++++ .../__typetests__/decimal-core-typetest.ts | 35 ++++++++++++++++++ .../src/__typetests__/placeholder-typetest.ts | 4 --- packages/fixed-points/src/binary/core.ts | 36 +++++++++++++++++++ packages/fixed-points/src/binary/index.ts | 1 + packages/fixed-points/src/decimal/core.ts | 30 ++++++++++++++++ packages/fixed-points/src/decimal/index.ts | 1 + packages/fixed-points/src/index.ts | 13 +++---- packages/fixed-points/src/rounding.ts | 18 ++++++++++ packages/fixed-points/src/signedness.ts | 12 +++++++ 11 files changed, 175 insertions(+), 18 deletions(-) create mode 100644 packages/fixed-points/src/__typetests__/binary-core-typetest.ts create mode 100644 packages/fixed-points/src/__typetests__/decimal-core-typetest.ts delete mode 100644 packages/fixed-points/src/__typetests__/placeholder-typetest.ts create mode 100644 packages/fixed-points/src/binary/core.ts create mode 100644 packages/fixed-points/src/binary/index.ts create mode 100644 packages/fixed-points/src/decimal/core.ts create mode 100644 packages/fixed-points/src/decimal/index.ts create mode 100644 packages/fixed-points/src/rounding.ts create mode 100644 packages/fixed-points/src/signedness.ts diff --git a/packages/fixed-points/src/__tests__/placeholder-test.ts b/packages/fixed-points/src/__tests__/placeholder-test.ts index ee89838a6..340e22f70 100644 --- a/packages/fixed-points/src/__tests__/placeholder-test.ts +++ b/packages/fixed-points/src/__tests__/placeholder-test.ts @@ -1,7 +1,5 @@ -import { __placeholder } from '..'; - -describe('@solana/fixed-points scaffold', () => { - it('exports a placeholder symbol until the real API lands', () => { - expect(typeof __placeholder).toBe('symbol'); +describe('@solana/fixed-points', () => { + it('has runtime tests that arrive with the upcoming factory PR', () => { + expect(true).toBe(true); }); }); diff --git a/packages/fixed-points/src/__typetests__/binary-core-typetest.ts b/packages/fixed-points/src/__typetests__/binary-core-typetest.ts new file mode 100644 index 000000000..7b3b39b80 --- /dev/null +++ b/packages/fixed-points/src/__typetests__/binary-core-typetest.ts @@ -0,0 +1,35 @@ +import type { BinaryFixedPoint } from '../binary/core'; + +// [DESCRIBE] BinaryFixedPoint. +{ + // It preserves the Signedness, TotalBits, and FractionalBits type parameters. + { + const value = {} as BinaryFixedPoint<'signed', 16, 15>; + value.signedness satisfies 'signed'; + value.totalBits satisfies 16; + value.fractionalBits satisfies 15; + value.kind satisfies 'binaryFixedPoint'; + value.raw satisfies bigint; + } + + // A concretely-parameterised value satisfies a generically-parameterised type. + { + const value = {} as BinaryFixedPoint<'signed', 16, 15>; + value satisfies BinaryFixedPoint<'signed', number, number>; + } + + // Fields are readonly. + { + const value = {} as BinaryFixedPoint<'signed', 16, 8>; + // @ts-expect-error fractionalBits is readonly. + value.fractionalBits = 16; + // @ts-expect-error kind is readonly. + value.kind = 'decimalFixedPoint'; + // @ts-expect-error raw is readonly. + value.raw = 1n; + // @ts-expect-error signedness is readonly. + value.signedness = 'unsigned'; + // @ts-expect-error totalBits is readonly. + value.totalBits = 32; + } +} diff --git a/packages/fixed-points/src/__typetests__/decimal-core-typetest.ts b/packages/fixed-points/src/__typetests__/decimal-core-typetest.ts new file mode 100644 index 000000000..1a10104d7 --- /dev/null +++ b/packages/fixed-points/src/__typetests__/decimal-core-typetest.ts @@ -0,0 +1,35 @@ +import type { DecimalFixedPoint } from '../decimal/core'; + +// [DESCRIBE] DecimalFixedPoint. +{ + // It preserves the Signedness, TotalBits, and Decimals type parameters. + { + const value = {} as DecimalFixedPoint<'unsigned', 64, 6>; + value.signedness satisfies 'unsigned'; + value.totalBits satisfies 64; + value.decimals satisfies 6; + value.kind satisfies 'decimalFixedPoint'; + value.raw satisfies bigint; + } + + // A concretely-parameterised value satisfies a generically-parameterised type. + { + const value = {} as DecimalFixedPoint<'unsigned', 64, 6>; + value satisfies DecimalFixedPoint<'unsigned', number, number>; + } + + // Fields are readonly. + { + const value = {} as DecimalFixedPoint<'unsigned', 64, 6>; + // @ts-expect-error decimals is readonly. + value.decimals = 16; + // @ts-expect-error kind is readonly. + value.kind = 'binaryFixedPoint'; + // @ts-expect-error raw is readonly. + value.raw = 1n; + // @ts-expect-error signedness is readonly. + value.signedness = 'signed'; + // @ts-expect-error totalBits is readonly. + value.totalBits = 32; + } +} diff --git a/packages/fixed-points/src/__typetests__/placeholder-typetest.ts b/packages/fixed-points/src/__typetests__/placeholder-typetest.ts deleted file mode 100644 index 5ac9766e0..000000000 --- a/packages/fixed-points/src/__typetests__/placeholder-typetest.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { __placeholder } from '..'; - -// Placeholder type test. Replaced once real exports land. -__placeholder satisfies symbol; diff --git a/packages/fixed-points/src/binary/core.ts b/packages/fixed-points/src/binary/core.ts new file mode 100644 index 000000000..921c4c0ff --- /dev/null +++ b/packages/fixed-points/src/binary/core.ts @@ -0,0 +1,36 @@ +import type { Signedness } from '../signedness'; + +/** + * A fixed-point number whose scale is a power of 2. The stored `raw` bigint + * represents the mathematical value `raw / 2 ** fractionalBits`. + * + * Binary fixed-point is the fastest fractional representation to compute + * with — rescaling is a bit shift — so it is the preferred choice for + * audio samples, graphics, probabilities, and any other quantity where + * performance matters and the scale does not need to align with decimal + * digits. + * + * @typeParam TSignedness - Whether the value can be negative. + * @typeParam TTotalBits - The total number of bits used to store the raw value. + * @typeParam TFractionalBits - The number of bits to the right of the binary point. + * + * @example + * A 16-bit signed Q1.15 audio sample: + * ```ts + * type AudioSample = BinaryFixedPoint<'signed', 16, 15>; + * ``` + * + * @see {@link DecimalFixedPoint} + * @see {@link Signedness} + */ +export type BinaryFixedPoint< + TSignedness extends Signedness, + TTotalBits extends number, + TFractionalBits extends number, +> = { + readonly fractionalBits: TFractionalBits; + readonly kind: 'binaryFixedPoint'; + readonly raw: bigint; + readonly signedness: TSignedness; + readonly totalBits: TTotalBits; +}; diff --git a/packages/fixed-points/src/binary/index.ts b/packages/fixed-points/src/binary/index.ts new file mode 100644 index 000000000..4b0e04137 --- /dev/null +++ b/packages/fixed-points/src/binary/index.ts @@ -0,0 +1 @@ +export * from './core'; diff --git a/packages/fixed-points/src/decimal/core.ts b/packages/fixed-points/src/decimal/core.ts new file mode 100644 index 000000000..48c724a74 --- /dev/null +++ b/packages/fixed-points/src/decimal/core.ts @@ -0,0 +1,30 @@ +import type { Signedness } from '../signedness'; + +/** + * A fixed-point number whose scale is a power of 10. The stored `raw` bigint + * represents the mathematical value `raw / 10 ** decimals`. + * + * Decimal fixed-point is the natural representation for quantities that + * users reason about in base-10 terms, such as token amounts, currency, or + * probabilities with decimal precision. + * + * @typeParam TSignedness - Whether the value can be negative. + * @typeParam TTotalBits - The total number of bits used to store the raw value. + * @typeParam TDecimals - The number of decimal digits to the right of the decimal point. + * + * @example + * An unsigned 64-bit USDC amount with 6 decimals of precision: + * ```ts + * type Usdc = DecimalFixedPoint<'unsigned', 64, 6>; + * ``` + * + * @see {@link BinaryFixedPoint} + * @see {@link Signedness} + */ +export type DecimalFixedPoint = { + readonly decimals: TDecimals; + readonly kind: 'decimalFixedPoint'; + readonly raw: bigint; + readonly signedness: TSignedness; + readonly totalBits: TTotalBits; +}; diff --git a/packages/fixed-points/src/decimal/index.ts b/packages/fixed-points/src/decimal/index.ts new file mode 100644 index 000000000..4b0e04137 --- /dev/null +++ b/packages/fixed-points/src/decimal/index.ts @@ -0,0 +1 @@ +export * from './core'; diff --git a/packages/fixed-points/src/index.ts b/packages/fixed-points/src/index.ts index ce983909e..456f32509 100644 --- a/packages/fixed-points/src/index.ts +++ b/packages/fixed-points/src/index.ts @@ -10,12 +10,7 @@ * * @packageDocumentation */ - -/** - * Placeholder export that exists solely to give this package tree-shakable - * contents while its real API is implemented in follow-up PRs. It will be - * removed as soon as the first real export lands. - * - * @internal - */ -export const __placeholder: unique symbol = Symbol('@solana/fixed-points/placeholder'); +export * from './binary'; +export * from './decimal'; +export * from './rounding'; +export * from './signedness'; diff --git a/packages/fixed-points/src/rounding.ts b/packages/fixed-points/src/rounding.ts new file mode 100644 index 000000000..d01920c20 --- /dev/null +++ b/packages/fixed-points/src/rounding.ts @@ -0,0 +1,18 @@ +/** + * Rounding mode used by fixed-point operations that must coerce an exact + * mathematical result into a value with fewer bits of precision. Applies to + * factories that accept lossy inputs, as well as to downscaling rescales and + * divisions. + * + * - `'floor'` rounds toward negative infinity. + * - `'ceil'` rounds toward positive infinity. + * - `'trunc'` rounds toward zero, discarding the fractional part. + * - `'round'` rounds to the nearest representable value, with ties rounded + * away from zero. That is, `0.5` rounds to `1`, `-0.5` rounds to `-1`, and + * `-1.5` rounds to `-2`. This is symmetric around zero and differs from + * JavaScript's `Math.round`, which breaks ties toward positive infinity. + * - `'strict'` rejects any input that would require rounding and throws + * `SOLANA_ERROR__FIXED_POINTS__STRICT_MODE_PRECISION_LOSS` instead of + * coercing the result. + */ +export type RoundingMode = 'ceil' | 'floor' | 'round' | 'strict' | 'trunc'; diff --git a/packages/fixed-points/src/signedness.ts b/packages/fixed-points/src/signedness.ts new file mode 100644 index 000000000..dbe58c7d4 --- /dev/null +++ b/packages/fixed-points/src/signedness.ts @@ -0,0 +1,12 @@ +/** + * Whether a fixed-point number can represent negative values. + * + * - `'signed'` fixed-point numbers use two's-complement semantics and can + * represent both negative and non-negative values. + * - `'unsigned'` fixed-point numbers can only represent non-negative values + * but get one extra bit of positive range. + * + * @see {@link BinaryFixedPoint} + * @see {@link DecimalFixedPoint} + */ +export type Signedness = 'signed' | 'unsigned';