qs-kotlin
A query string encoding and decoding library for Android and Kotlin/JVM.
Ported from qs for JavaScript.
This repo provides:
qs-kotlin– the core JVM library (Jar)qs-kotlin-android– a thin Android AAR wrapper that re-exports the same APIqs-kotlin-okhttp– optional OkHttpHttpUrlextensions for adding qs-style nested query parametersqs-kotlin-ktor– optional KtorURLBuilder,Url, andApplicationRequestextensionsqs-kotlin-spring-web– optional Spring WebUriComponentsBuilderextension
If you only target the JVM (including Android projects that are fine with a plain Jar), just use
qs-kotlin. The Android wrapper is provided for teams that prefer an AAR coordinate and AGP metadata. The OkHttp, Ktor, and Spring Web modules are optional and keep HTTP-client/server integrations out of the core artifact.
Highlights
Nested maps and lists:
foo[bar][baz]=qux⇄{ foo: { bar: { baz: "qux" } } }Multiple list formats (indices, brackets, repeat, comma)
Dot-notation support (
a.b=c) and"."-encoding togglesUTF-8 and ISO-8859-1 charsets, plus optional charset sentinel (
utf8=✓)Custom encoders/decoders, key sorting, filtering, and strict null handling
Supports
LocalDateTime/Instantserialization via a pluggable serializerExtensive tests (Kotest), performance-minded implementation
Installation
JVM (Jar)
Kotlin:
dependencies {
implementation("io.github.techouse:qs-kotlin:<version>")
}Java (Gradle Groovy DSL):
dependencies {
implementation 'io.github.techouse:qs-kotlin:<version>'
}Android (AAR wrapper)
Kotlin:
dependencies {
implementation("io.github.techouse:qs-kotlin-android:<version>")
}Java (Gradle Groovy DSL):
dependencies {
implementation 'io.github.techouse:qs-kotlin-android:<version>'
}OkHttp integration (Jar)
Kotlin:
dependencies {
implementation("io.github.techouse:qs-kotlin-okhttp:<version>")
}Java (Gradle Groovy DSL):
dependencies {
implementation 'io.github.techouse:qs-kotlin-okhttp:<version>'
}Ktor integration (Jar)
Kotlin:
dependencies {
implementation("io.github.techouse:qs-kotlin-ktor:<version>")
}Java (Gradle Groovy DSL):
dependencies {
implementation 'io.github.techouse:qs-kotlin-ktor:<version>'
}Spring Web integration (Jar)
Kotlin:
dependencies {
implementation("io.github.techouse:qs-kotlin-spring-web:<version>")
}Java (Gradle Groovy DSL):
dependencies {
implementation 'io.github.techouse:qs-kotlin-spring-web:<version>'
}The Android AAR depends on Java 17 APIs. If your app’s
minSdk < 26and you usejava.timetransitively, enable core library desugaring in your app:
Kotlin:
android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}Java (Gradle Groovy DSL):
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
coreLibraryDesugaringEnabled = true
}
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
}Requirements
Kotlin 2.3.0+
Java 17+
Android wrapper: AGP 8.7+,
compileSdk 35,minSdk 25OkHttp integration: OkHttp 5.4.0
Ktor integration: Ktor 3.5.0
Spring Web integration: Spring Web 7.0.8
Quick start
Kotlin:
import io.github.techouse.qskotlin.QS
// Decode
val obj: Map<String, Any?> = QS.decode("foo[bar]=baz&foo[list][]=a&foo[list][]=b")
// -> mapOf("foo" to mapOf("bar" to "baz", "list" to listOf("a", "b")))
// Encode
val qs: String = QS.encode(mapOf("foo" to mapOf("bar" to "baz")))
// -> "foo%5Bbar%5D=baz"Java:
import io.github.techouse.qskotlin.QS;
// Decode
Map<@NotNull String, @Nullable Object> obj = QS.decode("foo[bar]=baz&foo[list][]=a&foo[list][]=b");
// -> {foo={bar=baz, list=[a, b]}}
// Encode
String qs = QS.encode(Map.of("foo", Map.of("bar", "baz")));
// -> "foo%5Bbar%5D=baz"OkHttp integration
OkHttp's HttpUrl.Builder.addQueryParameter encodes names and values itself. qs-kotlin already returns an encoded query string, including nested bracket notation such as filter%5Bwhere%5D%5Bname%5D=John. The qs-kotlin-okhttp module splits qs-kotlin output into pairs and adds them with OkHttp's encoded query-parameter API to avoid double-encoding %5B into %255B.
HttpUrl.Builder
import io.github.techouse.qskotlin.okhttp.addQsQueryParameters
import okhttp3.HttpUrl.Companion.toHttpUrl
val url =
"https://api.example.com/products"
.toHttpUrl()
.newBuilder()
.addQsQueryParameters(
mapOf(
"filter" to
mapOf(
"where" to
mapOf(
"name" to "John",
"age" to mapOf("gte" to 30),
)
),
"tags" to listOf("a", "b"),
)
)
.build()
// https://api.example.com/products?filter%5Bwhere%5D%5Bname%5D=John&filter%5Bwhere%5D%5Bage%5D%5Bgte%5D=30&tags%5B0%5D=a&tags%5B1%5D=bImmutable HttpUrl
import io.github.techouse.qskotlin.okhttp.addQsQueryParameters
import okhttp3.HttpUrl.Companion.toHttpUrl
val original = "https://api.example.com/products?existing=1".toHttpUrl()
val updated = original.addQsQueryParameters(mapOf("page" to 2))
// original: https://api.example.com/products?existing=1
// updated: https://api.example.com/products?existing=1&page=2List formats
The integration uses qs-kotlin defaults unless you pass EncodeOptions.
import io.github.techouse.qskotlin.enums.ListFormat
import io.github.techouse.qskotlin.models.EncodeOptions
import io.github.techouse.qskotlin.okhttp.addQsQueryParameters
import okhttp3.HttpUrl.Companion.toHttpUrl
val repeated =
"https://api.example.com/search"
.toHttpUrl()
.addQsQueryParameters(
mapOf("tag" to listOf("kotlin", "android")),
EncodeOptions(listFormat = ListFormat.REPEAT),
)
// https://api.example.com/search?tag=kotlin&tag=androidThis module only targets HttpUrl and HttpUrl.Builder.
Retrofit
There is no dedicated Retrofit integration because Retrofit's @QueryMap API cannot represent all qs-kotlin output, such as duplicate keys and name-only parameters.
For full qs-kotlin fidelity, build the URL with qs-kotlin-okhttp and pass the resulting HttpUrl to Retrofit through @Url.
import io.github.techouse.qskotlin.okhttp.addQsQueryParameters
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import retrofit2.http.GET
import retrofit2.http.Url
interface ProductsApi {
@GET
suspend fun search(@Url url: HttpUrl): ProductsResponse
}
val url =
"https://api.example.com/products"
.toHttpUrl()
.addQsQueryParameters(
mapOf(
"filter" to mapOf("where" to mapOf("name" to "John")),
"tags" to listOf("a", "b"),
)
)
val response = api.search(url)
// https://api.example.com/products?filter%5Bwhere%5D%5Bname%5D=John&tags%5B0%5D=a&tags%5B1%5D=bKtor integration
Ktor's decoded query parameter APIs encode names and values when building URLs. qs-kotlin already returns an encoded query string, including nested bracket notation such as filter%5Bwhere%5D%5Bname%5D=John. The qs-kotlin-ktor module splits qs-kotlin output into pairs and appends them with Ktor's encoded query-parameter API to avoid double-encoding %5B into %255B.
URLBuilder
import io.github.techouse.qskotlin.ktor.appendQsQueryParameters
import io.ktor.client.request.get
import io.ktor.client.request.url
val response = client.get("https://api.example.com/products") {
url {
appendQsQueryParameters(
mapOf(
"filter" to
mapOf(
"where" to
mapOf(
"name" to "John",
"age" to mapOf("gte" to 30),
)
),
"tags" to listOf("a", "b"),
)
)
}
}
// https://api.example.com/products?filter%5Bwhere%5D%5Bname%5D=John&filter%5Bwhere%5D%5Bage%5D%5Bgte%5D=30&tags%5B0%5D=a&tags%5B1%5D=bImmutable Url
import io.github.techouse.qskotlin.ktor.appendQsQueryParameters
import io.ktor.http.Url
val original = Url("https://api.example.com/products?existing=1")
val updated = original.appendQsQueryParameters(mapOf("page" to 2))
// original: https://api.example.com/products?existing=1
// updated: https://api.example.com/products?existing=1&page=2List formats
The integration uses qs-kotlin defaults unless you pass EncodeOptions.
import io.github.techouse.qskotlin.enums.ListFormat
import io.github.techouse.qskotlin.models.EncodeOptions
import io.github.techouse.qskotlin.ktor.appendQsQueryParameters
import io.ktor.http.Url
val repeated =
Url("https://api.example.com/search")
.appendQsQueryParameters(
mapOf("tag" to listOf("kotlin", "ktor")),
EncodeOptions(listFormat = ListFormat.REPEAT),
)
// https://api.example.com/search?tag=kotlin&tag=ktorServer parsing
import io.github.techouse.qskotlin.ktor.parseQsQuery
import io.ktor.server.response.respond
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
routing {
get("/products") {
val query = call.request.parseQsQuery()
call.respond(query)
}
}The server helper reads Ktor's raw queryString() and passes it to qs-kotlin. It does not parse queryParameters, because Ktor has already interpreted those values.
This module does not include client framework adapters beyond Ktor.
Spring Web integration
Spring's URI builders can double-encode already encoded query components when the wrong final build path is used. qs-kotlin already returns an encoded query string, including nested bracket notation such as filter%5Bwhere%5D%5Bname%5D=John. The qs-kotlin-spring-web module splits qs-kotlin output into pairs and appends them to UriComponentsBuilder.
Call .build(true).toUri() after queryQs(...). This is mandatory: using .build().toUri() or .encode().build() can double-encode %5B into %255B.
import io.github.techouse.qskotlin.spring.web.queryQs
import org.springframework.web.util.UriComponentsBuilder
val uri =
UriComponentsBuilder
.fromUriString("https://api.example.com/products")
.queryQs(
mapOf(
"filter" to mapOf("where" to mapOf("name" to "John")),
"tags" to listOf("a", "b"),
)
)
.build(true)
.toUri()
// https://api.example.com/products?filter%5Bwhere%5D%5Bname%5D=John&tags%5B0%5D=a&tags%5B1%5D=bThis module only targets UriComponentsBuilder. It does not include WebClient, RestClient, UriBuilder, Spring Boot, or auto-configuration helpers.
Usage
Simple
Kotlin:
// Decode
val decoded: Map<String, Any?> = QS.decode("a=c")
// => mapOf("a" to "c")
// Encode
val encoded: String = QS.encode(mapOf("a" to "c"))
// => "a=c"Java:
// Decode
Map<@NotNull String, @Nullable Object> decoded = QS.decode("a=c");
// => {a=c}
// Encode
String encoded = QS.encode(Map.of("a", "c"));
// => "a=c"Decoding
java.net.URI
Use decodeQsQuery to decode a URI's raw query component without losing qs semantics:
Kotlin:
import io.github.techouse.qskotlin.decodeQsQuery
import java.net.URI
val uri = URI("https://example.com/search?filter%5Bname%5D=Jane%20Doe")
val decoded = uri.decodeQsQuery()
// => mapOf("filter" to mapOf("name" to "Jane Doe"))Java:
import io.github.techouse.qskotlin.QS;
import java.net.URI;
import java.util.Map;
URI uri = URI.create("https://example.com/search?filter%5Bname%5D=Jane%20Doe");
Map<String, Object> decoded = QS.decodeQsQuery(uri);
// => {filter={name=Jane Doe}}The helper intentionally reads URI.rawQuery, not URI.query. The decoded accessor can turn an encoded value delimiter such as %26 into & before qs parses the query, changing its structure. URIs with an absent or explicitly empty query return an empty map. Opaque URIs also return an empty map because Java does not expose a query component for them.
To construct a new URI when there is no existing query to preserve, encode the query first and use the single-string URI constructor:
import io.github.techouse.qskotlin.toQueryString
import java.net.URI
val encoded = mapOf("filter" to mapOf("name" to "Jane Doe")).toQueryString()
val uri = URI("https://example.com/search?$encoded")Do not decode and re-encode an existing URI query to replace or append values; that can lose name-only values, duplicate ordering, delimiters, and list formatting.
Nested maps
Kotlin:
QS.decode("foo[bar]=baz")
// => mapOf("foo" to mapOf("bar" to "baz"))
QS.decode("a%5Bb%5D=c")
// => mapOf("a" to mapOf("b" to "c"))
QS.decode("foo[bar][baz]=foobarbaz")
// => mapOf("foo" to mapOf("bar" to mapOf("baz" to "foobarbaz")))Java:
QS.decode("foo[bar]=baz");
// => {foo={bar=baz}}
QS.decode("a%5Bb%5D=c");
// => {a={b=c}}
QS.decode("foo[bar][baz]=foobarbaz");
// => {foo={bar={baz=foobarbaz}}}Depth (default: 5)
Beyond the configured depth, remaining bracket content is kept as literal text:
Kotlin:
QS.decode("a[b][c][d][e][f][g][h][i]=j")
// => mapOf("a" to mapOf("b" to mapOf("c" to mapOf("d" to mapOf("e" to mapOf("f" to mapOf("[g][h][i]" to "j")))))))Java:
QS.decode("a[b][c][d][e][f][g][h][i]=j");
// => {a={b={c={d={e={f={[g][h][i]=j}}}}}}}Override depth:
Kotlin:
QS.decode(
"a[b][c][d][e][f][g][h][i]=j",
DecodeOptions(depth = 1)
)
// => mapOf("a" to mapOf("b" to mapOf("[c][d][e][f][g][h][i]" to "j")))Java:
QS.decode(
"a[b][c][d][e][f][g][h][i]=j",
DecodeOptions.builder()
.depth(1)
.build()
);
// => {a={b={[c][d][e][f][g][h][i]=j}}}Parameter limit
Kotlin:
QS.decode(
"a=b&c=d",
DecodeOptions(parameterLimit = 1)
)
// => mapOf("a" to "b")Java:
QS.decode(
"a=b&c=d",
DecodeOptions.builder()
.parameterLimit(1)
.build()
);
// => {a=b}Ignore leading ?
Kotlin:
QS.decode(
"?a=b&c=d",
DecodeOptions(ignoreQueryPrefix = true)
)
// => mapOf("a" to "b", "c" to "d")Java:
QS.decode(
"?a=b&c=d",
DecodeOptions.builder()
.ignoreQueryPrefix(true)
.build()
);
// => {a=b, c=d}Custom delimiter (string or regex)
Kotlin:
QS.decode(
"a=b;c=d",
DecodeOptions(delimiter = StringDelimiter(";"))
)
// => mapOf("a" to "b", "c" to "d")
QS.decode(
"a=b;c=d",
DecodeOptions(delimiter = RegexDelimiter("[;,]"))
)
// => mapOf("a" to "b", "c" to "d")Java:
QS.decode(
"a=b;c=d",
DecodeOptions.builder()
.delimiter(Delimiter.SEMICOLON)
.build()
);
// => {a=b, c=d}
QS.decode(
"a=b;c=d",
DecodeOptions.builder()
.delimiter(new RegexDelimiter("[;,]"))
.build()
);
// => {a=b, c=d}Dot-notation and “decode dots in keys”
Kotlin:
QS.decode(
"a.b=c",
DecodeOptions(allowDots = true)
)
// => mapOf("a" to mapOf("b" to "c"))
QS.decode(
"name%252Eobj.first=John&name%252Eobj.last=Doe",
DecodeOptions(decodeDotInKeys = true)
)
// => mapOf("name.obj" to mapOf("first" to "John", "last" to "Doe"))Java:
QS.decode(
"a.b=c",
DecodeOptions.builder()
.allowDots(true)
.build()
);
// => {a={b=c}}
QS.decode(
"name%252Eobj.first=John&name%252Eobj.last=Doe",
DecodeOptions.builder()
.decodeDotInKeys(true)
.build()
);
// => {name.obj={first=John, last=Doe}}Empty lists
Kotlin:
QS.decode(
"foo[]&bar=baz",
DecodeOptions(allowEmptyLists = true)
)
// => mapOf("foo" to emptyList<String>(), "bar" to "baz")Java:
QS.decode(
"foo[]&bar=baz",
DecodeOptions.builder()
.allowEmptyLists(true)
.build()
);
// => {foo=[], bar=baz}Duplicates
Kotlin:
QS.decode("foo=bar&foo=baz")
// => mapOf("foo" to listOf("bar", "baz"))
QS.decode(
"foo=bar&foo=baz",
DecodeOptions(duplicates = Duplicates.COMBINE)
)
// => same as above
QS.decode(
"foo=bar&foo=baz",
DecodeOptions(duplicates = Duplicates.FIRST)
)
// => mapOf("foo" to "bar")
QS.decode(
"foo=bar&foo=baz",
DecodeOptions(duplicates = Duplicates.LAST)
)
// => mapOf("foo" to "baz")
QS.decode(
"foo=bar&foo=baz&items[]=a&items[]=b",
DecodeOptions(duplicates = Duplicates.LAST)
)
// => mapOf("foo" to "baz", "items" to listOf("a", "b"))Java:
QS.decode("foo=bar&foo=baz");
// => {foo=[bar, baz]}
QS.decode(
"foo=bar&foo=baz",
DecodeOptions.builder()
.duplicates(Duplicates.COMBINE)
.build()
);
// => same as above
QS.decode(
"foo=bar&foo=baz",
DecodeOptions.builder()
.duplicates(Duplicates.FIRST)
.build()
);
// => {foo=bar}
QS.decode(
"foo=bar&foo=baz",
DecodeOptions.builder()
.duplicates(Duplicates.LAST)
.build()
);
// => {foo=baz}
QS.decode(
"foo=bar&foo=baz&items[]=a&items[]=b",
DecodeOptions.builder()
.duplicates(Duplicates.LAST)
.build()
);
// => {foo=baz, items=[a, b]}Bracket-list notation ([]) always combines values, regardless of duplicates.
Strict merge
When a key appears as both an object and a scalar, qs-kotlin wraps the conflict in a list by default:
Kotlin:
QS.decode("a[b]=c&a=d")
// => mapOf("a" to listOf(mapOf("b" to "c"), "d"))
QS.decode(
"a[b]=c&a=d",
DecodeOptions(strictMerge = false)
)
// => mapOf("a" to mapOf("b" to "c", "d" to true))Java:
QS.decode("a[b]=c&a=d");
// => {a=[{b=c}, d]}
QS.decode(
"a[b]=c&a=d",
DecodeOptions.builder()
.strictMerge(false)
.build()
);
// => {a={b=c, d=true}}Charset and sentinel
Kotlin:
// latin1
QS.decode(
"a=%A7",
DecodeOptions(charset = StandardCharsets.ISO_8859_1)
)
// => mapOf("a" to "§")
// Sentinels
QS.decode(
"utf8=%E2%9C%93&a=%C3%B8",
DecodeOptions(
charset = StandardCharsets.ISO_8859_1,
charsetSentinel = true
)
)
// => mapOf("a" to "ø")
QS.decode(
"utf8=%26%2310003%3B&a=%F8",
DecodeOptions(
charset = StandardCharsets.UTF_8,
charsetSentinel = true
)
)
// => mapOf("a" to "ø")Java:
QS.decode(
"a=%A7",
DecodeOptions.builder()
.charset(StandardCharsets.ISO_8859_1)
.build()
);
// => {a=§}
QS.decode(
"utf8=%E2%9C%93&a=%C3%B8",
DecodeOptions.builder()
.charset(StandardCharsets.ISO_8859_1)
.charsetSentinel(true)
.build()
);
// => {a=ø}
QS.decode(
"utf8=%26%2310003%3B&a=%F8",
DecodeOptions.builder()
.charset(StandardCharsets.UTF_8)
.charsetSentinel(true)
.build()
);
// => {a=ø}Interpret numeric entities (Ӓ)
Kotlin:
QS.decode(
"a=%26%239786%3B",
DecodeOptions(
charset = StandardCharsets.ISO_8859_1,
interpretNumericEntities = true
)
)
// => mapOf("a" to "☺")Java:
QS.decode(
"a=%26%239786%3B",
DecodeOptions.builder()
.charset(StandardCharsets.ISO_8859_1)
.interpretNumericEntities(true)
.build()
);
// => {a=☺}Lists
Kotlin:
QS.decode("a[]=b&a[]=c")
// => mapOf("a" to listOf("b", "c"))
QS.decode("a[1]=c&a[0]=b")
// => mapOf("a" to listOf("b", "c"))
QS.decode("a[1]=b&a[15]=c")
// => mapOf("a" to listOf("b", "c"))
QS.decode("a[]=&a[]=b")
// => mapOf("a" to listOf("", "b"))Java:
QS.decode("a[]=b&a[]=c");
// => {a=[b, c]}
QS.decode("a[1]=c&a[0]=b");
// => {a=[b, c]}
QS.decode("a[1]=b&a[15]=c");
// => {a=[b, c]}
QS.decode("a[]=&a[]=b");
// => {a=["", b]}listLimit is the maximum list element count. Indices greater than or equal to the limit convert to a map by default:
Kotlin:
QS.decode("a[100]=b")
// => mapOf("a" to mapOf("100" to "b"))Java:
QS.decode("a[100]=b");
// => {a={100=b}}Disable list parsing:
Kotlin:
QS.decode(
"a[]=b",
DecodeOptions(parseLists = false)
)
// => mapOf("a" to mapOf("0" to "b"))Java:
QS.decode(
"a[]=b",
DecodeOptions.builder()
.parseLists(false)
.build()
);
// => {a={0=b}}Mixing notations merges into a map:
Kotlin:
QS.decode("a[0]=b&a[b]=c")
// => mapOf("a" to mapOf("0" to "b", "b" to "c"))Java:
QS.decode("a[0]=b&a[b]=c");
// => {a={0=b, b=c}}Comma-separated values:
Kotlin:
QS.decode(
"a=b,c",
DecodeOptions(comma = true)
)
// => mapOf("a" to listOf("b", "c"))Java:
QS.decode(
"a=b,c",
DecodeOptions.builder()
.comma(true)
.build()
);
// => {a=[b, c]}When comma = true, comma-split values also honor listLimit. If throwOnLimitExceeded = true, decode throws; otherwise over-limit comma results convert to a map while preserving all values.
Primitive/scalar values
All values decode as strings by default:
Kotlin:
QS.decode("a=15&b=true&c=null")
// => mapOf("a" to "15", "b" to "true", "c" to "null")Java:
QS.decode("a=15&b=true&c=null");
// => {a=15, b=true, c=null}Encoding
Basics
Kotlin:
QS.encode(mapOf("a" to "b"))
// => "a=b"
QS.encode(mapOf("a" to mapOf("b" to "c")))
// => "a%5Bb%5D=c"Java:
QS.encode(Map.of("a", "b"));
// => "a=b"
QS.encode(Map.of("a", Map.of("b", "c")));
// => "a%5Bb%5D=c"Disable URI encoding for readability:
Kotlin:
QS.encode(
mapOf("a" to mapOf("b" to "c")),
EncodeOptions(encode = false)
)
// => "a[b]=c"Java:
QS.encode(
Map.of("a", Map.of("b", "c")),
EncodeOptions.builder()
.encode(false)
.build()
);
// => "a[b]=c"Values-only encoding:
Kotlin:
QS.encode(
mapOf(
"a" to "b",
"c" to listOf("d", "e=f"),
"f" to listOf(listOf("g"), listOf("h")),
),
EncodeOptions(encodeValuesOnly = true)
)
// => "a=b&c[0]=d&c[1]=e%3Df&f[0][0]=g&f[1][0]=h"Java:
Map<String, Object> map = new LinkedHashMap<>();
map.put("a", "b");
map.put("c", List.of("d", "e=f"));
map.put("f", List.of(List.of("g"), List.of("h")));
QS.encode(
map,
EncodeOptions.builder()
.encodeValuesOnly(true)
.build()
);
// => "a=b&c[0]=d&c[1]=e%3Df&f[0][0]=g&f[1][0]=h"Custom encoder:
Kotlin:
QS.encode(
mapOf("a" to mapOf("b" to "č")),
EncodeOptions(
encoder = { v, _, _ -> if (v == "č") "c" else v.toString() }
)
)
// => "a[b]=c" (with encode=false would be unescaped)Java:
JValueEncoder enc = (v, cs, f) -> Objects.equals(v, "č") ? "c" : Objects.toString(v, "");
QS.encode(
Map.of("a", Map.of("b", "č")),
EncodeOptions.builder()
.encoder(enc)
.build()
);
// => "a%5Bb%5D=c"List formats
Kotlin:
// default (indices)
QS.encode(
mapOf("a" to listOf("b", "c")),
EncodeOptions(encode = false)
)
// => "a[0]=b&a[1]=c"
// brackets
QS.encode(
mapOf("a" to listOf("b", "c")),
EncodeOptions(
encode = false,
listFormat = ListFormat.BRACKETS
)
)
// => "a[]=b&a[]=c"
// repeat
QS.encode(
mapOf("a" to listOf("b", "c")),
EncodeOptions(
encode = false,
listFormat = ListFormat.REPEAT
)
)
// => "a=b&a=c"
// comma
QS.encode(
mapOf("a" to listOf("b", "c")),
EncodeOptions(
encode = false,
listFormat = ListFormat.COMMA
)
)
// => "a=b,c"Java:
QS.encode(
Map.of("a", List.of("b","c")),
EncodeOptions.builder()
.encode(false)
.listFormat(ListFormat.INDICES)
.build()
);
// => "a[0]=b&a[1]=c"
QS.encode(
Map.of("a", List.of("b","c")),
EncodeOptions.builder()
.encode(false)
.listFormat(ListFormat.BRACKETS)
.build()
);
// => "a[]=b&a[]=c"
QS.encode(
Map.of("a", List.of("b","c")),
EncodeOptions.builder()
.encode(false)
.listFormat(ListFormat.REPEAT)
.build()
);
// => "a=b&a=c"
QS.encode(
Map.of("a", List.of("b","c")),
EncodeOptions.builder()
.encode(false)
.listFormat(ListFormat.COMMA)
.build()
);
// => "a=b,c"Note: When ListFormat.COMMA is selected, you can also set EncodeOptions.commaRoundTrip to true or false to append [] on single-element lists so they round-trip through decoding. Set EncodeOptions.commaCompactNulls to true alongside the comma format when you'd like to drop null entries instead of preserving empty slots (for example, listOf("one", null, "two") becomes one,two).
Nested maps
Kotlin:
QS.encode(
mapOf("a" to mapOf("b" to mapOf("c" to "d", "e" to "f"))),
EncodeOptions(encode = false)
)
// => "a[b][c]=d&a[b][e]=f"Java:
Map<String, Object> inner = new LinkedHashMap<>();
inner.put("c","d"); inner.put("e","f");
Map<String, Object> mid = new LinkedHashMap<>(); mid.put("b", inner);
Map<String, Object> root = new LinkedHashMap<>(); root.put("a", mid);
QS.encode(
root,
EncodeOptions.builder()
.encode(false)
.build()
);
// => "a[b][c]=d&a[b][e]=f"Dot notation:
Kotlin:
QS.encode(
mapOf("a" to mapOf("b" to mapOf("c" to "d", "e" to "f"))),
EncodeOptions(
encode = false,
allowDots = true
)
)
// => "a.b.c=d&a.b.e=f"Java:
QS.encode(
root,
EncodeOptions.builder()
.allowDots(true)
.encode(false)
.build()
);
// => "a.b.c=d&a.b.e=f"Encode dots in keys:
Kotlin:
QS.encode(
mapOf("name.obj" to mapOf("first" to "John", "last" to "Doe")),
EncodeOptions(
allowDots = true,
encodeDotInKeys = true
)
)
// => "name%252Eobj.first=John&name%252Eobj.last=Doe"Java:
QS.encode(
Map.of("name.obj", Map.of("first","John","last","Doe")),
EncodeOptions.builder()
.allowDots(true)
.encodeDotInKeys(true)
.build()
);
// => "name%252Eobj.first=John&name%252Eobj.last=Doe"Allow empty lists:
Kotlin:
QS.encode(
mapOf("foo" to emptyList<String>(), "bar" to "baz"),
EncodeOptions(
encode = false,
allowEmptyLists = true
)
)
// => "foo[]&bar=baz"Java:
Map<String, Object> emptyMap = new LinkedHashMap<>();
emptyMap.put("foo", List.of()); emptyMap.put("bar", "baz");
QS.encode(
emptyMap,
EncodeOptions.builder()
.allowEmptyLists(true)
.encode(false)
.build()
);
// => "foo[]&bar=baz"Empty strings & nulls:
Kotlin:
QS.encode(mapOf("a" to ""))
// => "a="Java:
QS.encode(Map.of("a", ""));
// => "a="Return empty string for empty containers:
Kotlin:
QS.encode(mapOf("a" to emptyList<String>())) // => ""
QS.encode(mapOf("a" to emptyMap<String, Any>())) // => ""
QS.encode(mapOf("a" to listOf(emptyMap<String, Any>()))) // => ""
QS.encode(mapOf("a" to mapOf("b" to emptyList<String>()))) // => ""
QS.encode(mapOf("a" to mapOf("b" to emptyMap<String, Any>()))) // => ""Java:
QS.encode(Map.of("a", List.of())); // => ""
QS.encode(Map.of("a", Map.of())); // => ""
QS.encode(Map.of("a", List.of(Map.of()))); // => ""
QS.encode(Map.of("a", Map.of("b", List.of()))); // => ""
QS.encode(Map.of("a", Map.of("b", Map.of()))); // => ""Omit Undefined:
Kotlin:
QS.encode(mapOf("a" to null, "b" to Undefined()))
// => "a="Java:
Map<String, Object> omit = new LinkedHashMap<>();
omit.put("a", null); omit.put("b", Undefined.INSTANCE);
QS.encode(omit);
// => "a="Add query prefix:
Kotlin:
QS.encode(
mapOf("a" to "b", "c" to "d"),
EncodeOptions(addQueryPrefix = true)
)
// => "?a=b&c=d"Java:
QS.encode(
Map.of("a","b","c","d"),
EncodeOptions.builder()
.addQueryPrefix(true)
.build()
);
// => "?a=b&c=d"Custom delimiter:
Kotlin:
QS.encode(
mapOf("a" to "b", "c" to "d"),
EncodeOptions(delimiter = Delimiter.SEMICOLON)
)
// => "a=b;c=d"Java:
QS.encode(
Map.of("a","b","c","d"),
EncodeOptions.builder()
.delimiter(Delimiter.SEMICOLON)
.build()
);
// => "a=b;c=d"Dates
By default, LocalDateTime is serialized using toString().
Kotlin:
val date = java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(7), java.time.ZoneId.systemDefault())
QS.encode(mapOf("a" to date), EncodeOptions(encode = false))
// => "a=1970-01-01T01:00:00.007" (example output depends on system zone)
QS.encode(
mapOf("a" to date),
EncodeOptions(
encode = false,
dateSerializer = { d -> d.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli().toString() }
)
)
// => "a=7"Java:
var date = java.time.LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(7), java.time.ZoneId.systemDefault());
QS.encode(
Map.of("a", date),
EncodeOptions.builder()
.encode(false)
.build()
);
// => "a=1970-01-01T01:00:00.007" (example output depends on system zone)
QS.encode(
Map.of("a", date),
EncodeOptions.builder()
.encode(false)
.dateSerializer(d -> Long.toString(d.atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()))
.build()
);
// => "a=7"Sorting & filtering
Kotlin:
// Sort keys
QS.encode(
mapOf("a" to "c", "z" to "y", "b" to "f"),
EncodeOptions(
encode = false,
sort = { a, b -> a.toString().compareTo(b.toString()) }
)
)
// => "a=c&b=f&z=y"
// Filter by function (drop/transform values)
QS.encode(
mapOf("a" to "b", "c" to "d", "e" to mapOf("f" to java.time.Instant.ofEpochMilli(123), "g" to listOf(2))),
EncodeOptions(
encode = false,
filter = FunctionFilter { prefix, value ->
when (prefix) {
"b" -> Undefined()
"e[f]" -> (value as java.time.Instant).toEpochMilli()
"e[g][0]" -> (value as Number).toInt() * 2
else -> value
}
}
)
)
// => "a=b&c=d&e[f]=123&e[g][0]=4"
// Filter by explicit list of keys/indices
QS.encode(
mapOf("a" to "b", "c" to "d", "e" to "f"),
EncodeOptions(
encode = false,
filter = IterableFilter(listOf("a", "e"))
)
)
// => "a=b&e=f"
QS.encode(
mapOf("a" to listOf("b", "c", "d"), "e" to "f"),
EncodeOptions(
encode = false,
filter = IterableFilter(listOf("a", 0, 2))
)
)
// => "a[0]=b&a[2]=d"Java:
// Sort keys
QS.encode(
Map.of("a","c","z","y","b","f"),
EncodeOptions.builder()
.encode(false)
.sort(Comparator.comparing(o -> o.toString()))
.build()
);
// => "a=c&b=f&z=y"
// Function filter
Map<String, Object> input = new LinkedHashMap<>();
input.put("a","b"); input.put("c","d");
Map<String, Object> eMap = new LinkedHashMap<>();
eMap.put("f", java.time.Instant.ofEpochMilli(123));
eMap.put("g", List.of(2));
input.put("e", eMap);
FunctionFilter fn = FunctionFilter.from((k,v) -> switch(k) {
case "b" -> Undefined.INSTANCE;
case "e[f]" -> ((java.time.Instant)v).toEpochMilli();
case "e[g][0]" -> ((Number)v).intValue()*2;
default -> v;
});
QS.encode(
input,
EncodeOptions.builder()
.encode(false)
.filter(fn)
.build()
);
// => "a=b&c=d&e[f]=123&e[g][0]=4"
// Iterable filters
QS.encode(
Map.of("a","b","c","d","e","f"),
EncodeOptions.builder()
.encode(false)
.filter(new IterableFilter(List.of("a","e")))
.build()
);
// => "a=b&e=f"
QS.encode(
Map.of("a", List.of("b","c","d"), "e","f"),
EncodeOptions.builder()
.encode(false)
.filter(new IterableFilter(List.of("a",0,2)))
.build()
);
// => "a[0]=b&a[2]=d"RFC 3986 vs RFC 1738 space encoding
Kotlin:
QS.encode(mapOf("a" to "b c"))
// => "a=b%20c" (RFC 3986 default)
QS.encode(
mapOf("a" to "b c"),
EncodeOptions(format = Format.RFC3986)
)
// => "a=b%20c"
QS.encode(
mapOf("a" to "b c"),
EncodeOptions(format = Format.RFC1738)
)
// => "a=b+c"Java:
QS.encode(Map.of("a","b c"));
// => "a=b%20c" (RFC 3986 default)
QS.encode(
Map.of("a","b c"),
EncodeOptions.builder()
.format(Format.RFC3986)
.build()
);
// => "a=b%20c"
QS.encode(
Map.of("a","b c"),
EncodeOptions.builder()
.format(Format.RFC1738)
.build()
);
// => "a=b+c"Design notes
Performance: The implementation mirrors qs semantics but is optimized for Kotlin/JVM. Deep parsing, list compaction, and cycle-safe compaction are implemented iteratively where it matters.
Perf snapshot: For local optimization checks, run
./gradlew :comparison:run --args perfto print encode/decode timing and allocation snapshots.Safety: Defaults (depth, parameterLimit) help mitigate abuse in user-supplied inputs; you can loosen them when you fully trust the source.
Interop: Exposes knobs similar to qs (filters, sorters, custom encoders/decoders) to make migrations straightforward.
Special thanks to the authors of qs for JavaScript:
Other ports
| Port | Repository | Package |
|---|---|---|
| Dart | techouse/qs | |
| Python | techouse/qs_codec | |
| Swift / Objective-C | techouse/qs-swift | |
| .NET / C# | techouse/qs-net | |
| Rust | techouse/qs_rust | |
| Node.js (original) | ljharb/qs |
License
BSD 3-Clause © techouse