diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..f5225c3
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,9 @@
+/.github/ export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+
+/math/phpcs.xml.dist export-ignore
+/math/phpstan.neon export-ignore
+
+/money/phpcs.xml.dist export-ignore
+/money/phpstan.neon export-ignore
diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml
new file mode 100644
index 0000000..e3435ef
--- /dev/null
+++ b/.github/workflows/coding-standards.yml
@@ -0,0 +1,42 @@
+name: "Coding Standards"
+
+on:
+ pull_request:
+ push:
+ branches:
+ - "main"
+
+jobs:
+ coding-standards:
+ name: "Coding Standards - ${{ matrix.package }}"
+ runs-on: "ubuntu-24.04"
+
+ strategy:
+ fail-fast: false
+ matrix:
+ package:
+ - "math"
+ - "money"
+
+ defaults:
+ run:
+ working-directory: "${{ matrix.package }}"
+
+ steps:
+ - name: "Checkout"
+ uses: "actions/checkout@v6"
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@v2"
+ with:
+ coverage: "none"
+ php-version: "8.4"
+ tools: "cs2pr"
+
+ - name: "Install dependencies with Composer"
+ uses: "ramsey/composer-install@v3"
+ with:
+ working-directory: "${{ matrix.package }}"
+
+ - name: "Run PHP_CodeSniffer"
+ run: "vendor/bin/phpcs -q --no-colors --report=checkstyle | cs2pr"
diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
new file mode 100644
index 0000000..c7d488f
--- /dev/null
+++ b/.github/workflows/static-analysis.yml
@@ -0,0 +1,42 @@
+name: "Static Analysis"
+
+on:
+ pull_request:
+ push:
+ branches:
+ - "main"
+
+jobs:
+ static-analysis-phpstan:
+ name: "PHPStan - ${{ matrix.package }}"
+ runs-on: "ubuntu-24.04"
+
+ strategy:
+ fail-fast: false
+ matrix:
+ package:
+ - "math"
+ - "money"
+
+ defaults:
+ run:
+ working-directory: "${{ matrix.package }}"
+
+ steps:
+ - name: "Checkout code"
+ uses: "actions/checkout@v6"
+
+ - name: "Install PHP"
+ uses: "shivammathur/setup-php@v2"
+ with:
+ coverage: "none"
+ php-version: "8.4"
+ tools: "cs2pr"
+
+ - name: "Install dependencies with Composer"
+ uses: "ramsey/composer-install@v3"
+ with:
+ working-directory: "${{ matrix.package }}"
+
+ - name: "Run a static analysis with phpstan/phpstan"
+ run: "vendor/bin/phpstan analyse --error-format=checkstyle | cs2pr"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f6898c1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+# math
+/math/vendor/
+/math/composer.lock
+/math/.phpcs-cache
+
+# money
+/money/vendor/
+/money/composer.lock
+/money/.phpcs-cache
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0e3ff77
--- /dev/null
+++ b/README.md
@@ -0,0 +1,35 @@
+# PHPStan Brick Extensions
+
+PHPStan extensions that narrow throw types for [brick/math](https://github.com/brick/math) and [brick/money](https://github.com/brick/money).
+
+## Packages
+
+### `simpod/phpstan-brick-math`
+
+```
+composer require --dev simpod/phpstan-brick-math
+```
+
+If you use [phpstan/extension-installer](https://github.com/phpstan/extension-installer), you're all set.
+
+Otherwise, include in your `phpstan.neon`:
+
+```neon
+includes:
+ - vendor/simpod/phpstan-brick-math/extension.neon
+```
+
+### `simpod/phpstan-brick-money`
+
+```
+composer require --dev simpod/phpstan-brick-money
+```
+
+If you use [phpstan/extension-installer](https://github.com/phpstan/extension-installer), you're all set.
+
+Otherwise, include in your `phpstan.neon`:
+
+```neon
+includes:
+ - vendor/simpod/phpstan-brick-money/extension.neon
+```
diff --git a/math/composer.json b/math/composer.json
new file mode 100644
index 0000000..4419c46
--- /dev/null
+++ b/math/composer.json
@@ -0,0 +1,31 @@
+{
+ "name": "simpod/phpstan-brick-math",
+ "description": "PHPStan dynamic throw type extensions for brick/math",
+ "license": "MIT",
+ "type": "phpstan-extension",
+ "require": {
+ "php": "^8.4",
+ "brick/math": "^0.15",
+ "phpstan/phpstan": "^2.1"
+ },
+ "require-dev": {
+ "cdn77/coding-standard": "^7.4"
+ },
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Brick\\Math\\PHPStan\\": "src/"
+ }
+ }
+}
diff --git a/math/extension.neon b/math/extension.neon
new file mode 100644
index 0000000..9732266
--- /dev/null
+++ b/math/extension.neon
@@ -0,0 +1,20 @@
+services:
+ -
+ class: Brick\Math\PHPStan\BigNumberOfThrowTypeExtension
+ tags:
+ - phpstan.dynamicStaticMethodThrowTypeExtension
+
+ -
+ class: Brick\Math\PHPStan\BigNumberConversionThrowTypeExtension
+ tags:
+ - phpstan.dynamicMethodThrowTypeExtension
+
+ -
+ class: Brick\Math\PHPStan\BigNumberOperationThrowTypeExtension
+ tags:
+ - phpstan.dynamicMethodThrowTypeExtension
+
+ -
+ class: Brick\Math\PHPStan\RoundingModeThrowTypeExtension
+ tags:
+ - phpstan.dynamicMethodThrowTypeExtension
diff --git a/math/phpcs.xml.dist b/math/phpcs.xml.dist
new file mode 100644
index 0000000..b4a84c0
--- /dev/null
+++ b/math/phpcs.xml.dist
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ src
+
+
+
diff --git a/math/phpstan.neon b/math/phpstan.neon
new file mode 100644
index 0000000..38b3a3f
--- /dev/null
+++ b/math/phpstan.neon
@@ -0,0 +1,7 @@
+includes:
+ - extension.neon
+
+parameters:
+ level: max
+ paths:
+ - src
diff --git a/math/src/BigNumberConversionThrowTypeExtension.php b/math/src/BigNumberConversionThrowTypeExtension.php
new file mode 100644
index 0000000..8d65842
--- /dev/null
+++ b/math/src/BigNumberConversionThrowTypeExtension.php
@@ -0,0 +1,73 @@
+ BigInteger::class,
+ 'toBigDecimal' => BigDecimal::class,
+ 'toBigRational' => BigRational::class,
+ ];
+
+ public function isMethodSupported(MethodReflection $methodReflection): bool
+ {
+ $className = $methodReflection->getDeclaringClass()->getName();
+ $isBigNumber = $className === BigNumber::class
+ || $methodReflection->getDeclaringClass()->isSubclassOf(BigNumber::class);
+
+ if (! $isBigNumber) {
+ return false;
+ }
+
+ return isset(self::MethodToClass[$methodReflection->getName()])
+ || $methodReflection->getName() === 'toInt';
+ }
+
+ public function getThrowTypeFromMethodCall(
+ MethodReflection $methodReflection,
+ MethodCall $methodCall,
+ Scope $scope,
+ ): Type|null {
+ $callerType = $scope->getType($methodCall->var);
+ $methodName = $methodReflection->getName();
+
+ if ($methodName === 'toInt') {
+ // BigInteger::toInt() can only throw IntegerOverflowException (no RoundingNecessaryException).
+ if ((new ObjectType(BigInteger::class))->isSuperTypeOf($callerType)->yes()) {
+ return new ObjectType(IntegerOverflowException::class);
+ }
+
+ return $methodReflection->getThrowType();
+ }
+
+ $targetClass = self::MethodToClass[$methodName];
+
+ if ((new ObjectType($targetClass))->isSuperTypeOf($callerType)->yes()) {
+ return null;
+ }
+
+ return $methodReflection->getThrowType();
+ }
+}
diff --git a/math/src/BigNumberOfThrowTypeExtension.php b/math/src/BigNumberOfThrowTypeExtension.php
new file mode 100644
index 0000000..cc36a2e
--- /dev/null
+++ b/math/src/BigNumberOfThrowTypeExtension.php
@@ -0,0 +1,225 @@
+ ['of', 'ofNullable', 'min', 'max', 'sum'],
+ BigInteger::class => ['of', 'ofNullable', 'gcdAll', 'lcmAll', 'randomRange'],
+ BigDecimal::class => ['of', 'ofNullable', 'ofUnscaledValue'],
+ BigRational::class => ['of', 'ofNullable', 'ofFraction'],
+ ];
+
+ public function isStaticMethodSupported(MethodReflection $methodReflection): bool
+ {
+ $className = $methodReflection->getDeclaringClass()->getName();
+ $methodName = $methodReflection->getName();
+
+ return isset(self::SupportedMethods[$className])
+ && in_array($methodName, self::SupportedMethods[$className], true);
+ }
+
+ public function getThrowTypeFromStaticMethodCall(
+ MethodReflection $methodReflection,
+ StaticCall $methodCall,
+ Scope $scope,
+ ): Type|null {
+ if (count($methodCall->getArgs()) < 1) {
+ return $methodReflection->getThrowType();
+ }
+
+ $calledOnClass = $methodReflection->getDeclaringClass()->getName();
+ $methodName = $methodReflection->getName();
+
+ // Methods with residual exceptions after parsing is eliminated.
+ if ($methodName === 'ofFraction') {
+ return $this->narrowOfFraction($methodCall, $scope, $methodReflection);
+ }
+
+ if ($methodName === 'randomRange') {
+ return $this->narrowRandomRange($methodCall, $scope, $methodReflection);
+ }
+
+ $noThrowType = self::getNoThrowType($calledOnClass);
+
+ if ($noThrowType === null) {
+ return $methodReflection->getThrowType();
+ }
+
+ // Check ALL arguments (including variadic) match the required type.
+ $allMatch = true;
+
+ foreach ($methodCall->getArgs() as $arg) {
+ $argType = $scope->getType($arg->value);
+
+ if (! $noThrowType->isSuperTypeOf($argType)->yes()) {
+ $allMatch = false;
+
+ break;
+ }
+ }
+
+ if ($allMatch) {
+ return null;
+ }
+
+ // int and numeric-string arguments cannot contain "/", so DivisionByZeroException is impossible.
+ // This applies to of() and ofNullable() on all classes.
+ if (in_array($methodName, ['of', 'ofNullable'], true)) {
+ return $this->narrowNonRationalInput($methodCall, $scope, $methodReflection);
+ }
+
+ return $methodReflection->getThrowType();
+ }
+
+ private function narrowOfFraction(
+ StaticCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ if (count($methodCall->getArgs()) < 2) {
+ return $methodReflection->getThrowType();
+ }
+
+ // int|BigInteger args cannot cause parsing exceptions in BigInteger::of().
+ $safeType = TypeCombinator::union(new IntegerType(), new ObjectType(BigInteger::class));
+
+ foreach ($methodCall->getArgs() as $arg) {
+ $argType = $scope->getType($arg->value);
+
+ if (! $safeType->isSuperTypeOf($argType)->yes()) {
+ return $methodReflection->getThrowType();
+ }
+ }
+
+ // DivisionByZeroException is impossible when the denominator is guaranteed non-zero.
+ $denominatorType = $scope->getType($methodCall->getArgs()[1]->value);
+
+ if (self::isNonZero($denominatorType)) {
+ return null;
+ }
+
+ return new ObjectType(DivisionByZeroException::class);
+ }
+
+ private function narrowRandomRange(
+ StaticCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ if (count($methodCall->getArgs()) < 2) {
+ return $methodReflection->getThrowType();
+ }
+
+ $bigIntegerType = new ObjectType(BigInteger::class);
+
+ // Only check first two args ($min, $max) — third is the callable.
+ for ($i = 0; $i < 2; $i++) {
+ $argType = $scope->getType($methodCall->getArgs()[$i]->value);
+
+ if (! $bigIntegerType->isSuperTypeOf($argType)->yes()) {
+ return $methodReflection->getThrowType();
+ }
+ }
+
+ // Both args are BigInteger — only InvalidArgumentException + RandomSourceException remain.
+ return TypeCombinator::union(
+ new ObjectType(InvalidArgumentException::class),
+ new ObjectType(RandomSourceException::class),
+ );
+ }
+
+ /**
+ * When all arguments are int or numeric-string, the input cannot contain "/",
+ * so {@see DivisionByZeroException} is impossible.
+ */
+ private function narrowNonRationalInput(
+ StaticCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ $nonRationalType = TypeCombinator::union(
+ new IntegerType(),
+ new AccessoryNumericStringType(),
+ new ObjectType(BigNumber::class),
+ );
+
+ foreach ($methodCall->getArgs() as $arg) {
+ $argType = $scope->getType($arg->value);
+
+ if (! $nonRationalType->isSuperTypeOf($argType)->yes()) {
+ return $methodReflection->getThrowType();
+ }
+ }
+
+ $defaultThrowType = $methodReflection->getThrowType();
+
+ if ($defaultThrowType === null) {
+ return null;
+ }
+
+ return TypeCombinator::remove($defaultThrowType, new ObjectType(DivisionByZeroException::class));
+ }
+
+ /**
+ * Returns the argument type for which the method cannot throw.
+ *
+ * int arguments are always safe: _of(int) produces a {@see BigInteger} which converts losslessly to all subtypes.
+ *
+ * - {@see BigNumber}::of/min/max/sum(int|{@see BigNumber}) — no parsing, returned as-is.
+ * - {@see BigInteger}::of/gcdAll/lcmAll(int|{@see BigInteger}) — no parsing, no conversion.
+ * - {@see BigDecimal}::of/ofUnscaledValue(int|{@see BigInteger}|{@see BigDecimal}) — BigInteger converts losslessly to BigDecimal.
+ * - {@see BigRational}::of(int|{@see BigNumber}) — all BigNumber types convert losslessly to BigRational.
+ */
+ private static function isNonZero(Type $type): bool
+ {
+ return (new ConstantIntegerType(0))->isSuperTypeOf($type)->no();
+ }
+
+ private static function getNoThrowType(string $calledOnClass): Type|null
+ {
+ $intType = new IntegerType();
+
+ return match ($calledOnClass) {
+ BigNumber::class => TypeCombinator::union($intType, new ObjectType(BigNumber::class)),
+ BigInteger::class => TypeCombinator::union($intType, new ObjectType(BigInteger::class)),
+ BigDecimal::class => TypeCombinator::union(
+ $intType,
+ new ObjectType(BigInteger::class),
+ new ObjectType(BigDecimal::class),
+ ),
+ BigRational::class => TypeCombinator::union($intType, new ObjectType(BigNumber::class)),
+ default => null,
+ };
+ }
+}
diff --git a/math/src/BigNumberOperationThrowTypeExtension.php b/math/src/BigNumberOperationThrowTypeExtension.php
new file mode 100644
index 0000000..d674f97
--- /dev/null
+++ b/math/src/BigNumberOperationThrowTypeExtension.php
@@ -0,0 +1,317 @@
+ [
+ 'plus',
+ 'minus',
+ 'multipliedBy',
+ 'gcd',
+ 'lcm',
+ 'and',
+ 'or',
+ 'xor',
+ ],
+ BigDecimal::class => [
+ 'plus',
+ 'minus',
+ 'multipliedBy',
+ ],
+ BigRational::class => [
+ 'plus',
+ 'minus',
+ 'multipliedBy',
+ ],
+ BigNumber::class => [
+ 'isEqualTo',
+ 'isLessThan',
+ 'isLessThanOrEqualTo',
+ 'isGreaterThan',
+ 'isGreaterThanOrEqualTo',
+ 'compareTo',
+ ],
+ ];
+
+ /**
+ * Methods that throw because of argument parsing AND have inherent exceptions.
+ * When the argument is the right type, only the inherent exceptions remain.
+ *
+ * class => [method => [residual exception classes]]
+ */
+ private const array ResidualThrowMethods = [
+ BigInteger::class => [
+ 'quotient' => [DivisionByZeroException::class],
+ 'remainder' => [DivisionByZeroException::class],
+ 'quotientAndRemainder' => [DivisionByZeroException::class],
+ 'mod' => [
+ DivisionByZeroException::class,
+ InvalidArgumentException::class,
+ ],
+ 'modInverse' => [
+ DivisionByZeroException::class,
+ InvalidArgumentException::class,
+ NoInverseException::class,
+ ],
+ 'modPow' => [DivisionByZeroException::class, InvalidArgumentException::class],
+ ],
+ BigDecimal::class => [
+ 'quotient' => [DivisionByZeroException::class],
+ 'remainder' => [DivisionByZeroException::class],
+ 'quotientAndRemainder' => [DivisionByZeroException::class],
+ 'dividedByExact' => [DivisionByZeroException::class, RoundingNecessaryException::class],
+ ],
+ BigRational::class => [
+ 'dividedBy' => [DivisionByZeroException::class],
+ ],
+ BigNumber::class => [
+ 'clamp' => [InvalidArgumentException::class],
+ ],
+ ];
+
+ public function isMethodSupported(MethodReflection $methodReflection): bool
+ {
+ $className = $methodReflection->getDeclaringClass()->getName();
+ $methodName = $methodReflection->getName();
+
+ // Check class-specific no-throw methods.
+ if (
+ isset(self::NoThrowMethods[$className])
+ && in_array($methodName, self::NoThrowMethods[$className], true)
+ ) {
+ return true;
+ }
+
+ // Check class-specific residual-throw methods.
+ if (isset(self::ResidualThrowMethods[$className][$methodName])) {
+ return true;
+ }
+
+ // BigNumber comparison/clamp methods match any BigNumber subclass.
+ if ($methodReflection->getDeclaringClass()->isSubclassOf(BigNumber::class)) {
+ if (in_array($methodName, self::NoThrowMethods[BigNumber::class], true)) {
+ return true;
+ }
+
+ if (isset(self::ResidualThrowMethods[BigNumber::class][$methodName])) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function getThrowTypeFromMethodCall(
+ MethodReflection $methodReflection,
+ MethodCall $methodCall,
+ Scope $scope,
+ ): Type|null {
+ if ($methodCall->getArgs() === []) {
+ return $methodReflection->getThrowType();
+ }
+
+ $className = $methodReflection->getDeclaringClass()->getName();
+ $methodName = $methodReflection->getName();
+
+ // Check residual-throw methods first (O(1) lookup).
+ if (isset(self::ResidualThrowMethods[$className][$methodName])) {
+ return $this->narrowResidual($methodCall, $scope, $methodReflection);
+ }
+
+ // Everything else is a no-throw method.
+ return $this->narrowNoThrow($methodCall, $scope, $methodReflection);
+ }
+
+ private function narrowNoThrow(
+ MethodCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ $argType = $scope->getType($methodCall->getArgs()[0]->value);
+ $methodName = $methodReflection->getName();
+
+ // For methods declared on BigNumber (compareTo, isEqualTo, etc.),
+ // the argument just needs to be any BigNumber or int to avoid parsing exceptions.
+ if (in_array($methodName, self::NoThrowMethods[BigNumber::class], true)) {
+ $safeType = TypeCombinator::union(new IntegerType(), new ObjectType(BigNumber::class));
+
+ if ($safeType->isSuperTypeOf($argType)->yes()) {
+ return null;
+ }
+
+ return $methodReflection->getThrowType();
+ }
+
+ // For subclass-specific methods (plus, minus, etc.),
+ // the argument must be the same type (or convertible without loss).
+ $className = $methodReflection->getDeclaringClass()->getName();
+ $requiredType = self::getRequiredArgType($className);
+
+ if ($requiredType !== null && $requiredType->isSuperTypeOf($argType)->yes()) {
+ return null;
+ }
+
+ return $methodReflection->getThrowType();
+ }
+
+ private function narrowResidual(
+ MethodCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ $className = $methodReflection->getDeclaringClass()->getName();
+ $methodName = $methodReflection->getName();
+
+ // For clamp(), check both $min and $max args.
+ if ($methodName === 'clamp') {
+ return $this->narrowClamp($methodCall, $scope, $methodReflection);
+ }
+
+ $argType = $scope->getType($methodCall->getArgs()[0]->value);
+ $requiredType = self::getRequiredArgType($className);
+
+ if ($requiredType === null || ! $requiredType->isSuperTypeOf($argType)->yes()) {
+ return $methodReflection->getThrowType();
+ }
+
+ // modPow has two BigNumber|int|string args.
+ if ($methodName === 'modPow' && isset($methodCall->getArgs()[1])) {
+ $arg2Type = $scope->getType($methodCall->getArgs()[1]->value);
+
+ if (! $requiredType->isSuperTypeOf($arg2Type)->yes()) {
+ return $methodReflection->getThrowType();
+ }
+ }
+
+ $residual = self::ResidualThrowMethods[$className][$methodName];
+
+ // DivisionByZeroException is impossible when the divisor is guaranteed non-zero.
+ if (self::isNonZero($argType)) {
+ $residual = array_values(array_filter(
+ $residual,
+ static fn (string $e): bool => $e !== DivisionByZeroException::class,
+ ));
+ }
+
+ if ($residual === []) {
+ return null;
+ }
+
+ return self::buildExceptionUnion($residual);
+ }
+
+ private function narrowClamp(
+ MethodCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ $args = $methodCall->getArgs();
+
+ if (! isset($args[0], $args[1])) {
+ return $methodReflection->getThrowType();
+ }
+
+ // clamp() is on BigNumber, so we need the caller's type to determine conversion requirements.
+ $callerType = $scope->getType($methodCall->var);
+ $requiredType = self::getRequiredArgTypeForCallerType($callerType);
+
+ if ($requiredType === null) {
+ return $methodReflection->getThrowType();
+ }
+
+ $minType = $scope->getType($args[0]->value);
+ $maxType = $scope->getType($args[1]->value);
+
+ if ($requiredType->isSuperTypeOf($minType)->yes() && $requiredType->isSuperTypeOf($maxType)->yes()) {
+ return new ObjectType(InvalidArgumentException::class);
+ }
+
+ return $methodReflection->getThrowType();
+ }
+
+ private static function getRequiredArgType(string $className): Type|null
+ {
+ // int is always safe: it converts losslessly to any BigNumber subtype.
+ // BigInteger methods require int|BigInteger.
+ // BigDecimal methods accept int|BigInteger|BigDecimal (BigInteger converts losslessly).
+ // BigRational methods accept int|BigNumber (all convert losslessly).
+ $intType = new IntegerType();
+
+ return match ($className) {
+ BigInteger::class => TypeCombinator::union($intType, new ObjectType(BigInteger::class)),
+ BigDecimal::class => TypeCombinator::union(
+ $intType,
+ new ObjectType(BigInteger::class),
+ new ObjectType(BigDecimal::class),
+ ),
+ BigRational::class => TypeCombinator::union($intType, new ObjectType(BigNumber::class)),
+ default => null,
+ };
+ }
+
+ private static function getRequiredArgTypeForCallerType(Type $callerType): Type|null
+ {
+ foreach ([BigInteger::class, BigDecimal::class, BigRational::class] as $class) {
+ if ((new ObjectType($class))->isSuperTypeOf($callerType)->yes()) {
+ return self::getRequiredArgType($class);
+ }
+ }
+
+ return null;
+ }
+
+ private static function isNonZero(Type $type): bool
+ {
+ return (new ConstantIntegerType(0))->isSuperTypeOf($type)->no();
+ }
+
+ /** @param list> $exceptionClasses */
+ private static function buildExceptionUnion(array $exceptionClasses): Type
+ {
+ $types = [];
+
+ foreach ($exceptionClasses as $exceptionClass) {
+ $types[] = new ObjectType($exceptionClass);
+ }
+
+ return TypeCombinator::union(...$types);
+ }
+}
diff --git a/math/src/RoundingModeThrowTypeExtension.php b/math/src/RoundingModeThrowTypeExtension.php
new file mode 100644
index 0000000..d5d5730
--- /dev/null
+++ b/math/src/RoundingModeThrowTypeExtension.php
@@ -0,0 +1,224 @@
+ method => [rounding mode arg index, residual exception classes].
+ */
+ private const array METHODS = [
+ BigInteger::class => [
+ 'dividedBy' => [1, [MathException::class, DivisionByZeroException::class]],
+ 'sqrt' => [0, [NegativeNumberException::class]],
+ ],
+ BigDecimal::class => [
+ 'dividedBy' => [2, [MathException::class, DivisionByZeroException::class]],
+ 'sqrt' => [1, [NegativeNumberException::class]],
+ ],
+ ];
+
+ public function isMethodSupported(MethodReflection $methodReflection): bool
+ {
+ $className = $methodReflection->getDeclaringClass()->getName();
+ $methodName = $methodReflection->getName();
+
+ if ($methodName === 'toScale') {
+ return $className === BigNumber::class
+ || $className === BigInteger::class
+ || $className === BigDecimal::class
+ || $className === BigRational::class;
+ }
+
+ return isset(self::METHODS[$className][$methodName]);
+ }
+
+ public function getThrowTypeFromMethodCall(
+ MethodReflection $methodReflection,
+ MethodCall $methodCall,
+ Scope $scope,
+ ): Type|null {
+ $methodName = $methodReflection->getName();
+
+ if ($methodName === 'toScale') {
+ return $this->narrowToScale($methodCall, $scope, $methodReflection);
+ }
+
+ $className = $methodReflection->getDeclaringClass()->getName();
+ $config = self::METHODS[$className][$methodName] ?? null;
+
+ if ($config === null) {
+ return $methodReflection->getThrowType();
+ }
+
+ [$roundingModeArgIndex, $residualExceptions] = $config;
+
+ return $this->narrowByRoundingMode(
+ $methodCall,
+ $scope,
+ $methodReflection,
+ $roundingModeArgIndex,
+ $residualExceptions,
+ );
+ }
+
+ /**
+ * toScale() has two independent narrowing axes:
+ * - scale is non-negative → removes {@see InvalidArgumentException}
+ * - rounding mode is not Unnecessary → removes {@see RoundingNecessaryException}
+ */
+ private function narrowToScale(
+ MethodCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ $args = $methodCall->getArgs();
+
+ $scaleIsNonNegative = isset($args[0])
+ && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($scope->getType($args[0]->value))->yes();
+
+ $roundingIsNotUnnecessary = isset($args[1])
+ && self::isNotUnnecessary($scope->getType($args[1]->value));
+
+ if ($scaleIsNonNegative && $roundingIsNotUnnecessary) {
+ return null;
+ }
+
+ $residual = [];
+
+ if (! $scaleIsNonNegative) {
+ $residual[] = new ObjectType(InvalidArgumentException::class);
+ }
+
+ if (! $roundingIsNotUnnecessary) {
+ $residual[] = new ObjectType(RoundingNecessaryException::class);
+ }
+
+ return TypeCombinator::union(...$residual);
+ }
+
+ /** @param list> $residualExceptions */
+ private function narrowByRoundingMode(
+ MethodCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ int $roundingModeArgIndex,
+ array $residualExceptions,
+ ): Type|null {
+ $args = $methodCall->getArgs();
+
+ $roundingIsNotUnnecessary = isset($args[$roundingModeArgIndex])
+ && self::isNotUnnecessary($scope->getType($args[$roundingModeArgIndex]->value));
+
+ // For dividedBy(), MathException is only thrown when parsing the divisor argument.
+ // When the divisor is already the right type (or int), MathException is impossible.
+ $argType = isset($args[0]) ? $scope->getType($args[0]->value) : null;
+ $argIsSafe = $argType !== null && $this->isDivisorArgSafe($methodReflection, $argType);
+
+ // DivisionByZeroException is impossible when the divisor is a non-zero int.
+ $divisorIsNonZero = $argType !== null && self::isNonZero($argType);
+
+ if (! $roundingIsNotUnnecessary && ! $argIsSafe && ! $divisorIsNonZero) {
+ return $methodReflection->getThrowType();
+ }
+
+ $types = [];
+
+ foreach ($residualExceptions as $exceptionClass) {
+ if ($argIsSafe && $exceptionClass === MathException::class) {
+ continue;
+ }
+
+ if ($roundingIsNotUnnecessary && $exceptionClass === RoundingNecessaryException::class) {
+ continue;
+ }
+
+ if ($divisorIsNonZero && $exceptionClass === DivisionByZeroException::class) {
+ continue;
+ }
+
+ $types[] = new ObjectType($exceptionClass);
+ }
+
+ if ($types === []) {
+ return null;
+ }
+
+ return TypeCombinator::union(...$types);
+ }
+
+ /**
+ * Checks if the divisor argument is already the correct type (no parsing needed).
+ */
+ private function isDivisorArgSafe(MethodReflection $methodReflection, Type $argType): bool
+ {
+ $className = $methodReflection->getDeclaringClass()->getName();
+
+ $safeType = match ($className) {
+ BigInteger::class => TypeCombinator::union(new IntegerType(), new ObjectType(BigInteger::class)),
+ BigDecimal::class => TypeCombinator::union(
+ new IntegerType(),
+ new ObjectType(BigInteger::class),
+ new ObjectType(BigDecimal::class),
+ ),
+ default => null,
+ };
+
+ return $safeType !== null && $safeType->isSuperTypeOf($argType)->yes();
+ }
+
+ /**
+ * Checks if the type is guaranteed to be non-zero (excludes {@see DivisionByZeroException}).
+ *
+ * Non-zero int ranges and {@see BigNumber} instances (whose zeroness can't be statically proven) are considered non-zero.
+ * Only pure int types that include zero are treated as potentially zero.
+ */
+ private static function isNonZero(Type $type): bool
+ {
+ $zeroType = new ConstantIntegerType(0);
+
+ return $zeroType->isSuperTypeOf($type)->no();
+ }
+
+ private static function isNotUnnecessary(Type $roundingModeType): bool
+ {
+ $unnecessaryType = new EnumCaseObjectType(RoundingMode::class, 'Unnecessary');
+
+ return $unnecessaryType->isSuperTypeOf($roundingModeType)->no();
+ }
+}
diff --git a/money/composer.json b/money/composer.json
new file mode 100644
index 0000000..9efffee
--- /dev/null
+++ b/money/composer.json
@@ -0,0 +1,31 @@
+{
+ "name": "simpod/phpstan-brick-money",
+ "description": "PHPStan dynamic throw type extensions for brick/money",
+ "license": "MIT",
+ "type": "phpstan-extension",
+ "require": {
+ "php": "^8.4",
+ "brick/money": "^0.12",
+ "phpstan/phpstan": "^2.1"
+ },
+ "require-dev": {
+ "cdn77/coding-standard": "^7.4"
+ },
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Brick\\Money\\PHPStan\\": "src/"
+ }
+ }
+}
diff --git a/money/extension.neon b/money/extension.neon
new file mode 100644
index 0000000..eb50626
--- /dev/null
+++ b/money/extension.neon
@@ -0,0 +1,25 @@
+services:
+ -
+ class: Brick\Money\PHPStan\MoneyFactoryThrowTypeExtension
+ tags:
+ - phpstan.dynamicStaticMethodThrowTypeExtension
+
+ -
+ class: Brick\Money\PHPStan\MoneyFactoryRoundingModeThrowTypeExtension
+ tags:
+ - phpstan.dynamicStaticMethodThrowTypeExtension
+
+ -
+ class: Brick\Money\PHPStan\MoneyOperationThrowTypeExtension
+ tags:
+ - phpstan.dynamicMethodThrowTypeExtension
+
+ -
+ class: Brick\Money\PHPStan\MoneyRoundingModeThrowTypeExtension
+ tags:
+ - phpstan.dynamicMethodThrowTypeExtension
+
+ -
+ class: Brick\Money\PHPStan\CurrencyConverterThrowTypeExtension
+ tags:
+ - phpstan.dynamicMethodThrowTypeExtension
diff --git a/money/phpcs.xml.dist b/money/phpcs.xml.dist
new file mode 100644
index 0000000..b4a84c0
--- /dev/null
+++ b/money/phpcs.xml.dist
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ src
+
+
+
diff --git a/money/phpstan.neon b/money/phpstan.neon
new file mode 100644
index 0000000..38b3a3f
--- /dev/null
+++ b/money/phpstan.neon
@@ -0,0 +1,7 @@
+includes:
+ - extension.neon
+
+parameters:
+ level: max
+ paths:
+ - src
diff --git a/money/src/CurrencyConverterThrowTypeExtension.php b/money/src/CurrencyConverterThrowTypeExtension.php
new file mode 100644
index 0000000..dbba78d
--- /dev/null
+++ b/money/src/CurrencyConverterThrowTypeExtension.php
@@ -0,0 +1,74 @@
+getDeclaringClass()->getName();
+ $methodName = $methodReflection->getName();
+
+ return $className === CurrencyConverter::class
+ && ($methodName === 'convert' || $methodName === 'convertToRational');
+ }
+
+ public function getThrowTypeFromMethodCall(
+ MethodReflection $methodReflection,
+ MethodCall $methodCall,
+ Scope $scope,
+ ): Type|null {
+ $args = $methodCall->getArgs();
+
+ if (! isset($args[1])) {
+ return $methodReflection->getThrowType();
+ }
+
+ $methodName = $methodReflection->getName();
+ $currencyType = $scope->getType($args[1]->value);
+ $currencyIsSafe = SafeType::isSafeCurrency($currencyType);
+
+ // Check rounding mode for convert() — arg index 3.
+ $roundingModeIsSafe = false;
+
+ if ($methodName === 'convert' && isset($args[3])) {
+ $roundingModeIsSafe = SafeType::isSafeRoundingMode($scope->getType($args[3]->value));
+ }
+
+ if (! $currencyIsSafe && ! $roundingModeIsSafe) {
+ return $methodReflection->getThrowType();
+ }
+
+ $residualTypes = [new ObjectType(CurrencyConversionException::class)];
+
+ if (! $currencyIsSafe) {
+ $residualTypes[] = new ObjectType(UnknownCurrencyException::class);
+ }
+
+ if ($methodName === 'convert' && ! $roundingModeIsSafe) {
+ $residualTypes[] = new ObjectType(RoundingNecessaryException::class);
+ }
+
+ return TypeCombinator::union(...$residualTypes);
+ }
+}
diff --git a/money/src/MoneyFactoryRoundingModeThrowTypeExtension.php b/money/src/MoneyFactoryRoundingModeThrowTypeExtension.php
new file mode 100644
index 0000000..ec038d0
--- /dev/null
+++ b/money/src/MoneyFactoryRoundingModeThrowTypeExtension.php
@@ -0,0 +1,63 @@
+ rounding mode arg index */
+ private const array METHODS = [
+ 'of' => 3,
+ 'ofMinor' => 3,
+ ];
+
+ public function isStaticMethodSupported(MethodReflection $methodReflection): bool
+ {
+ return $methodReflection->getDeclaringClass()->getName() === Money::class
+ && isset(self::METHODS[$methodReflection->getName()]);
+ }
+
+ public function getThrowTypeFromStaticMethodCall(
+ MethodReflection $methodReflection,
+ StaticCall $methodCall,
+ Scope $scope,
+ ): Type|null {
+ $methodName = $methodReflection->getName();
+ $roundingModeArgIndex = self::METHODS[$methodName];
+
+ $args = $methodCall->getArgs();
+
+ if (! isset($args[$roundingModeArgIndex])) {
+ return $methodReflection->getThrowType();
+ }
+
+ $roundingModeType = $scope->getType($args[$roundingModeArgIndex]->value);
+
+ if (! SafeType::isSafeRoundingMode($roundingModeType)) {
+ return $methodReflection->getThrowType();
+ }
+
+ // Rounding mode is not Unnecessary — RoundingNecessaryException cannot occur.
+ return TypeCombinator::union(
+ new ObjectType(NumberFormatException::class),
+ new ObjectType(UnknownCurrencyException::class),
+ );
+ }
+}
diff --git a/money/src/MoneyFactoryThrowTypeExtension.php b/money/src/MoneyFactoryThrowTypeExtension.php
new file mode 100644
index 0000000..b53ac71
--- /dev/null
+++ b/money/src/MoneyFactoryThrowTypeExtension.php
@@ -0,0 +1,116 @@
+ ['of', 'ofMinor', 'zero'],
+ RationalMoney::class => ['of', 'zero'],
+ ];
+
+ public function isStaticMethodSupported(MethodReflection $methodReflection): bool
+ {
+ $className = $methodReflection->getDeclaringClass()->getName();
+ $methodName = $methodReflection->getName();
+
+ return isset(self::SupportedMethods[$className])
+ && in_array($methodName, self::SupportedMethods[$className], true);
+ }
+
+ public function getThrowTypeFromStaticMethodCall(
+ MethodReflection $methodReflection,
+ StaticCall $methodCall,
+ Scope $scope,
+ ): Type|null {
+ $args = $methodCall->getArgs();
+ $methodName = $methodReflection->getName();
+
+ // zero() only has a currency parameter.
+ if ($methodName === 'zero') {
+ return $this->narrowZero($methodCall, $scope, $methodReflection);
+ }
+
+ if (count($args) < 2) {
+ return $methodReflection->getThrowType();
+ }
+
+ $className = $methodReflection->getDeclaringClass()->getName();
+ $amountType = $scope->getType($args[0]->value);
+ $currencyType = $scope->getType($args[1]->value);
+
+ $amountIsSafe = SafeType::isSafeNumber($amountType);
+ $currencyIsSafe = SafeType::isSafeCurrency($currencyType);
+
+ if (! $amountIsSafe && ! $currencyIsSafe) {
+ return $methodReflection->getThrowType();
+ }
+
+ $residualTypes = [];
+
+ if (! $amountIsSafe) {
+ $residualTypes[] = new ObjectType(NumberFormatException::class);
+ }
+
+ if (! $currencyIsSafe) {
+ $residualTypes[] = new ObjectType(UnknownCurrencyException::class);
+ }
+
+ // Money::of()/ofMinor() can still throw RoundingNecessaryException.
+ if ($className === Money::class) {
+ $residualTypes[] = new ObjectType(RoundingNecessaryException::class);
+ }
+
+ if ($residualTypes === []) {
+ return null;
+ }
+
+ return TypeCombinator::union(...$residualTypes);
+ }
+
+ private function narrowZero(
+ StaticCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ $args = $methodCall->getArgs();
+
+ if (count($args) < 1) {
+ return $methodReflection->getThrowType();
+ }
+
+ $currencyType = $scope->getType($args[0]->value);
+
+ if (SafeType::isSafeCurrency($currencyType)) {
+ return null;
+ }
+
+ return $methodReflection->getThrowType();
+ }
+}
diff --git a/money/src/MoneyOperationThrowTypeExtension.php b/money/src/MoneyOperationThrowTypeExtension.php
new file mode 100644
index 0000000..b5beed4
--- /dev/null
+++ b/money/src/MoneyOperationThrowTypeExtension.php
@@ -0,0 +1,269 @@
+getName();
+ $className = $methodReflection->getDeclaringClass()->getName();
+
+ if (in_array($methodName, self::ComparisonMethods, true)) {
+ return $className === AbstractMoney::class
+ || $methodReflection->getDeclaringClass()->isSubclassOf(AbstractMoney::class);
+ }
+
+ $isAbstractMoney = $className === AbstractMoney::class
+ || $methodReflection->getDeclaringClass()->isSubclassOf(AbstractMoney::class);
+
+ if (in_array($methodName, self::ArithmeticMethods, true)) {
+ return $isAbstractMoney;
+ }
+
+ if ($methodName === 'convertedTo') {
+ return $className === RationalMoney::class;
+ }
+
+ if ($methodName === 'getMoney') {
+ return $className === MoneyBag::class;
+ }
+
+ return false;
+ }
+
+ public function getThrowTypeFromMethodCall(
+ MethodReflection $methodReflection,
+ MethodCall $methodCall,
+ Scope $scope,
+ ): Type|null {
+ if ($methodCall->getArgs() === []) {
+ return $methodReflection->getThrowType();
+ }
+
+ $methodName = $methodReflection->getName();
+
+ if ($methodName === 'convertedTo') {
+ return $this->narrowConvertedTo($methodCall, $scope, $methodReflection);
+ }
+
+ if ($methodName === 'getMoney') {
+ return $this->narrowGetMoney($methodCall, $scope, $methodReflection);
+ }
+
+ if (in_array($methodName, self::ComparisonMethods, true)) {
+ return $this->narrowComparison($methodCall, $scope, $methodReflection);
+ }
+
+ return $this->narrowArithmetic($methodName, $methodCall, $scope, $methodReflection);
+ }
+
+ /**
+ * Narrows comparison methods on {@see AbstractMoney}.
+ *
+ * AbstractMoney arg -> only {@see MoneyMismatchException}; safe number -> no throw.
+ */
+ private function narrowComparison(
+ MethodCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ $argType = $scope->getType($methodCall->getArgs()[0]->value);
+
+ if ((new ObjectType(AbstractMoney::class))->isSuperTypeOf($argType)->yes()) {
+ return new ObjectType(MoneyMismatchException::class);
+ }
+
+ if (SafeType::isSafeNumber($argType)) {
+ return null;
+ }
+
+ return $methodReflection->getThrowType();
+ }
+
+ /**
+ * Narrows arithmetic methods (`plus`, `minus`, `multipliedBy`, `dividedBy`)
+ * on {@see Money} and {@see RationalMoney}.
+ *
+ * Considers both argument type (safe number / {@see AbstractMoney}) and rounding mode.
+ */
+ private function narrowArithmetic(
+ string $methodName,
+ MethodCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ $callerType = $scope->getType($methodCall->var);
+ $isRational = (new ObjectType(RationalMoney::class))->isSuperTypeOf($callerType)->yes();
+
+ $args = $methodCall->getArgs();
+ $argType = $scope->getType($args[0]->value);
+
+ $argIsSafeNumber = SafeType::isSafeNumber($argType);
+ $argIsAbstractMoney = (new ObjectType(AbstractMoney::class))->isSuperTypeOf($argType)->yes();
+ $argIsSafe = $argIsSafeNumber || $argIsAbstractMoney;
+
+ $roundingModeIsSafe = false;
+
+ if (! $isRational && isset($args[1])) {
+ $roundingModeIsSafe = SafeType::isSafeRoundingMode($scope->getType($args[1]->value));
+ }
+
+ if (! $argIsSafe && ! $roundingModeIsSafe) {
+ return $methodReflection->getThrowType();
+ }
+
+ // RationalMoney: no rounding concern. Safe arg -> no throw (except dividedBy zero).
+ if ($isRational) {
+ if ($argIsAbstractMoney) {
+ $residual = [new ObjectType(MoneyMismatchException::class)];
+
+ if ($methodName === 'dividedBy' && ! SafeType::isNonZero($argType)) {
+ $residual[] = new ObjectType(DivisionByZeroException::class);
+ }
+
+ return TypeCombinator::union(...$residual);
+ }
+
+ if ($argIsSafeNumber) {
+ if ($methodName === 'dividedBy' && ! SafeType::isNonZero($argType)) {
+ return new ObjectType(DivisionByZeroException::class);
+ }
+
+ return null;
+ }
+
+ return $methodReflection->getThrowType();
+ }
+
+ // Money: build residual throw types.
+ $residualTypes = [];
+
+ // MoneyMismatchException when arg is AbstractMoney (currency/context mismatch).
+ if ($argIsAbstractMoney) {
+ $residualTypes[] = new ObjectType(MoneyMismatchException::class);
+ }
+
+ // NumberFormatException when arg is not safe (parsing risk).
+ if (! $argIsSafe) {
+ $residualTypes[] = new ObjectType(NumberFormatException::class);
+ }
+
+ // RoundingNecessaryException when rounding mode is not safe.
+ if (! $roundingModeIsSafe) {
+ $residualTypes[] = new ObjectType(RoundingNecessaryException::class);
+ }
+
+ // DivisionByZeroException when divisor could be zero.
+ if ($methodName === 'dividedBy' && ! SafeType::isNonZero($argType)) {
+ $residualTypes[] = new ObjectType(DivisionByZeroException::class);
+ }
+
+ if ($residualTypes === []) {
+ return null;
+ }
+
+ return TypeCombinator::union(...$residualTypes);
+ }
+
+ /**
+ * Narrows {@see MoneyBag::getMoney()} — safe currency eliminates {@see UnknownCurrencyException}.
+ */
+ private function narrowGetMoney(
+ MethodCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ $currencyType = $scope->getType($methodCall->getArgs()[0]->value);
+
+ if (SafeType::isSafeCurrency($currencyType)) {
+ return null;
+ }
+
+ return $methodReflection->getThrowType();
+ }
+
+ /**
+ * Narrows {@see RationalMoney::convertedTo()} based on currency and exchange rate argument types.
+ */
+ private function narrowConvertedTo(
+ MethodCall $methodCall,
+ Scope $scope,
+ MethodReflection $methodReflection,
+ ): Type|null {
+ $args = $methodCall->getArgs();
+
+ if (count($args) < 2) {
+ return $methodReflection->getThrowType();
+ }
+
+ $currencyIsSafe = SafeType::isSafeCurrency($scope->getType($args[0]->value));
+ $rateIsSafe = SafeType::isSafeNumber($scope->getType($args[1]->value));
+
+ if (! $currencyIsSafe && ! $rateIsSafe) {
+ return $methodReflection->getThrowType();
+ }
+
+ $residualTypes = [];
+
+ if (! $currencyIsSafe) {
+ $residualTypes[] = new ObjectType(UnknownCurrencyException::class);
+ }
+
+ if (! $rateIsSafe) {
+ $residualTypes[] = new ObjectType(NumberFormatException::class);
+ }
+
+ if ($residualTypes === []) {
+ return null;
+ }
+
+ return TypeCombinator::union(...$residualTypes);
+ }
+}
diff --git a/money/src/MoneyRoundingModeThrowTypeExtension.php b/money/src/MoneyRoundingModeThrowTypeExtension.php
new file mode 100644
index 0000000..5399586
--- /dev/null
+++ b/money/src/MoneyRoundingModeThrowTypeExtension.php
@@ -0,0 +1,80 @@
+ rounding mode arg index.
+ */
+ private const array METHODS = [
+ 'toContext' => 1,
+ 'convertedTo' => 3,
+ ];
+
+ public function isMethodSupported(MethodReflection $methodReflection): bool
+ {
+ $methodName = $methodReflection->getName();
+ $className = $methodReflection->getDeclaringClass()->getName();
+
+ if (! isset(self::METHODS[$methodName])) {
+ return false;
+ }
+
+ return $className === Money::class
+ || $className === AbstractMoney::class
+ || $methodReflection->getDeclaringClass()->isSubclassOf(AbstractMoney::class);
+ }
+
+ public function getThrowTypeFromMethodCall(
+ MethodReflection $methodReflection,
+ MethodCall $methodCall,
+ Scope $scope,
+ ): Type|null {
+ $methodName = $methodReflection->getName();
+ $roundingModeArgIndex = self::METHODS[$methodName];
+
+ $args = $methodCall->getArgs();
+
+ if (! isset($args[$roundingModeArgIndex])) {
+ return $methodReflection->getThrowType();
+ }
+
+ $roundingModeType = $scope->getType($args[$roundingModeArgIndex]->value);
+
+ if (! SafeType::isSafeRoundingMode($roundingModeType)) {
+ return $methodReflection->getThrowType();
+ }
+
+ // Rounding mode is not Unnecessary — RoundingNecessaryException cannot occur.
+ if ($methodName === 'toContext') {
+ return null;
+ }
+
+ // convertedTo: UnknownCurrencyException + NumberFormatException remain.
+ return TypeCombinator::union(
+ new ObjectType(UnknownCurrencyException::class),
+ new ObjectType(NumberFormatException::class),
+ );
+ }
+}
diff --git a/money/src/SafeType.php b/money/src/SafeType.php
new file mode 100644
index 0000000..b718cce
--- /dev/null
+++ b/money/src/SafeType.php
@@ -0,0 +1,127 @@
+|null */
+ private static array|null $knownCurrencies = null;
+
+ /**
+ * Returns whether the given type can be safely passed to {@see BigNumber::of()} without risk of parsing exceptions.
+ *
+ * Safe types: `BigNumber` (and subclasses), `int`, `numeric-string`.
+ */
+ public static function isSafeNumber(Type $type): bool
+ {
+ return self::getSafeNumberType()->isSuperTypeOf($type)->yes();
+ }
+
+ /**
+ * Returns whether the given type is a known currency that cannot throw {@see UnknownCurrencyException}.
+ *
+ * This is true when the type is a {@see Currency} instance, or a constant string matching a known ISO currency code.
+ */
+ public static function isSafeCurrency(Type $type): bool
+ {
+ if ((new ObjectType(Currency::class))->isSuperTypeOf($type)->yes()) {
+ return true;
+ }
+
+ $constantStrings = $type->getConstantStrings();
+
+ if ($constantStrings === []) {
+ return false;
+ }
+
+ $knownCurrencies = self::getKnownCurrencies();
+
+ foreach ($constantStrings as $constantString) {
+ if (! isset($knownCurrencies[$constantString->getValue()])) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns whether the given rounding mode type is known to NOT be `Unnecessary`,
+ * meaning {@see RoundingNecessaryException} cannot occur.
+ */
+ public static function isSafeRoundingMode(Type $type): bool
+ {
+ $unnecessaryType = new EnumCaseObjectType(RoundingMode::class, 'Unnecessary');
+
+ return $unnecessaryType->isSuperTypeOf($type)->no();
+ }
+
+ /**
+ * Returns whether the given type is guaranteed to be non-zero.
+ *
+ * This is used to eliminate {@see DivisionByZeroException} from throw types.
+ * Only proven for integer types (e.g. int<1, max>, literal 5).
+ */
+ public static function isNonZero(Type $type): bool
+ {
+ if (! (new IntegerType())->isSuperTypeOf($type)->yes()) {
+ return false;
+ }
+
+ $zeroType = new ConstantIntegerType(0);
+
+ return $zeroType->isSuperTypeOf($type)->no();
+ }
+
+ private static function getSafeNumberType(): Type
+ {
+ return self::$safeNumberType ??= new UnionType([
+ new ObjectType(BigNumber::class),
+ new IntegerType(),
+ new IntersectionType([new StringType(), new AccessoryNumericStringType()]),
+ ]);
+ }
+
+ /** @return array */
+ private static function getKnownCurrencies(): array
+ {
+ if (self::$knownCurrencies === null) {
+ $currencyClassFile = (new ReflectionClass(Currency::class))->getFileName();
+ assert($currencyClassFile !== false);
+
+ /** @var array $data */
+ $data = require dirname($currencyClassFile, 2) . '/data/iso-currencies.php';
+ self::$knownCurrencies = [];
+
+ foreach ($data as $code => $_) {
+ self::$knownCurrencies[$code] = true;
+ }
+ }
+
+ return self::$knownCurrencies;
+ }
+}