Skip to content

techouse/qs-swift

QsSwift

A fast, flexible query string encoding/decoding library for Swift.

Ported from qs for JavaScript.

SwiftPM compatible Docs License Test codecov GitHub Sponsors GitHub Repo stars


Highlights

  • Nested maps & lists: foo[bar][baz]=qux["foo": ["bar": ["baz": "qux"]]]
  • Multiple list formats (indices, brackets, repeat, comma)
  • Dot-notation (a.b=c) and optional dot-encoding
  • UTF‑8 and ISO‑8859‑1 charsets; optional charset sentinel (utf8=✓)
  • Custom encoders/decoders, sorting, filtering, strict/null handling
  • Deterministic ordering with OrderedDictionary (swift-collections)

Requirements

  • Swift 5.10+
  • Platforms: macOS 12+, iOS 13+, tvOS 13+, watchOS 8+

Installation (Swift Package Manager)

Xcode

Package.swift

// in your Package.swift
dependencies: [
    .package(url: "https://github.com/techouse/qs-swift", from: "1.1.0")
],
targets: [
    .target(
        name: "YourApp",
        dependencies: [
            .product(name: "QsSwift", package: "qs-swift")
        ]
    )
]

Quick start

import QsSwift

// Decode
let decoded: [String: Any] = try Qs.decode("foo[bar]=baz&foo[list][]=a&foo[list][]=b")
// decoded == ["foo": ["bar": "baz", "list": ["a", "b"]]]

// Encode
let encoded: String = try Qs.encode(["foo": ["bar": "baz"]])
// encoded == "foo%5Bbar%5D=baz"

Usage

Simple

// Decode
let obj: [String: Any] = try Qs.decode("a=c")
// ["a": "c"]

// Encode
let qs: String = try Qs.encode(["a": "c"])
// "a=c"

Decoding

Nested maps

try Qs.decode("foo[bar]=baz")
// ["foo": ["bar": "baz"]]

try Qs.decode("a%5Bb%5D=c")
// ["a": ["b": "c"]]

try Qs.decode("foo[bar][baz]=foobarbaz")
// ["foo": ["bar": ["baz": "foobarbaz"]]]

Depth (default: 5)

Beyond the configured depth, the remainder is kept literally:

let r = try Qs.decode("a[b][c][d][e][f][g][h][i]=j")
// r["a"]?["b"]?["c"]?["d"]?["e"]?["f"]?["[g][h][i]"] == "j"

Override depth:

let r = try Qs.decode("a[b][c][d][e][f][g][h][i]=j", options: .init(depth: 1))
// r["a"]?["b"]?["[c][d][e][f][g][h][i]"] == "j"

Parameter limit & ignoring ?

try Qs.decode("a=b&c=d", options: .init(parameterLimit: 1))
// ["a": "b"]

try Qs.decode("?a=b&c=d", options: .init(ignoreQueryPrefix: true))
// ["a": "b", "c": "d"]

Custom delimiters (string or regex)

try Qs.decode("a=b;c=d", options: .init(delimiter: StringDelimiter(";")))
// ["a": "b", "c": "d"]

let delim = try RegexDelimiter("[;,]")
try Qs.decode("a=b;c=d", options: .init(delimiter: delim))
// ["a": "b", "c": "d"]

Dot notation & “decode dots in keys”

try Qs.decode("a.b=c", options: .init(allowDots: true))
// ["a": ["b": "c"]]

let r = try Qs.decode(
    "name%252Eobj.first=John&name%252Eobj.last=Doe",
    options: .init(decodeDotInKeys: true)
)
// ["name.obj": ["first": "John", "last": "Doe"]]

Empty lists & duplicates

try Qs.decode("foo[]&bar=baz", options: .init(allowEmptyLists: true))
// ["foo": [], "bar": "baz"]

try Qs.decode("foo=bar&foo=baz")
// ["foo": ["bar", "baz"]]

try Qs.decode("foo=bar&foo=baz", options: .init(duplicates: .first))
// ["foo": "bar"]

try Qs.decode("foo=bar&foo=baz", options: .init(duplicates: .last))
// ["foo": "baz"]

Charset & sentinel

try Qs.decode("a=%A7", options: .init(charset: .isoLatin1))
// ["a": "§"]

try Qs.decode(
    "utf8=%E2%9C%93&a=%C3%B8",
    options: .init(charset: .isoLatin1, charsetSentinel: true)
)
// ["a": "ø"]

try Qs.decode(
    "utf8=%26%2310003%3B&a=%F8",
    options: .init(charset: .utf8, charsetSentinel: true)
)
// ["a": "ø"]

Interpret numeric entities (Ӓ)

try Qs.decode(
    "a=%26%239786%3B",
    options: .init(charset: .isoLatin1, interpretNumericEntities: true)
)
// ["a": "☺"]

Lists

try Qs.decode("a[]=b&a[]=c")
// ["a": ["b", "c"]]

try Qs.decode("a[1]=c&a[0]=b")
// ["a": ["b", "c"]]

try Qs.decode("a[1]=b&a[15]=c")
// ["a": ["b", "c"]]

try Qs.decode("a[]=&a[]=b")
// ["a": ["", "b"]]

Large indices become a map by default:

let r = try Qs.decode("a[100]=b")
// ["a": ["100": "b"]]

Disable list parsing:

let r = try Qs.decode("a[]=b", options: .init(parseLists: false))
// ["a": ["0": "b"]]

Mix notations:

let r = try Qs.decode("a[0]=b&a[b]=c")
// ["a": ["0": "b", "b": "c"]]

Comma-separated values:

let r = try Qs.decode("a=b,c", options: .init(comma: true))
// ["a": ["b", "c"]]

Encoding

Basics

try Qs.encode(["a": "b"])
// "a=b"

try Qs.encode(["a": ["b": "c"]])
// "a%5Bb%5D=c"

Disable URI encoding for readability:

try Qs.encode(["a": ["b": "c"]], options: .init(encode: false))
// "a[b]=c"

Values-only encoding:

let input: [String: Any] = [
    "a": "b",
    "c": ["d", "e=f"],
    "f": [["g"], ["h"]],
]
try Qs.encode(input, options: .init(encodeValuesOnly: true))
// "a=b&c[0]=d&c[1]=e%3Df&f[0][0]=g&f[1][0]=h"

Custom encoder:

let enc: ValueEncoder = { value, _, _ in
    // e.g. map "č" → "c", otherwise describe
    if let s = value as? String, s == "č" {
        return "c"
    }
    return String(describing: value ?? "")
}
try Qs.encode(["a": ["b": "č"]], options: .init(encoder: enc))
// "a[b]=c"

List formats

// indices (default when encode=false)
try Qs.encode(["a": ["b", "c"]], options: .init(encode: false))
// "a[0]=b&a[1]=c"

// brackets
try Qs.encode(["a": ["b", "c"]], options: .init(listFormat: .brackets, encode: false))
// "a[]=b&a[]=c"

// repeat
try Qs.encode(["a": ["b", "c"]], options: .init(listFormat: .repeatKey, encode: false))
// "a=b&a=c"

// comma
try Qs.encode(["a": ["b", "c"]], options: .init(listFormat: .comma, encode: false))
// "a=b,c"

Nested maps and dot notation

try Qs.encode(["a": ["b": ["c": "d", "e": "f"]]], options: .init(encode: false))
// "a[b][c]=d&a[b][e]=f"

try Qs.encode(["a": ["b": ["c": "d", "e": "f"]]], options: .init(allowDots: true, encode: false))
// "a.b.c=d&a.b.e=f"

Encode dots in keys:

try Qs.encode(
    ["name.obj": ["first": "John", "last": "Doe"]],
    options: .init(allowDots: true, encodeDotInKeys: true)
)
// "name%252Eobj.first=John&name%252Eobj.last=Doe"

Empty lists, nulls, and other niceties:

// Allow empty lists (order preserved with OrderedDictionary input)
try Qs.encode(["foo": [Any](), "bar": "baz"], options: .init(allowEmptyLists: true, encode: false))
// e.g. "foo[]&bar=baz"

try Qs.encode(["a": ""])                         // "a="
try Qs.encode(["a": [Any]()])                    // ""
try Qs.encode(["a": ["b": [Any]()]])             // ""
try Qs.encode(["a": NSNull(), "b": Undefined()]) // "a="

try Qs.encode(["a": "b", "c": "d"], options: .init(addQueryPrefix: true))  // "?a=b&c=d"
try Qs.encode(["a": "b", "c": "d"], options: .init(delimiter: ";"))        // "a=b;c=d"

Dates

let date = Date(timeIntervalSince1970: 0.007) // 7 ms since epoch

// Default ISO-8601 with millisecond precision (encode=false example)
try Qs.encode(["a": date], options: .init(encode: false))
// "a=1970-01-01T00:00:00.007Z"

// Custom serializer (epoch millis)
try Qs.encode(
    ["a": date],
    options: .init(
        dateSerializer: { d in String(Int((d.timeIntervalSince1970 * 1000.0).rounded())) },
        encode: false
    )
)
// "a=7"

Sorting and filtering

// Sort keys
let sort: Sorter = { a, b in
    let la = String(describing: a ?? "")
    let lb = String(describing: b ?? "")
    return la.compare(lb).rawValue // -1/0/1
}
try Qs.encode(["a": "c", "z": "y", "b": "f"], options: .init(encode: false, sort: sort))
// "a=c&b=f&z=y"

// Function filter (drop/transform)
let date = Date(timeIntervalSince1970: 0.123) // 123 ms
let filter = FunctionFilter { prefix, value in
    switch prefix {
    case "b": return Undefined()
    case "e[f]":
        if let d = value as? Date {
            return Int((d.timeIntervalSince1970 * 1000.0).rounded())
        }
    case "e[g][0]":
        if let n = value as? NSNumber {
            return n.intValue * 2
        }
        if let i = value as? Int {
            return i * 2
        }
    default: break
    }
    return value
}

let input: [String: Any] = [
    "a": "b",
    "c": "d",
    "e": ["f": date, "g": [2]],
]
try Qs.encode(input, options: .init(encode: false, filter: filter))
// "a=b&c=d&e[f]=123&e[g][0]=4"

// Iterable filter (whitelist keys/indices)
try Qs.encode(["a": "b", "c": "d", "e": "f"], options: .init(encode: false, filter: IterableFilter(["a", "e"])))
// "a=b&e=f"

RFC 3986 vs RFC 1738 (spaces)

try Qs.encode(["a": "b c"])                                   // "a=b%20c" (RFC 3986 default)
try Qs.encode(["a": "b c"], options: .init(format: .rfc3986)) // "a=b%20c"
try Qs.encode(["a": "b c"], options: .init(format: .rfc1738)) // "a=b+c"

nil, NSNull, and Undefined (null semantics)

Query strings don’t have a native null concept, so Qs uses a few conventions to mirror “JSON-style” semantics as closely as possible:

  • NSNull() – use this to represent an explicit “null-like” value.
  • Undefined() – a special sentinel provided by Qs to mean “omit this key entirely”.
  • "" (empty string) – a real, present-but-empty value.

Encoding behavior (Swift → query string)

Input value Default (strictNullHandling: false) With strictNullHandling: true With skipNulls: true
"foo" a=foo a=foo a=foo
"" (empty string) a= a= a=
NSNull() a= a (no = sign) (omitted)
Undefined() (omitted) (omitted) (omitted)

Examples:

try Qs.encode(["a": NSNull()])
// "a="

try Qs.encode(["a": NSNull()], options: .init(strictNullHandling: true))
// "a"               // bare key, no "="

try Qs.encode(["a": NSNull()], options: .init(skipNulls: true))
// ""                // key omitted

try Qs.encode(["a": Undefined()])
// ""                // always omitted, regardless of options

Decoding behavior (query string → Swift)

Input token Default (strictNullHandling: false) With strictNullHandling: true
a= ["a": ""] ["a": ""]
a ["a": ""] ["a": NSNull()]

Examples:

try Qs.decode("a&b=")
// ["a": "", "b": ""]

try Qs.decode("a&b=", options: .init(strictNullHandling: true))
// ["a": NSNull(), "b": ""]

How this maps to JSON libraries

  • In Foundation's JSONSerialization, NSNull is the conventional stand-in for JSON null. → In Qs, use NSNull() to mean a null-like value.
  • In Codable/JSONEncoder, whether missing keys are emitted or omitted often depends on how your model is encoded (encode vs encodeIfPresent). → In Qs, use Undefined() to always omit a key from the output.
  • There is no native “null” in query strings, so preserving a true “null round-trip” requires using:
    • NSNull() on encode and strictNullHandling: true (so it renders as a bare key), and
    • strictNullHandling: true on decode (so bare keys come back as NSNull()).

Round-trip tip:

// Encode with a null-like value:
let out = try Qs.encode(["a": NSNull()], options: .init(strictNullHandling: true))
// "a"

// Decode back to NSNull:
let back = try Qs.decode(out, options: .init(strictNullHandling: true))
// ["a": NSNull()]

If you simply want to drop keys when a value is not present, prefer Undefined() (or skipNulls: true when values are NSNull()), rather than encoding NSNull() itself.


API surface

  • Qs.decode(_:, options:) -> [String: Any]
  • Qs.encode(_:, options:) -> String
  • DecodeOptions / EncodeOptions – configuration knobs
  • Duplicates / ListFormat / Format – enums matching qs.js semantics
  • Undefined – sentinel used by filters to omit keys

Ordering notes

  • If options.sort != nil, that comparator decides order.
  • If options.sort == nil and options.encode == false, key order follows input traversal (use OrderedDictionary for stability).
  • Arrays always preserve input order.

Safety tips

  • Keep depth and parameterLimit reasonable for untrusted inputs (defaults are sane).
  • allowEmptyLists, allowSparseLists, and parseLists let you tune behavior for edge cases.
  • Use strictNullHandling to differentiate nil (no =) from empty string (=).

Bench (optional)

A tiny micro‑bench harness lives in Bench/ (separate SPM package). It’s excluded from the main library.

cd Bench
make profile

Objective-C

This package currently exposes a Swift API only. If you need Obj-C compatibility (facade + delegate hooks), please open an issue—we can add it without breaking the Swift API.


Special thanks to the authors of qs for JavaScript:


License

BSD 3‑Clause © techouse

About

A query string encoding and decoding library for Swift. Ported from qs for JavaScript.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Sponsor this project

  •  

Packages

No packages published