diff --git a/CHANGELOG.md b/CHANGELOG.md index 14cbc4c..9660761 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.0] + +### Changed + +- **BC Break:** Use `BigDecimal` for floats, see [002_float_precision.md](adr/002_float_precision.md). + ## [0.5.1] ### Added diff --git a/adr/002_float_precision.md b/adr/002_float_precision.md new file mode 100644 index 0000000..3f95476 --- /dev/null +++ b/adr/002_float_precision.md @@ -0,0 +1,113 @@ +# ADR-002: Address Floating-Point Precision + +## Status + +Proposed + +## Context + +Typhoon defines two types that represent floating-point numbers using float literals: `FloatValueT` and `FloatRangeT`. + +Both types may appear in PHPDoc annotations or be constructed programmatically: + +```php +use function Typhoon\Type\floatT; +use function Typhoon\Type\floatRangeT; + +/** + * @param -0.21991 $value + * @param float<0.5, 1234.8> $range + */ +function x(float $value, float $range): void {} + +floatT(-0.21991); +floatRangeT(0.5, 1234.8); +``` + +When converting string float representations parsed from PHPDoc into native `float`, precision is lost due to +the inherent limitations of binary floating-point representation. + +## Decision + +Store float values inside `FloatValueT` and `FloatRangeT` using `Brick\Math\BigDecimal`. Although this introduces an +external dependency, `BigDecimal` provides a robust, type-safe, and precise representation that avoids floating-point +rounding errors and requires minimal maintenance. + +## Considered Options + +### 1. `numeric-string` + +**Advantages** + +* Retains full decimal precision. +* No external dependencies. +* Minimal implementation effort. + +**Disadvantages** + +* Allows scientific notation (e.g., `1e-5`). +* Requires external or custom logic for arithmetic and comparisons. + +### 2. Value Object wrapping `numeric-string` + +**Advantages** + +* Retains full decimal precision. +* Can forbid scientific notation. +* No external dependencies. + +**Disadvantages** + +* Requires internal support and maintenance. +* Still needs external or custom implementations for numeric operations. + +### 3. `brick/math` + +**Advantages** + +* Retains full decimal precision. +* Eliminates scientific notation inconsistencies. +* Provides a mature, feature-rich API. +* Supports multiple math backends and extensions. +* No need for custom logic or maintenance. + +**Disadvantages** + +* External dependency. + +## Consequences + +* Full decimal precision is preserved for all float literals and ranges. +* PHPDoc float literals are represented consistently and accurately. +* The project now depends on `brick/math`. +* Rational values (e.g., `1/3`) remain unsupported in type syntax. + +## Implementation Details + +* Use `Brick\Math\BigDecimal` internally in `FloatValueT` and `FloatRangeT`. +* Do **not** use `BigNumber`, as it includes `BigRational` (e.g., `1/3`), which cannot currently be expressed in + PHPDoc syntax. Users who need rational arithmetic can handle it explicitly: + + ```php + use Brick\Math\BigNumber; + + floatValueT(BigNumber::of('1/3')->toScale(10, RoundingMode::HALF_UP)); + ``` +* In [`Stringify`](../src/Visitor/Stringify.php), when stringifying floats with a zero scale, set the scale to `1` + to visually distinguish floats from integers: + + ```php + use Brick\Math\BigDecimal; + use function Typhoon\Type\stringify; + + stringify(floatValueT(1)); // 1.0 + stringify(floatValueT(1.0)); // 1.0 + stringify(floatValueT(BigDecimal::one())); // 1.0 + + // note that these numbers equal: + var_dump(BigDecimal::one()->isEqualTo('1.0')); // true + ``` + +## References + +* [Brick\Math documentation](https://github.com/brick/math) diff --git a/generator/spec.php b/generator/spec.php index fb4a496..2fff222 100644 --- a/generator/spec.php +++ b/generator/spec.php @@ -4,14 +4,14 @@ namespace Typhoon\Type\Generator\Spec; -use Brick\Math\BigNumber; +use Brick\Math\BigDecimal; use Typhoon\Type\ArrayKeyT; use Typhoon\Type\Mask; use Typhoon\Type\MixedT; use Typhoon\Type\Type; $typeClass = '\\' . Type::class; -$bigNumberClass = '\\' . BigNumber::class; +$bigDecimalClass = '\\' . BigDecimal::class; $closureClass = '\\' . \Closure::class; $maskClass = '\\' . Mask::class; $mixedT = \sprintf('\%s::T', MixedT::class); @@ -37,8 +37,8 @@ constr('bitmask', 'T', [tpl('T', 'int')], [prop('intType', $typeClass)]), // float single('float', 'float', 'floatRange()'), - constr('floatValue', 'T', [tpl('T', 'float')], [prop('value', $bigNumberClass)], 'floatRange($value, $value)'), - constr('floatRange', 'T', [tpl('T', 'float')], [prop('min', '?' . $bigNumberClass), prop('max', '?' . $bigNumberClass)]), + constr('floatValue', 'T', [tpl('T', 'float')], [prop('value', $bigDecimalClass)], 'floatRange($value, $value)'), + constr('floatRange', 'T', [tpl('T', 'float')], [prop('min', '?' . $bigDecimalClass), prop('max', '?' . $bigDecimalClass)]), // string single('string', 'string'), single('nonEmptyString', 'non-empty-string'), diff --git a/src/FloatRangeT.php b/src/FloatRangeT.php index f2519f1..cd4773c 100644 --- a/src/FloatRangeT.php +++ b/src/FloatRangeT.php @@ -8,7 +8,7 @@ namespace Typhoon\Type; -use Brick\Math\BigNumber; +use Brick\Math\BigDecimal; /** * @api @@ -19,8 +19,8 @@ final readonly class FloatRangeT implements Type { public function __construct( - public ?BigNumber $min = null, - public ?BigNumber $max = null, + public ?BigDecimal $min = null, + public ?BigDecimal $max = null, ) {} #[\Override] diff --git a/src/FloatValueT.php b/src/FloatValueT.php index 6648ddb..a594181 100644 --- a/src/FloatValueT.php +++ b/src/FloatValueT.php @@ -8,7 +8,7 @@ namespace Typhoon\Type; -use Brick\Math\BigNumber; +use Brick\Math\BigDecimal; /** * @api @@ -19,7 +19,7 @@ final readonly class FloatValueT implements Type { public function __construct( - public BigNumber $value, + public BigDecimal $value, ) {} #[\Override] diff --git a/src/Visitor/Stringify.php b/src/Visitor/Stringify.php index b9cc7f8..14be43c 100644 --- a/src/Visitor/Stringify.php +++ b/src/Visitor/Stringify.php @@ -185,13 +185,13 @@ public function floatT(FloatT $type): string #[\Override] public function floatValueT(FloatValueT $type): string { - $string = (string) $type->value; + $value = $type->value; - if (str_contains($string, '.') || str_contains($string, '/')) { - return $string; + if ($value->getScale() === 0) { + return $value->toScale(1)->__toString(); } - return $string . '.0'; + return $value->__toString(); } #[\Override] diff --git a/src/constructors.php b/src/constructors.php index 964b454..81bb06c 100644 --- a/src/constructors.php +++ b/src/constructors.php @@ -4,7 +4,7 @@ namespace Typhoon\Type; -use Brick\Math\BigNumber; +use Brick\Math\BigDecimal; use Typhoon\Type\Generator\Generator; use Typhoon\Type\Internal\Optional; @@ -93,36 +93,36 @@ function intMaskT(int|Type|array $ints, int|Type ...$moreInts): BitmaskT /** * @api - * @param float|numeric-string|BigNumber $value + * @param float|numeric-string|BigDecimal $value * @return FloatValueT */ -function floatT(float|string|BigNumber $value): FloatValueT +function floatT(float|string|BigDecimal $value): FloatValueT { - return new FloatValueT(BigNumber::of($value)); + return new FloatValueT(BigDecimal::of($value)); } /** * @api - * @param null|int|float|numeric-string|BigNumber $min - * @param null|int|float|numeric-string|BigNumber $max + * @param null|int|float|numeric-string|BigDecimal $min + * @param null|int|float|numeric-string|BigDecimal $max * @return Type */ -function floatRangeT(null|int|float|string|BigNumber $min = null, null|int|float|string|BigNumber $max = null): Type +function floatRangeT(null|int|float|string|BigDecimal $min = null, null|int|float|string|BigDecimal $max = null): Type { if ($min === null) { if ($max === null) { return floatT; } - return new FloatRangeT(max: BigNumber::of($max)); + return new FloatRangeT(max: BigDecimal::of($max)); } if ($max === null) { - return new FloatRangeT(min: BigNumber::of($min)); + return new FloatRangeT(min: BigDecimal::of($min)); } - $min = BigNumber::of($min); - $max = BigNumber::of($max); + $min = BigDecimal::of($min); + $max = BigDecimal::of($max); if ($min->isEqualTo($max)) { return new FloatValueT($min); diff --git a/tests/StringifyTest.php b/tests/StringifyTest.php index d46e377..540eb84 100644 --- a/tests/StringifyTest.php +++ b/tests/StringifyTest.php @@ -4,9 +4,6 @@ namespace Typhoon\Type; -use Brick\Math\BigDecimal; -use Brick\Math\BigInteger; -use Brick\Math\BigRational; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversFunction; use PHPUnit\Framework\Attributes\DataProvider; @@ -100,14 +97,15 @@ public static function provideCases(): iterable yield [intMaskT(orT(intT(1), intT(2), intT(4))), 'int-mask-of<1|2|4>']; yield [intMaskT(constantMaskT('JSON_*')), 'int-mask-of>']; yield [floatT, 'float']; - yield [floatT('0.234'), '0.234']; + yield [floatT(0), '0.0']; + yield [floatT('0'), '0.0']; + yield [floatT(0.0), '0.0']; + yield [floatT('0.0'), '0.0']; yield [floatT(0.234), '0.234']; + yield [floatT('0.234'), '0.234']; yield [floatT(-0.234), '-0.234']; + yield [floatT('-0.234'), '-0.234']; yield [floatT(1), '1.0']; - yield [floatT(BigDecimal::one()), '1.0']; - yield [floatT(BigInteger::one()), '1.0']; - yield [floatT(BigRational::nd(1, 3)), '1/3']; - yield [floatT(BigRational::of(1)), '1.0']; yield [floatRangeT(), 'float']; yield [floatRangeT(-0.99999, 1.232111111), 'float<-0.99999, 1.232111111>']; yield [floatRangeT(max: 1.3), 'float'];