3

I have an precision issue when dealing with currency input using Decimal type. The issue is with the formatter. This is the minimum reproducible code in playground:

let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.isLenient = true
formatter.maximumFractionDigits = 2
formatter.generatesDecimalNumbers = true

let text = "89806.9"
let decimal = formatter.number(from: text)?.decimalValue ?? .zero
let string = "\(decimal)"
print(string)

It prints out 89806.89999999999 instead of 89806.9. However, most other numbers are fine (e.g. 8980.9). So I don't think this is a Double vs Decimal problem.

Edit:

The reason I need to use the formatter is that sometimes I need to deal with currency format input:

let text = "$89,806.9"
let decimal = formatter.number(from: text)?.decimalValue ?? .zero
print("\(decimal)") // prints 89806.89999999999

let text2 = "$89,806.9"
let decimal2 = Decimal(string: text2)
print("\(decimal2)") // prints nil

OMGPOP
  • 1,995
  • 8
  • 52
  • 95
  • @Sulthan interesting. did you try macos cmd line project? i run it in ios playground proj – OMGPOP Feb 22 '22 at 21:36
  • What do you wish to achieve with the formatter? What do you expect to get from `89806.9`? `89,806.90`? – trndjc Feb 22 '22 at 21:39
  • It seems the parsed decimal is correct but the `description` probably converts the value to `Double`. – Sulthan Feb 22 '22 at 21:39
  • I expect to get the decimal number without lose of precision – OMGPOP Feb 22 '22 at 21:41
  • @Sulthan The parsed decimal is also wrong. Check `decimal.magnitude`. It's 89806.89999999999. – Rob Napier Feb 22 '22 at 21:41
  • @RobNapier You are right. It surprises me that using `formatter` to convert the value to a String ends with the correct result. – Sulthan Feb 22 '22 at 21:43
  • Yeah. Definitely feels like a Foundation bug to me. – Rob Napier Feb 22 '22 at 21:44
  • @OMGPOP better to create a custom currency field. It would work for any locale / currency. It will also automatically set the number of fractional digits. https://stackoverflow.com/a/29783546/2303865 – Leo Dabus Feb 22 '22 at 21:47
  • @LeoDabus thanks for the suggestion. Though I still want to have a solution for this problem because there will be other non-currency decimal input fields in my app. – OMGPOP Feb 22 '22 at 21:48
  • You can use a fixed locale or you can use the current locale. Anyway you can customize it to your needs – Leo Dabus Feb 22 '22 at 21:49
  • @LeoDabus could you elaborate more? feel free to answer with a working solution – OMGPOP Feb 22 '22 at 21:50
  • You can subclass textfield with different settings for the fields that are not a currency. The link I have posted should be enough to guide you through the process – Leo Dabus Feb 22 '22 at 21:51
  • @LeoDabus looks like what you did there is to manually parse the string to get all the digits `var digits: Self { filter (\.isWholeNumber) }` – OMGPOP Feb 22 '22 at 21:56
  • I'd prefer not to manual parse it, because it's tedious to deal with bad formats (e.g. `12..45`, `..45` are invalid but `.45`, `45.` are valid. – OMGPOP Feb 22 '22 at 21:58
  • @OMGPOP It's actually pretty simple using a regular expression. – Sulthan Feb 22 '22 at 22:02
  • that's was interesting bug that I ever seen, I checked formatter.number(from: text)?.doubleValue and floatValue there wasn't problem for them and also I represent $0.1, did you check that? – Reza Khonsari Feb 22 '22 at 22:07
  • @OMGPOP there is no way for the user to input more than one period. It will automatically discard them before formatting it again – Leo Dabus Feb 22 '22 at 22:11
  • @OMGPOP there is a sample project there as well that you can test it’s behavior – Leo Dabus Feb 22 '22 at 22:12
  • @RezaKhonsari after converting to double, I got `89806.89999999997952` – OMGPOP Feb 22 '22 at 22:15
  • @OMGPOP you should remove this line formatter.generatesDecimalNumbers = true to work properly – Reza Khonsari Feb 22 '22 at 22:17
  • let me add it as answer maybe for someone it will help – Reza Khonsari Feb 22 '22 at 22:18

5 Answers5

5

Using the new FormatStyle seems to generate the correct result

let format = Decimal.FormatStyle
    .number
    .precision(.fractionLength(0...2))


let text = "89806.9"
let value = try! format.parseStrategy.parse(text)

Below is an example parsing a currency using the currency code from the locale

let currencyFormat = Decimal.FormatStyle.Currency
    .currency(code: Locale.current.currencyCode!)
    .precision(.fractionLength(0...2))

let amount = try! currencyFormat.parseStrategy.parse(text)

Swedish example:

let text = "89806,9 kr"
print(amount)

89806.9

Another option is to use the new init for Decimal that takes a String and a FormatStyle.Currency (or a Number or Percent)

let amount = try Decimal(text, format: currencyFormat)

and to format this value we can use formatted(_:) on Decimal

print(amount.formatted(currencyFormat))

Output (still Swedish):

89 806,9 kr

Joakim Danielson
  • 43,251
  • 5
  • 22
  • 52
  • This is much closer than I expected, but I'm having trouble adding the `.currency(...)` goal. Do you know how to include that so this can include things like `$`? – Rob Napier Feb 22 '22 at 22:07
  • I also need to support `$89,806.9` though. I feel it's only possible with a currency formatter – OMGPOP Feb 22 '22 at 22:07
  • Where does the currency come from, the locale or...? – Joakim Danielson Feb 22 '22 at 22:18
  • Oh i didn't put in currency yet, so it's default to be the phone setting i believe – OMGPOP Feb 22 '22 at 22:24
  • This really feels like the whole answer (or at least the majority of the answer), but (after a decade and a half of Cocoa development) I don't understand the syntax, even looking at the docs. Can you point us to how to understand this new tool? – Rob Napier Feb 22 '22 at 22:35
  • 1
    @RobNapier I don't have a "single source of documentation" to paraphrase Apple that made everything clear but instead it has been an iterative process of of reading documentation, testing in playgrounds and having some real use cases in the app I am working on. The documentation is mainly [Decimal.FormatStyle](https://developer.apple.com/documentation/foundation/decimal/formatstyle), the new init and formatted functions in [Decimal](https://developer.apple.com/documentation/foundation/decimal) (scroll down almost to the end) – Joakim Danielson Feb 23 '22 at 08:33
  • 1
    _continued_ and to lesser content some SwiftUI documentation like for [TextField](https://developer.apple.com/documentation/swiftui/textfield) but I ended up not using formatting that way. I also did this with Date and Date.FormatStyle. Sorry that I can't present a silver bullet here but the truth is that it has been a crocked path and I am still not at a position where I feel I fully understand this myself. – Joakim Danielson Feb 23 '22 at 08:39
1

I agree that this is a surprising bug, and I would open an Apple Feedback about it, but I would also highly recommend switching to Decimal(string:locale:) rather than a formatter, which will achieve your goal (except perhaps the isLenient part).

let x = Decimal(string: text)!
print("\(x)") // 89806.9

If you want to fix fraction digits, you can apply rounding pretty easily with * 100 / 100 conversions through Int. (I'll explain if it's not obvious how to do this; it works for Decimal, though not Double.)

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • But sometimes I need to deal with currency format. I added in the question – OMGPOP Feb 22 '22 at 21:45
  • I suspect you can't fix this directly. I believe Foundation has bugs that are breaking you, and you're going to have to implement those parsing issues by hand. I agree that's bad and unexpected. – Rob Napier Feb 22 '22 at 21:47
  • Hmm, I actually remember creating my own parser/formatter for decimal numbers with currency and grouping separators back on iOS 6. I was pretty sure all the problems with number formatter and decimals were fixed ages ago. – Sulthan Feb 22 '22 at 21:57
1

Following Joakim Danielson Answer see this amazing documentation on the format style

Decimal(10.01).formatted(.number.precision(.fractionLength(1))) // 10.0 Decimal(10.01).formatted(.number.precision(.fractionLength(2))) // 10.01 Decimal(10.01).formatted(.number.precision(.fractionLength(3))) // 10.010

Amazingly detailed documentation

devjme
  • 684
  • 6
  • 12
0

If this is strictly a rendering issue and you're just looking to translate a currency value from raw string to formatted string then just do that.

let formatter = NumberFormatter()
formatter.numberStyle = .currency

let raw = "89806.9"

if let double = Double(raw),
   let currency = formatter.string(from: NSNumber(value: double)) {
    print(currency) // $89,806.90
}

If there is math involved then before you get to the use of string formatters, I would point you to Why not use Double or Float to represent currency? and How to round a double to an int using Banker's Rounding in C as great starting points.

trndjc
  • 11,654
  • 3
  • 38
  • 51
  • The value here is incorrect, however. `double` has a binary rounding error. It prints correctly, but math will have errors. – Rob Napier Feb 22 '22 at 21:46
  • Oh there will be lose of precision with Double. That's why I switched all Doubles to Decimal. For example, there's no way for Double to represent `0.1` – OMGPOP Feb 22 '22 at 21:46
  • @RobNapier but you wouldn't be doing math with these values. This is strictly for the UI which is what I assumed OP wanted given the use of the string formatter. – trndjc Feb 22 '22 at 21:49
  • The OP is parsing with a string formatter. If the point is to input currency, you need to end up with a Decimal (or better IMO an Int). You can never, ever, use a Double. – Rob Napier Feb 22 '22 at 21:52
  • @liquid can you make it work with both `89806.9` and `$89,806.9` input? – OMGPOP Feb 22 '22 at 21:53
  • @OMGPOP made an edit – trndjc Feb 22 '22 at 22:03
0

I get my response with double value and remove formatter.generatesDecimalNumbers line to get work.

let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.isLenient = true
formatter.maximumFractionDigits = 2
//formatter.generatesDecimalNumbers = true // I removed this line

let text = "$89806.9"
let double = formatter.number(from: text)?.doubleValue ?? .zero // converting as double or float
let string = "\(double)"
print(string) // 89806.9

let anotherText = "$0.1"
let anotherDouble = formatter.number(from: anotherText)?.doubleValue ?? .zero // converting as double or float
let anotherString = "\(anotherDouble)"
print(anotherString) // 0.1

Reza Khonsari
  • 479
  • 3
  • 13
  • This is introducing rounding errors that the OP is trying to avoid. The value of `double` is 89806.89999999999. The default `print` handling applies some rounding so you don't see this. – Rob Napier Feb 22 '22 at 22:23
  • @RobNapier what about floatValue? – Reza Khonsari Feb 22 '22 at 22:25
  • as I mention it in my comment in code – Reza Khonsari Feb 22 '22 at 22:26
  • That's not a fix. The problem is decimal rounding vs binary rounding. 1/10 is a repeating fraction in in binary, like 1/3 in decimal. – Rob Napier Feb 22 '22 at 22:26
  • 1
    Changing how many digits of a repeating fraction you keep doesn't change the fact that it doesn't equal the original rational value. 0.33 * 3 == 0.99. No matter how many decimal places you keep of 1/3 as a decimal number, it will never *quite* equal 1/3, and when you multiply by 3, it will never *quite* equal 1. – Rob Napier Feb 22 '22 at 22:27
  • @RobNapier that's interesting for me – Reza Khonsari Feb 22 '22 at 22:33