Robust String to Double casting in Swift

In Swift, converting a String
to a Double
always returns an optional. This makes perfect sense, as non-numeric strings cannot be converted into a floating-point number. Double("whatEverStringGoesHere")
is going to return nil
.
However, there are cases where you absolutely need a non-optional return value and should attempt to convert a String to a Double as reliably as possible. One such case is when the string contains a comma instead of a period as the decimal separator:
For example, Double("2,45")
returns nil
, even though many users – such as those in Germany – use a comma as the decimal separator and a period as the thousands separator.
To handle this task, I use a function that converts any string into a floating-point number, as long as it contains at least one numeric digit.
The function follows a simple – but basic – logic:
- Replace every occurence of
,
with.
- Remove everything which is not a numeric digit (except for
.
) - In case multiple
.
exist, only the first is considered, and only two digits after it are kept
import Foundation
class NumericUtils {
class func convertToDouble(from input: String) -> Double? {
// Step 1: Replace ',' with '.'
var sanitizedString = input.replacingOccurrences(of: ",", with: ".")
// Step 2: Remove all non-numeric characters except '.'
sanitizedString = sanitizedString.filter { $0.isNumber || $0 == "." }
// Step 3: Handle multiple '.' occurrences
if let firstDotIndex = sanitizedString.firstIndex(of: ".") {
let afterDotIndex = sanitizedString.index(after: firstDotIndex)
let allowedEndIndex = sanitizedString.index(afterDotIndex, offsetBy: 2, limitedBy: sanitizedString.endIndex) ?? sanitizedString.endIndex
sanitizedString = String(sanitizedString[..<allowedEndIndex])
}
// Step 4: Convert to Double
return Double(sanitizedString)
}
}
The test cases look like this and pass:
func testRobustStringToDoubleConversion() {
let testCases = [
("1,234.567", 1.23),
("12,34,56", 12.34),
("abc12.34.56", 12.34),
("98,76.54", 98.76),
("4.567,89", 4.56),
("##++4.5!!67,89???ß", 4.56),
("55", 55)
]
testCases.forEach { sut in
if let result = NumericUtils.convertToDouble(from: sut.0) {
//print("\(sut) -> \(result)")
XCTAssertEqual(result, sut.1)
} else {
XCTFail("Conversion failed for: \(sut)")
}
}
}
The function above ensures a robust conversion from string to a floating-point number, but does not fully account the locale. Let’s explore a more specific solution that considers the locale – at least to some extent.
Alternative version considering locale
The following function is an improved version that considers the locale (to some extent) by accepting both .
and ,
as decimal delimiters. This ensures that strings like 3,456,789.24
and 3.456.789,24
are correctly converted to 3456789.24
.
The function shown below follows a similar logic as the function shown above:
- Replace
,
with.
(comma-cleaned string) - Remove all non-numeric characters except
.
(numeric-only string) - Remove every
.
except for the last one - Convert to
Double
As a bonus, this function also allows rounding the converted floating-point number to a specified number of digits. Rounding is implemented as an extension of the Double
type.
import Foundation
// MARK: - Extensions
extension Double {
func round(toPlaces places: Int) -> Double {
let divisor = pow(10.0, Double(places))
return (self * divisor).rounded() / divisor
}
}
// MARK: - Utils implementation
class NumericUtils {
class func convertToDouble(from input: String, roundToPlaces: Int = 2) -> Double? {
// Step 1: Replace ',' with '.' (comma-cleaned string)
var commaCleanedString = input.replacingOccurrences(of: ",", with: ".")
// Step 2: Remove all non-numeric characters except '.' (numeric-only string)
var numericOnlyString = commaCleanedString.filter { $0.isNumber || $0 == "." }
// Step 3: Remove every '.' except for the last one
if numericOnlyString.filter({ $0 == "." }).count > 1 {
var components = numericOnlyString.split(separator: ".")
let lastPart = components.removeLast()
numericOnlyString = components.joined() + "." + lastPart
}
// Step 4: Convert to Double
return Double(numericOnlyString)?.round(toPlaces: roundToPlaces)
}
}
The test cases look like this and pass:
func testRobustStringToDoubleConversion() {
let testCases = [
("3,456,789.24", 3456789.24),
("3.456.789,24", 3456789.24),
("##++4.5!!67,89???ß", 4567.89),
("1,234.567", 1234.57),
("12,34,56", 1234.56),
("abc12.34.56", 1234.56),
("98,76.54321", 9876.54),
("345", 345.0)
]
testCases.forEach { sut in
if let result = NumericUtils.convertToDouble(from: sut.0, roundToPlaces: 2) {
print("\(sut.0) -> \(result)")
XCTAssertEqual(result, sut.1)
} else {
XCTFail("Conversion failed for: \(sut)")
}
}
}