Skip to content
This repository was archived by the owner on Jan 15, 2025. It is now read-only.

Commit 7de395c

Browse files
authored
Remove normalization option in constructor in favor of normalizing in toString (#72)
* Specify beginning-of-document * Ensure README matches actual API Also, fix the description of how normalization is handled. * lint * Add constructor and arithmetic options * Specify rounding mode * Use npx to ensure we're using the intended TS rather than an ambient one
1 parent e620e92 commit 7de395c

File tree

4 files changed

+91
-31
lines changed

4 files changed

+91
-31
lines changed

.github/workflows/push.yaml

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
---
12
on: push
23
name: CI
34
jobs:
@@ -21,7 +22,7 @@ jobs:
2122
- name: lint
2223
run: npm run lint
2324
- name: compile typescript
24-
run: tsc
25+
run: npx tsc
2526
- name: test
2627
run: npm run test
2728
- name: generate coverage

README.md

+16-13
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,39 @@
77
- multiplication (`multiply`)
88
- division (`divide`)
99
- remainder (`remainder`)
10-
- absolute value (`abs`)
10+
- rounding (`round`)
1111
- `toString`
12-
- `toExponential`
1312

1413
## Implementation
1514

16-
This package is written in TypeScript. Unit tests are in (typed) Jest. There are no other external dependencies.
15+
This package is written in TypeScript. Unit tests are in Jest. There are no other external dependencies.
1716

1817
## Data model
1918

2019
This package aims to reproduce the IEEE 754 [Decimal128](https://en.wikipedia.org/wiki/Decimal128_floating-point_format) decimal floating-point numbers in JavaScript. These **decimal** (not binary!) numbers take up 128 bits of information per number. This format allows for an exact representation of decimal numbers with 34 (decimal) significant digits and an exponent between -6143 and 6144. That's a _vast_ amount of range and precision! Decimal128 is a fantastic standard. Let's implement it in JavaScript.
2120

22-
This package also supports minus zero, positive and negative infinity, and NaN.
23-
24-
This package aims to work with only _normalized_ values in the Decimal128 universe. With this package, there is no way to represent, say, `1.2` and `1.20` as distinct values. Digit strings are normalized right away, so `1.20` becomes `1.2`.
21+
This package also supports minus zero, positive and negative infinity, and NaN. These values are distinct from JS's built-in `-0`, `Infinity`, `-Infinity`, and `NaN`, since those are all JS Numbers.
2522

2623
### Differences with the official Decimal128
2724

28-
This package is not literally an implementation of Decimal128. In time, it may _become_ one, but initially, this package is working with a subspace of Decimal128 that makes sense for the use cases we have in mind (mainly, finance).
25+
This package is not literally an implementation of Decimal128. This package is working with a subset of Decimal128 that makes sense for the use cases we have in mind (mainly, though not exclusively, finance). Only a handful of arithmetic operations are implemented. We do not offer, for instance, the various trigonometric functions.
2926

3027
#### Lack of support for specifying context
3128

32-
IEEE 754 Decimal128 allows one to globally specify configuration values (e.g., precision) that control all mathematical operations on Decimal128 values. This JavaScript package does not support that. Calculations are always done using all available digits.
29+
IEEE 754 Decimal128 allows one to globally specify configuration values (e.g., precision) that control all mathematical operations on Decimal128 values. This JavaScript package does not support that. This package offers a purely functional subset of Decimal128; there's no ambient context to specify and set. If one wishes to control, e.g., rounding, then one needs to specify that when constructing Decimal128 values or doing arithmetic operations.
30+
31+
Think of this package as providing, basically, arbitrary-precision decimal numbers limited to those that fit into 128 bits the way that Decimal128 does it. No need to specify context. Just imagine that you're working in an ideal arbitrary-precision world, do the operation, and enjoy the results. If you need to cut off a calculation after a certain point, just perform the operation (e.g., addition) and use `round`.
3332

34-
Think of this package as providing, basically, arbitrary-precision decimal numbers limited to those that fit into 128 bits the way that Decimal128 does it. No need to specify context. Just imagine that you're working in an ideal arbitrary-precision world, do the operation, and enjoy the results. If you need to cut off a calculation after a certain point, just perform the operation (e.g., addition) and then use `toDecimalDigits` on the result.
33+
#### Serialized values normalized by default
3534

36-
#### Values always normalized
35+
Decimal128 is a universe of **unnormalized** values. In the Decimal128 world, `1.2` and `1.20` are _distinct_ values. There's good reason for adopting such an approach, and has some benefits. But there can be surprises when working with non-normal values. This package supports IEEE 754 Decimal128, but it also aims to minimize surprises. In IEEE 754 Decimal128, if one adds, say, 1.2 and 3.8, the result is 5.0, not 5. (Again, those are _distinct_ values in IEEE 754 Decimal128.) Reproducing that example with this package, one has
3736

38-
Decimal128 is a universe of **unnormalized** values. In the Decimal128 world, `1.2` and `1.20` are _distinct_ values. There's good reason for adopting such an approach, and some benefits. But this package deliberately works in a world of _normalized_ values. Given the string `1.20`, this package will turn that into `1.2`; that extra trailing zero will be lost. To recover the string `1.20`, additional, out-of-band information needs to be supplied. For instance: if you're working with numbers as financial quantities, you know, out-of-band, how to interpret your numbers. Thus, if I tell you that the cost of something is `1.2` USD, you know that means, and you know that, if you need to present that data to someone, you'd add an extra digit there. This package provides a `toDecimalDigits` method that allows you to generate `1.20` from the underlying `1.2`.
37+
```javascript
38+
new Decimal128("1.2").add(new Decimal128("3.8")).toString(); // "5"
39+
```
3940

40-
#### Missing operations
41+
One can switch off normalization by setting the `normalize` option to `false` in `toString`, like this:
4142

42-
This package focuses on the bread and butter of arithmetic: addition, multiplication, subtraction, and division. To round things out from there (ha!), we have the absolute value function, trunction, floor/ceiling.
43+
```javascript
44+
new Decimal128("1.2").add(new Decimal128("3.8")).toString({ normalize: false }); // "5.0"
45+
```

src/decimal128.mts

+63-17
Original file line numberDiff line numberDiff line change
@@ -865,10 +865,24 @@ interface FullySpecifiedConstructorOptions {
865865
normalize: boolean;
866866
}
867867

868-
const DEFAULT_CONSTRUCTOR_OPTIONS: FullySpecifiedConstructorOptions = {
869-
roundingMode: ROUNDING_MODE_DEFAULT,
870-
normalize: CONSTRUCTOR_SHOULD_NORMALIZE,
871-
};
868+
const DEFAULT_CONSTRUCTOR_OPTIONS: FullySpecifiedConstructorOptions =
869+
Object.freeze({
870+
roundingMode: ROUNDING_MODE_DEFAULT,
871+
normalize: CONSTRUCTOR_SHOULD_NORMALIZE,
872+
});
873+
874+
interface ArithmeticOperationOptions {
875+
roundingMode?: RoundingMode;
876+
}
877+
878+
interface FullySpecifiedArithmeticOperationOptions {
879+
roundingMode: RoundingMode;
880+
}
881+
882+
const DEFAULT_ARITHMETIC_OPERATION_OPTIONS: FullySpecifiedArithmeticOperationOptions =
883+
Object.freeze({
884+
roundingMode: ROUNDING_MODE_DEFAULT,
885+
});
872886

873887
type ToStringFormat = "decimal" | "exponential";
874888
const TOSTRING_FORMATS: string[] = ["decimal", "exponential"];
@@ -883,10 +897,10 @@ interface FullySpecifiedToStringOptions {
883897
numDecimalDigits: number | undefined;
884898
}
885899

886-
const DEFAULT_TOSTRING_OPTIONS: FullySpecifiedToStringOptions = {
900+
const DEFAULT_TOSTRING_OPTIONS: FullySpecifiedToStringOptions = Object.freeze({
887901
format: "decimal",
888902
numDecimalDigits: undefined,
889-
};
903+
});
890904

891905
function ensureFullySpecifiedConstructorOptions(
892906
options?: ConstructorOptions
@@ -911,6 +925,25 @@ function ensureFullySpecifiedConstructorOptions(
911925
return opts;
912926
}
913927

928+
function ensureFullySpecifiedArithmeticOperationOptions(
929+
options?: ArithmeticOperationOptions
930+
): FullySpecifiedArithmeticOperationOptions {
931+
let opts = { ...DEFAULT_ARITHMETIC_OPERATION_OPTIONS };
932+
933+
if (undefined === options) {
934+
return opts;
935+
}
936+
937+
if (
938+
"string" === typeof options.roundingMode &&
939+
ROUNDING_MODES.includes(options.roundingMode)
940+
) {
941+
opts.roundingMode = options.roundingMode;
942+
}
943+
944+
return opts;
945+
}
946+
914947
function ensureFullySpecifiedToStringOptions(
915948
options?: ToStringOptions
916949
): FullySpecifiedToStringOptions {
@@ -1144,8 +1177,9 @@ export class Decimal128 {
11441177
* Add this Decimal128 value to one or more Decimal128 values.
11451178
*
11461179
* @param x
1180+
* @param opts
11471181
*/
1148-
add(x: Decimal128): Decimal128 {
1182+
add(x: Decimal128, opts?: ArithmeticOperationOptions): Decimal128 {
11491183
if (this.isNaN() || x.isNaN()) {
11501184
return new Decimal128(NAN);
11511185
}
@@ -1167,12 +1201,14 @@ export class Decimal128 {
11671201
}
11681202

11691203
if (this.isNegative && x.isNegative) {
1170-
return this.negate().add(x.negate()).negate();
1204+
return this.negate().add(x.negate(), opts).negate();
11711205
}
11721206

11731207
let resultRat = Rational.add(this.rat, x.rat);
1208+
let options = ensureFullySpecifiedArithmeticOperationOptions(opts);
11741209
let initialResult = new Decimal128(
1175-
resultRat.toDecimalPlaces(MAX_SIGNIFICANT_DIGITS + 1)
1210+
resultRat.toDecimalPlaces(MAX_SIGNIFICANT_DIGITS + 1),
1211+
{ roundingMode: options.roundingMode }
11761212
);
11771213
let adjusted = initialResult.setExponent(
11781214
Math.min(this.exponent, x.exponent)
@@ -1185,8 +1221,9 @@ export class Decimal128 {
11851221
* Subtract another Decimal128 value from one or more Decimal128 values.
11861222
*
11871223
* @param x
1224+
* @param opts
11881225
*/
1189-
subtract(x: Decimal128): Decimal128 {
1226+
subtract(x: Decimal128, opts?: ArithmeticOperationOptions): Decimal128 {
11901227
if (this.isNaN() || x.isNaN()) {
11911228
return new Decimal128(NAN);
11921229
}
@@ -1215,7 +1252,9 @@ export class Decimal128 {
12151252
MAX_SIGNIFICANT_DIGITS + 1
12161253
);
12171254

1218-
let initialResult = new Decimal128(rendered);
1255+
let options = ensureFullySpecifiedArithmeticOperationOptions(opts);
1256+
1257+
let initialResult = new Decimal128(rendered, options);
12191258
let adjusted = initialResult.setExponent(
12201259
Math.min(this.exponent, x.exponent)
12211260
);
@@ -1228,8 +1267,9 @@ export class Decimal128 {
12281267
* If no arguments are given, return this value.
12291268
*
12301269
* @param x
1270+
* @param opts
12311271
*/
1232-
multiply(x: Decimal128): Decimal128 {
1272+
multiply(x: Decimal128, opts?: ArithmeticOperationOptions): Decimal128 {
12331273
if (this.isNaN() || x.isNaN()) {
12341274
return new Decimal128(NAN);
12351275
}
@@ -1268,7 +1308,8 @@ export class Decimal128 {
12681308

12691309
let resultRat = Rational.multiply(this.rat, x.rat);
12701310
let initialResult = new Decimal128(
1271-
resultRat.toDecimalPlaces(MAX_SIGNIFICANT_DIGITS + 1)
1311+
resultRat.toDecimalPlaces(MAX_SIGNIFICANT_DIGITS + 1),
1312+
ensureFullySpecifiedArithmeticOperationOptions(opts)
12721313
);
12731314
let adjusted = initialResult.setExponent(this.exponent + x.exponent);
12741315

@@ -1291,8 +1332,9 @@ export class Decimal128 {
12911332
* If only one argument is given, just return the first argument.
12921333
*
12931334
* @param x
1335+
* @param opts
12941336
*/
1295-
divide(x: Decimal128): Decimal128 {
1337+
divide(x: Decimal128, opts?: ArithmeticOperationOptions): Decimal128 {
12961338
if (this.isNaN() || x.isNaN()) {
12971339
return new Decimal128(NAN);
12981340
}
@@ -1375,7 +1417,10 @@ export class Decimal128 {
13751417
}
13761418

13771419
let resultExponent = this.exponent - (x.exponent + adjust);
1378-
return new Decimal128(`${resultCoefficient}E${resultExponent}`);
1420+
return new Decimal128(
1421+
`${resultCoefficient}E${resultExponent}`,
1422+
ensureFullySpecifiedArithmeticOperationOptions(opts)
1423+
);
13791424
}
13801425

13811426
/**
@@ -1453,9 +1498,10 @@ export class Decimal128 {
14531498
* Return the remainder of this Decimal128 value divided by another Decimal128 value.
14541499
*
14551500
* @param d
1501+
* @param opts
14561502
* @throws RangeError If argument is zero
14571503
*/
1458-
remainder(d: Decimal128): Decimal128 {
1504+
remainder(d: Decimal128, opts?: ArithmeticOperationOptions): Decimal128 {
14591505
if (this.isNaN() || d.isNaN()) {
14601506
return new Decimal128(NAN);
14611507
}
@@ -1481,7 +1527,7 @@ export class Decimal128 {
14811527
}
14821528

14831529
let q = this.divide(d).round(0, ROUNDING_MODE_TRUNCATE);
1484-
return this.subtract(d.multiply(q));
1530+
return this.subtract(d.multiply(q), opts);
14851531
}
14861532

14871533
normalize(): Decimal128 {

tests/add.test.js

+10
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,16 @@ describe("addition", () => {
117117
});
118118
});
119119

120+
describe("specify rounding mode", () => {
121+
test("truncate rounding mode", () => {
122+
expect(
123+
new Decimal128("1234567890123456789012345678901234")
124+
.add(new Decimal128("0.5"), { roundingMode: "trunc" })
125+
.toString()
126+
).toStrictEqual("1234567890123456789012345678901234");
127+
});
128+
});
129+
120130
describe("examples from the General Decimal Arithmetic specification", () => {
121131
test("example one", () => {
122132
expect(

0 commit comments

Comments
 (0)