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 API
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.
- 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/Instantserialization via a pluggable serializer - Extensive tests (Kotest), performance-minded implementation
Kotlin:
dependencies { implementation("io.github.techouse:qs-kotlin:<version>") }Java (Gradle Groovy DSL):
dependencies { implementation 'io.github.techouse:qs-kotlin:<version>' }Kotlin:
dependencies { implementation("io.github.techouse:qs-kotlin-android:<version>") }Java (Gradle Groovy DSL):
dependencies { implementation 'io.github.techouse:qs-kotlin-android:<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' }- Kotlin 2.2.20+
- Java 17+
- Android wrapper: AGP 8.7+,
compileSdk 35,minSdk 25
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"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"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}}}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}}}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}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}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}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}}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}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")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}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=ø}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=☺}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]}Large indices 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]}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}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"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).
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"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"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"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"- 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.
- 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:
| Port | Repository | Package |
|---|---|---|
| Dart | techouse/qs | |
| Python | techouse/qs_codec | |
| Swift / Objective-C | techouse/qs-swift | |
| .NET / C# | techouse/qs-net | |
| Node.js (original) | ljharb/qs |
BSD 3-Clause © techouse
