Robust String to Double casting in Swift

Xcode playground showing string to double casting and returning an optional

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:

  1. Replace every occurence of , with .
  2. Remove everything which is not a numeric digit (except for .)
  3. 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:

  1. Replace , with . (comma-cleaned string)
  2. Remove all non-numeric characters except . (numeric-only string)
  3. Remove every . except for the last one
  4. 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)")
                }
        }
}