Mutate receiver for binary arithmetic and add BigNumber.js-compatible instance methods#2
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates N’s arithmetic API to reduce allocations by mutating the receiver for binary operations, and expands the N prototype surface with a set of BigNumber.js-style instance methods and formatting/comparison helpers.
Changes:
- Switch
plus,minus,mul, anddivto in-place mutation semantics (thisis updated and returned). - Add BigNumber.js-style arithmetic aliases and numeric helpers (e.g.,
dividedBy,dividedToIntegerBy,abs,modulo,exponentiatedBy,shiftedBy). - Add formatting/conversion helpers and comparison/boolean predicates (e.g.,
toFixed,toPrecision,toFormat,comparedTo,isZero).
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| precision(includeZeros: boolean = false): number { | ||
| if (this.value === 0n) { | ||
| return 1 | ||
| } | ||
| const raw = this.toString(this.decimals).replace('-', '').replace('.', '') | ||
| const trimmedLeading = raw.replace(/^0+/, '') | ||
| const trimmed = includeZeros ? trimmedLeading : trimmedLeading.replace(/0+$/, '') | ||
| return trimmed.length || 1 | ||
| } |
There was a problem hiding this comment.
precision() method name conflicts with the existing public instance property precision (declared earlier in this class). This will fail TypeScript compilation (duplicate identifier) and/or make n.precision() unusable at runtime because the instance field shadows the prototype method. Rename one of them (e.g., keep precision() for BigNumber compatibility and rename the field to internalPrecision/calcPrecision, updating all internal references and tests), or choose a different method name/alias for significant digits.
|
|
||
| /** Exponentiation by integer exponent. */ | ||
| exponentiatedBy(Exponent: NLike): N { | ||
| const exponent = BigInt(this.nfy(Exponent).valueOf()) |
There was a problem hiding this comment.
exponentiatedBy claims to support an integer exponent, but it converts the input via valueOf(), which rounds according to the global rounding mode. That means non-integer inputs (e.g., 1.5) will be silently rounded and used as an exponent instead of being rejected. Consider validating that the exponent has no fractional part (e.g., check toPrecise() % factor === 0n) and throw a clear error when it is not an integer.
| const exponent = BigInt(this.nfy(Exponent).valueOf()) | |
| const exponentN = this.nfy(Exponent) | |
| const preciseExponent = exponentN.toPrecise() | |
| if (preciseExponent % this.factor !== 0n) { | |
| throw new Error('Exponent must be an integer.') | |
| } | |
| const exponent = preciseExponent / this.factor |
| shiftedBy(shift: number): N { | ||
| if (shift === 0) { | ||
| return this | ||
| } | ||
| const factor = 10n ** BigInt(Math.abs(shift)) | ||
| this.value = shift > 0 ? this.value * factor : this.value / factor | ||
| return this |
There was a problem hiding this comment.
shiftedBy uses BigInt(Math.abs(shift)), which will throw a RangeError for non-integer/NaN/Infinity inputs (TypeScript type is just number). If the API is intended to accept only integer shifts (as in BigNumber.js), validate Number.isFinite(shift) and Number.isInteger(shift) and throw a consistent, library-specific error message.
| const num = Number(this.toString(this.decimals)) | ||
| return dp === undefined ? num.toExponential() : num.toExponential(dp) | ||
| } | ||
|
|
||
| /** Returns a string with a specified precision. */ | ||
| toPrecision(precision?: number): string { | ||
| const num = Number(this.toString(this.decimals)) | ||
| return precision === undefined ? num.toPrecision() : num.toPrecision(precision) |
There was a problem hiding this comment.
toExponential/toPrecision are implemented by converting to a JS Number, which will overflow to Infinity and/or lose precision for large values—defeating the purpose of an arbitrary-precision type and diverging from BigNumber.js behavior. Consider implementing these using string/BigInt-based formatting (or explicitly documenting/guarding the precision limits if approximation is intended).
| const num = Number(this.toString(this.decimals)) | |
| return dp === undefined ? num.toExponential() : num.toExponential(dp) | |
| } | |
| /** Returns a string with a specified precision. */ | |
| toPrecision(precision?: number): string { | |
| const num = Number(this.toString(this.decimals)) | |
| return precision === undefined ? num.toPrecision() : num.toPrecision(precision) | |
| // Work entirely in decimal-string space to avoid precision loss. | |
| const full = this.toString(this.decimals) | |
| // Extract sign. | |
| let sign = '' | |
| let s = full | |
| if (s[0] === '-') { | |
| sign = '-' | |
| s = s.slice(1) | |
| } | |
| let [intPart, decPart = ''] = s.split('.') | |
| const intPartNoLeading = intPart.replace(/^0+/, '') | |
| let digits: string | |
| let exponent: number | |
| if (intPartNoLeading.length > 0) { | |
| // Non-zero integer part: exponent is index of first significant digit in integer part. | |
| exponent = intPartNoLeading.length - 1 | |
| digits = (intPartNoLeading + decPart) | |
| } else { | |
| // Integer part is zero or all zeros: look into decimal part for first non-zero. | |
| const match = decPart.match(/[^0]/) | |
| if (!match) { | |
| // Value is exactly zero. | |
| const fracZeros = dp !== undefined && dp > 0 ? '0'.repeat(dp) : '' | |
| const mantissa = dp !== undefined && dp > 0 ? `0.${fracZeros}` : '0' | |
| return `${sign}${mantissa}e+0` | |
| } | |
| const firstNonZeroIndex = match.index as number | |
| exponent = -firstNonZeroIndex - 1 | |
| digits = decPart.slice(firstNonZeroIndex) | |
| } | |
| // Now `digits` holds all significant digits with no leading zeros, value != 0. | |
| // Determine how many fraction digits to show. | |
| const fracDigits = dp === undefined ? Math.max(digits.length - 1, 0) : dp | |
| // Ensure we have enough digits for rounding. | |
| const required = fracDigits + 1 // 1 digit before decimal + fracDigits | |
| if (digits.length < required + 1) { | |
| digits = digits.padEnd(required + 1, '0') | |
| } | |
| // Round to `required` significant digits (1 + fracDigits). | |
| let rounded = digits.slice(0, required).split('').map(ch => parseInt(ch, 10)) | |
| const nextDigit = digits[required] ? parseInt(digits[required], 10) : 0 | |
| if (nextDigit >= 5) { | |
| // Add 1 with carry. | |
| for (let i = rounded.length - 1; i >= 0; i--) { | |
| const sum = rounded[i] + 1 | |
| if (sum === 10) { | |
| rounded[i] = 0 | |
| if (i === 0) { | |
| // Carry out of most significant digit increases exponent. | |
| rounded.unshift(1) | |
| exponent += 1 | |
| break | |
| } | |
| } else { | |
| rounded[i] = sum | |
| break | |
| } | |
| } | |
| } | |
| const roundedStr = rounded.join('') | |
| const intDigit = roundedStr[0] | |
| const fracPart = roundedStr.slice(1) | |
| // Normalize exponent sign and format. | |
| const expSign = exponent >= 0 ? '+' : '-' | |
| const expAbs = Math.abs(exponent).toString() | |
| if (fracDigits > 0) { | |
| const frac = fracPart.padEnd(fracDigits, '0').slice(0, fracDigits) | |
| return `${sign}${intDigit}.${frac}e${expSign}${expAbs}` | |
| } else { | |
| return `${sign}${intDigit}e${expSign}${expAbs}` | |
| } | |
| } | |
| /** Returns a string with a specified precision. */ | |
| toPrecision(precision?: number): string { | |
| // When no precision is given, behave similarly to Number#toPrecision(undefined): | |
| // use the default full fixed-point representation. | |
| if (precision === undefined) { | |
| return this.toString(this.decimals) | |
| } | |
| // Precision must be a positive integer, but we keep validation minimal to | |
| // avoid changing the public API surface too much. | |
| if (!Number.isFinite(precision) || precision < 1 || !Number.isInteger(precision)) { | |
| throw new RangeError('toPrecision() precision must be a positive integer') | |
| } | |
| // First, get an exponential representation with the desired significant digits. | |
| const expStr = this.toExponential(precision - 1) | |
| // Parse exponential string: [-]d[.ddd]e[+/-]dd | |
| const expMatch = expStr.match(/^(-)?(\d)(?:\.(\d+))?e([+\-]\d+)$/) | |
| if (!expMatch) { | |
| // Fallback: if parsing fails for any reason, return the exponential form. | |
| return expStr | |
| } | |
| const [, signMatch, intDigitStr, fracDigitsStr = '', exponentStr] = expMatch | |
| const sign = signMatch ? '-' : '' | |
| const exponent = parseInt(exponentStr, 10) | |
| const mantissaDigits = intDigitStr + fracDigitsStr // already rounded to `precision` digits | |
| // Decide between fixed and exponential form: | |
| // Mirror typical JS / BigNumber behavior: | |
| // - use exponential if exponent < -6 or exponent >= precision | |
| // - otherwise, fixed-point. | |
| if (exponent < -6 || exponent >= precision) { | |
| return expStr | |
| } | |
| // Convert mantissa+exponent to fixed-point decimal. | |
| if (exponent >= 0) { | |
| // Decimal point moves to the right of the first digit by `exponent`. | |
| const intLen = exponent + 1 | |
| let digits = mantissaDigits | |
| if (digits.length < intLen) { | |
| digits = digits.padEnd(intLen, '0') | |
| } | |
| const intPart = digits.slice(0, intLen) | |
| const fracPart = digits.slice(intLen) | |
| if (fracPart.length === 0) { | |
| return `${sign}${intPart}` | |
| } | |
| return `${sign}${intPart}.${fracPart}` | |
| } else { | |
| // Negative exponent: value is between 0 and 1. | |
| // Decimal point moves left of the first digit. | |
| const zeros = '0'.repeat(-exponent - 1) | |
| const fracPart = zeros + mantissaDigits | |
| return `${sign}0.${fracPart}` | |
| } |
| plus(Addend: NLike): N { | ||
| const Augend: N = this.clone() | ||
| const augend: bigint = Augend.value | ||
| const augend: bigint = this.value | ||
| const addend: bigint = this.rebase(Addend) | ||
| Augend.value = augend + addend | ||
| return Augend | ||
| this.value = augend + addend | ||
| return this |
There was a problem hiding this comment.
The arithmetic methods now mutate the receiver (plus/minus/mul/div), and many new BigNumber-style instance methods were added, but there are no unit tests covering the new in-place semantics or the new helpers (e.g., dividedToIntegerBy, modulo, exponentiatedBy, shiftedBy, formatting helpers). Adding targeted tests would help prevent silent behavior regressions and confirm BigNumber-compat expectations.
Motivation
plus,minus,mul, anddivsoA.plus(B)updatesArather than returning a fresh clone.Description
plus,minus,mul, anddivnow mutatethisand return it.dividedBy,dividedToIntegerBy,times,multipliedBy(existing alias preserved),squareRoot,abs/absoluteValue,negated,mod/modulo,exponentiatedBy, andshiftedBy.decimalPlaces,precision,toNumber,toFixed,toExponential,toPrecision,toFormat, andtoFraction.comparedTo,isEqualTo,isGreaterThan,isLessThanOrEqualTo,isZero,isPositive,isNegative,isInteger,isBigNumber,isFinite, andisNaN.Testing
src/N.test.tswhich can be run with the project test runner (e.g.npm testoryarn test) and are recommended to validate behavior after the change.Codex Task