qs-kotlin

qs-kotlin

A query string encoding and decoding library for Android and Kotlin/JVM.

Ported from qs for JavaScript.

Test LICENSE

This repo provides:

  • qs-kotlin – the core JVM library (Jar)

  • qs-kotlin-android – a thin Android AAR wrapper that re-exports the same API

  • qs-kotlin-okhttp – optional OkHttp HttpUrl extensions for adding qs-style nested query parameters

  • qs-kotlin-ktor – optional Ktor URLBuilder, Url, and ApplicationRequest extensions

  • qs-kotlin-spring-web – optional Spring Web UriComponentsBuilder extension

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 toggles

  • UTF-8 and ISO-8859-1 charsets, plus optional charset sentinel (utf8=✓)

  • Custom encoders/decoders, key sorting, filtering, and strict null handling

  • Supports LocalDateTime/Instant serialization via a pluggable serializer

  • Extensive 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 < 26 and you use java.time transitively, 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 25

  • OkHttp 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=b

Immutable 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=2

List 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=android

This 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=b

Ktor 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=b

Immutable 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=2

List 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=ktor

Server 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=b

This 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 (&#1234;)

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 perf to 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

PortRepositoryPackage
Darttechouse/qs
Pythontechouse/qs_codec
Swift / Objective-Ctechouse/qs-swift
.NET / C#techouse/qs-net
Rusttechouse/qs_rust
Node.js (original)ljharb/qs

License

BSD 3-Clause © techouse

Packages

Link copied to clipboard
Link copied to clipboard
Link copied to clipboard