Skip to content

URL Encode / Decode — Swift source

Percent-encode or decode URLs and query parameters. Choose component (encodeURIComponent) or full-URI (encodeURI) mode. 100% client-side.

This is the Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.

// url-encode — percent encode/decode, component (encodeURIComponent) and full-URI (encodeURI) scope.
// Language: Swift (5.9+, standard library only). Port of src/tools/UrlEncodeTool.tsx — same
// contract as this dir's go.go: each byte outside the safe set becomes %XX (uppercase hex);
// component keeps A-Za-z0-9-_.!~*'(), full URI also keeps ;,/?:@&=+$#; decode is strict (bad %
// pair, invalid UTF-8 -> error) and full-URI scope leaves encoded reserved bytes intact.
enum UrlEncodeError: Error { case malformed }

enum UrlEncode {
    static let hex = Array("0123456789ABCDEF".utf8)

    static func componentSafe(_ c: UInt8) -> Bool {
        (65...90).contains(c) || (97...122).contains(c) || (48...57).contains(c)
            || "-_.!~*'()".utf8.contains(c)
    }

    static func uriExtra(_ c: UInt8) -> Bool { ";,/?:@&=+$#".utf8.contains(c) }

    static func encode(_ s: String, fullURI: Bool = false) -> String {
        var b = [UInt8]()
        b.reserveCapacity(s.utf8.count * 3)
        for c in s.utf8 {
            if componentSafe(c) || (fullURI && uriExtra(c)) {
                b.append(c)
            } else {
                b.append(0x25) // '%'
                b.append(hex[Int(c >> 4)])
                b.append(hex[Int(c & 0xf)])
            }
        }
        return String(decoding: b, as: UTF8.self) // encode never produces invalid UTF-8
    }

    static func unhex(_ c: UInt8) -> UInt8? {
        switch c {
        case 48...57: return c - 48
        case 97...102: return c - 97 + 10
        case 65...70: return c - 65 + 10
        default: return nil
        }
    }

    static func decode(_ s: String, fullURI: Bool = false) -> Result<String, UrlEncodeError> {
        var bytes = [UInt8]()
        bytes.reserveCapacity(s.utf8.count)
        let src = Array(s.utf8)
        var i = 0
        while i < src.count {
            if src[i] != 0x25 { bytes.append(src[i]); i += 1; continue }
            guard i + 2 < src.count, let hi = unhex(src[i + 1]), let lo = unhex(src[i + 2]) else {
                return .failure(.malformed) // truncated tail or bad hex digit
            }
            let c = hi << 4 | lo
            if fullURI && c < 0x80 && uriExtra(c) { // decodeURI keeps encoded reserved
                bytes.append(contentsOf: src[i...i + 2])
            } else {
                bytes.append(c)
            }
            i += 3
        }
        // the validating initializer is the strict decoder — nil on overlong/surrogate/truncated
        guard let out = String(validating: bytes, as: UTF8.self) else { return .failure(.malformed) }
        return .success(out)
    }
}

func show(_ label: String, _ r: Result<String, UrlEncodeError>) {
    switch r {
    case .success(let out): print("\(label)\(out)")
    case .failure: print("\(label)malformed URI sequence")
    }
}

print("enc:  \(UrlEncode.encode("hello world & café"))")
print("uri:  \(UrlEncode.encode("hello world & café", fullURI: true))")
show("dec:  ", UrlEncode.decode("hello%20world%20%26%20caf%C3%A9"))
show("duri: ", UrlEncode.decode("a%2Fb%3Fc%2Cd", fullURI: true)) // reserved stay escaped in decodeURI
show("dcmp: ", UrlEncode.decode("a%2Fb%3Fc%2Cd")) // ...but decode in component scope
show("bad:  ", UrlEncode.decode("100%"))

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →