Skip to main content
Version: dev

Kotlin

Fory JSON Kotlin maps Kotlin/JVM types to ordinary JSON while preserving Kotlin constructor defaults, nullability, value types, and generic arguments. It is an optional module layered on Fory JSON; it does not change Fory's binary protocols.

Installation​

The runtime accepts model metadata supported by Kotlin's strict metadata reader and is built with Kotlin 2.3.20. Use the same Fory version for every module:

build.gradle.kts
plugins {
kotlin("jvm") version "2.3.20"
}

repositories {
maven("https://repository.apache.org/snapshots/") {
mavenContent { snapshotsOnly() }
}
mavenCentral()
}

dependencies {
implementation("org.apache.fory:fory-json-kotlin:1.7.7")
}

The Kotlin module does not require kotlin-reflect. On Android, add KSP when R8 or ProGuard is enabled or when a Kotlin-source Mixin adds inferred JsonSubTypes to a Java sealed target:

build.gradle.kts
plugins {
id("com.google.devtools.ksp") version "2.3.8"
}

dependencies {
ksp("org.apache.fory:fory-json-kotlin-ksp:1.7.7")
}

Annotate every Kotlin source model that needs exact minification retention with @JsonType. For a third-party target, declare an exact @JsonMixin in application source instead. Use Kotlin KSP for a Mixin when either the Mixin or its exact target is Kotlin. If a Kotlin-source Mixin adds inferred JsonSubTypes to a Java sealed target, also enable fory-annotation-processor and compile on JDK 17 or newer.

Quick start​

Use ForyJsonKotlin.builder() to install the Kotlin module. Retain a jsonTypeRef<T>() for every declared Kotlin root whose nullability, unsigned identity, value-class identity, or generic arguments matter:

import org.apache.fory.json.kotlin.ForyJsonKotlin
import org.apache.fory.json.kotlin.jsonTypeRef

data class Account(
val id: ULong,
val name: String,
val nickname: String? = null,
)

val json = ForyJsonKotlin.builder().build()
val accountType = jsonTypeRef<Account>()

val text = json.toJson(Account(7u, "Alice"), accountType)
val decoded = json.fromJson(text, accountType)

Use ForyJsonKotlin.builder().escapeNonAscii(true) to escape non-ASCII string contents and names in compact and pretty output. The setting is fixed per instance; raw JSON remains verbatim. See Non-ASCII escaping.

Use ForyJsonKotlin.builder().writeLongAsString(true) when signed Long and unsigned ULong values must be emitted as quoted decimal strings. The setting also applies to their declared collection and map values, nullable values, Kotlin value classes backed by them, ULongArray, and the Java Long-like wrappers supported by the core JSON runtime. Readers accept both quoted and unquoted integer tokens.

ByteArray uses Base64 strings by default. The standard builder's byteArrayFormat also controls root arrays and arrays nested in properties, collections, and maps:

import org.apache.fory.json.annotation.JsonByteArray
import org.apache.fory.json.kotlin.ForyJsonKotlin
import org.apache.fory.json.kotlin.jsonTypeRef

val hexJson = ForyJsonKotlin.builder().byteArrayFormat(JsonByteArray.Format.BASE16).build()
val bytesType = jsonTypeRef<ByteArray>()
val text = hexJson.toJson(byteArrayOf(1, -2, 3), bytesType) // "\"01fe03\""
val bytes = hexJson.fromJson(text, bytesType)

@field:JsonByteArray or @get:JsonByteArray, including a Mixin declaration, overrides the default for that property. UByteArray retains its unsigned numeric-array representation. See byte-array formats for accepted input and null behavior.

jsonTypeRef<T>() is a type token, not a codec lookup. Construct it once and reuse it. A Java Class or ordinary Java TypeRef cannot express distinctions such as List<Account?>, UInt, or a logical value class lowered to a primitive carrier.

The builder can also install the module explicitly:

import org.apache.fory.json.ForyJson
import org.apache.fory.json.kotlin.ForyJsonKotlin

val json = ForyJson.builder().withModule(ForyJsonKotlin).build()

There is no automatic classpath installation or Kotlin-specific encode/decode alias.

For framework callbacks with a runtime Kotlin KType, use jsonTypeRef<Any?>(kType). See Kotlin integration for controller type discovery, request and response conversion, and Spring MVC adapter requirements.

Immutable classes and compiler defaults​

An ordinary or data class is mapped as a named JSON object. Fory selects one valid public Kotlin constructor and reconstructible properties; copy and componentN functions do not define the schema. A compiler-generated default is used only when its JSON member is absent:

data class Request(
val id: Long,
val label: String? = "new",
val retries: Int = 3,
)

The primary constructor is automatic. A public secondary constructor or target-owned public static factory is used only when explicitly selected by JsonCreator; a companion factory qualifies only through its real outer-class @JvmStatic bridge. @JvmOverloads artifacts are not separate creator candidates, and a selected static factory cannot have compiler-default parameters. Private/protected, vararg, executable-generic, context-parameter, local, anonymous, inner, or synthetic construction requires an exact application codec.

With the default onNullRead(SET), this model behaves as follows:

  • {"id":1} invokes both compiler defaults.
  • {"id":1,"label":null} passes an explicit null and does not invoke the label default.
  • a missing id uses 0.
  • {"id":1,"retries":null} fails.

Use ForyJsonKotlin.builder().onNullRead(JsonProperty.NullHandling.SKIP) to treat a null occurrence as absent, preserving constructor defaults and body-property initializers. A non-null reference without a default remains required. Override one property with @get:JsonProperty(onNullRead = JsonProperty.NullHandling.SET) to retain ordinary null decoding. onContentNullRead handles immediate collection/array elements and map values, including unsigned arrays. Property overrides are shallow and skipped nulls are handled before element nullability checks. See Read-side null handling.

Enable ForyJsonKotlin.builder().failOnMissingRequiredProperties(true).build() to require ordinary constructor properties without declared defaults, including nullable properties and numeric or Boolean properties. Compiler defaults still apply when declared. For example, a missing id in the model above is then an error, while missing label and retries still use their defaults. Nullability alone does not make a property optional in this mode: a nullable property without a default must appear, but its value may be explicit null. This option defaults to false and does not change writing, body-property behavior, or existing non-null constraints.

Normal body var properties preserve their initializer when absent and are assigned after construction when present. A lateinit property is required. Automatic creator and deferred properties must be reconstructible in both read and write directions.

A body val, computed or delegated property, getter-only property, or delegated var must be ignored or handled by an exact custom codec. Present deferred setters run in fixed property order after construction, followed by validators; input member order does not choose application call order. Kotlin class instances are always constructed normally, so primary-constructor initialization and validation are not bypassed.

Kotlin properties follow the configured property inclusion, including constructor parameters and body properties. The default NON_NULL omits null values; NON_EMPTY also omits empty strings, arrays, collections, and maps, and absent JDK Optional values. A property's @JsonProperty(include = ...) overrides the class's @JsonInclude policy, which overrides the builder default. Use ALWAYS or writeNullFields(true) to retain null values.

import org.apache.fory.json.annotation.JsonProperty.Include
import org.apache.fory.json.kotlin.ForyJsonKotlin

data class Response(
val id: Int,
val name: String? = null,
val items: List<String>? = null,
)

val json = ForyJsonKotlin.builder().defaultPropertyInclusion(Include.NON_EMPTY).build()
val text = json.toJson(Response(1, items = emptyList())) // {"id":1}

Inclusion affects writing only. Reading the example's output uses the declared items default of null, so the original empty list is not preserved. A missing constructor parameter uses its declared default first. Otherwise, non-null numeric and Boolean parameters use zero and false, nullable parameters use null, and other non-null parameters remain required. A missing body property keeps its initializer. Explicit null remains distinct from a missing field and is rejected for a non-nullable property. Choose an inclusion rule that retains values when exact round trips are required.

NON_DEFAULT requires explicit field authorization with @JsonProperty, or class authorization with @JsonInclude. Comparing against a reference object is supported when every parameter of the selected constructor has a language default, or the model uses an ordinary no-argument constructor. Fory constructs one reference object per initialized model metadata and captures authorized property values. This executes the complete constructor, all initializers, and init blocks. It is not repeated on each write, and models without authorization do not construct a reference. Dynamic and declared model occurrences can initialize separately.

Both field-level and class-level authorization retain constructor properties without defaults and lateinit properties, including null, zero, false, and empty values. A defaulted property that needs a reference object is rejected when the selected constructor also has required parameters, even if another constructor has no arguments. For example, class-level NON_DEFAULT on data class Key(val id: Int) writes {"id":0} for Key(0). For data class Entry(val id: Int, val count: Int = 1), the same class policy cannot compare count without inventing id; give count an ALWAYS override to retain it. Fory does not fabricate parameters, borrow them from the first object, change creator selection, bypass a constructor, or analyze bytecode. Failed construction reports the model and property. Global defaultPropertyInclusion(NON_DEFAULT) is rejected.

Kotlin metadata indicates default presence but does not reveal expressions or dependencies. The caller must confirm stable defaults, no externally visible side effects from reference construction, and matching recovery for missing fields. A fixed reference cannot represent defaults that depend on the current input, time, randomness, or external state. Exclude those properties:

import org.apache.fory.json.annotation.JsonInclude
import org.apache.fory.json.annotation.JsonProperty
import org.apache.fory.json.annotation.JsonProperty.Include
import org.apache.fory.json.kotlin.ForyJsonKotlin

@JsonInclude(Include.NON_DEFAULT)
data class Limits(
val low: Int = 1,
@param:JsonProperty(include = Include.ALWAYS) val high: Int = low + 1
)

val json = ForyJsonKotlin.builder().build()
json.toJson(Limits()) // {"high":2}
json.toJson(Limits(5, 2)) // {"low":5,"high":2}
json.fromJson("""{"low":5}""", Limits::class.java) // Limits(5, 6)

Here high=2 must remain present when low=5: omitting it would restore high=6. Parameter dependencies are not universally unsafe, but this fixed reference cannot supply their changing context. To authorize only individual fields, omit JsonInclude and put @param:JsonProperty(include = Include.NON_DEFAULT) on those fields. A registered Mixin may provide the class or field policy without editing the model. Class authorization also covers future fields; maintainers must verify each new default or exclude it with ALWAYS.

Reading still invokes the normal Kotlin defaults for missing fields. Explicit null is not missing. The reference object and its mutable collections are never shared with decoded objects. Comparison uses primitive values, reference equality contracts (equals), and array contents; see Default omission. When minifying, use the existing JSON KSP setup. Default evaluation takes place at runtime, not during KSP processing.

An empty list is omitted by NON_EMPTY regardless of whether its default is null, emptyList(), or a non-empty list. Empty underlying string or collection carriers do not make non-null value-class properties empty. An explicit NON_EMPTY is unsupported when an unboxed value class itself implements CharSequence, Collection, or Map; use a containing custom codec for that shape.

Nullability​

Kotlin occurrence nullability is enforced at roots, properties, container elements, map values, and generic children:

DeclarationMissing memberExplicit JSON null
val value: StringFailsFails
val value: String?Passes nullPasses null
val value: IntUses 0Fails
val value: BooleanUses falseFails
val value: Int?Passes nullPasses null
val value: String = expressionEvaluates the defaultFails
val value: String? = expressionEvaluates the defaultPasses null

List<String?> accepts null elements; List<String> rejects them. Map keys must be non-null. Platform/unknown nullability is not guessed for automatic Kotlin construction.

Transparent wrappers must have one unambiguous null meaning. For example, non-null Optional<T> uses JSON null for Optional.empty(), so Optional<T>? and Optional<T?> are rejected. AtomicReference<T> and value classes follow the same injective-shape rule. Use an exact custom tagged codec when two logical states would otherwise share JSON null.

An application codec selected by registration or @JsonCodec remains trusted application code. For a non-null JSON token, it must return the exact declared type and honor Kotlin occurrence nullability.

Annotations and use-site targets​

Kotlin annotations feed the same logical-property merge used by Java. Prefer explicit use-site targets:

import org.apache.fory.json.annotation.JsonCodec
import org.apache.fory.json.annotation.JsonIgnore
import org.apache.fory.json.annotation.JsonProperty
import org.apache.fory.json.annotation.JsonType

@JsonType
data class Profile(
@param:JsonProperty("user_id")
val id: Long,

@field:JsonIgnore
val localCacheKey: String? = null,

@field:JsonProperty(include = JsonProperty.Include.ALWAYS)
val nickname: String? = null,

@field:JsonCodec(elementCodec = AccountIdCodec::class)
val accountIds: List<AccountId>,
)

Use explicit JVM use-site targets as shown so behavior does not depend on Kotlin's default-target policy. See Annotations for the complete supported-target and conflict rules, and Custom Codecs for complete-value, element, content, key, and map-value codecs.

Generics and collections​

Use complete declared types:

import org.apache.fory.json.kotlin.jsonTypeRef

val accountsType = jsonTypeRef<List<Account?>>()
val accounts = json.fromJson("""[null,{"id":7,"name":"Alice"}]""", accountsType)

Kotlin raw strings can be passed directly to fromJson; JSON double quotes do not need backslash escaping.

Raw generic types, in projections, and star projections are rejected for typed restoration. An out X projection is accepted only when it normalizes to one exact final or closed readable schema. Recursive generic models are supported when recursion returns to the same exact binding; an active raw declaration that expands to a different binding is rejected.

Kotlin read-only and mutable collection interfaces use the normal JSON collection/map behavior. Read-only does not mean immutable: standard interfaces materialize as ArrayList, LinkedHashSet, or LinkedHashMap. Declare public interfaces instead of JDK/Kotlin private empty, singleton, unmodifiable, or builder implementation classes. Iterable, Sequence, iterators, EnumEntries, and a directly declared Map.Entry are not automatic value schemas.

Unsigned and eligible non-null value-class map keys use JSON object member names through the normal map codec. A value-class key chain must terminate in String, enum, signed integer, or unsigned integer semantics. Floating, Boolean, nullable, and arbitrary object keys require an explicit key or whole-map codec.

Value classes, objects, and closed hierarchies​

A user value class has the transparent JSON shape of its underlying value:

import org.apache.fory.json.kotlin.jsonTypeRef

@JvmInline
value class AccountId(val value: ULong)

val idType = jsonTypeRef<AccountId>()
val id = json.fromJson("18446744073709551615", idType)

Fory executes the compiler's validated constructor operation, so value-class initialization checks still run. If both the outer value and its underlying value can be null, transparent JSON is ambiguous and automatic mapping is rejected. Use a tagged exact codec for that case.

A stateless object or data object uses strict {} and returns the canonical singleton. A stateful object and a companion object require an exact custom codec. Unit also uses {}; Nothing is rejected, while Nothing? accepts only JSON null through its explicit Kotlin type token.

Annotate a sealed class or interface with JsonSubTypes and leave value empty to infer its closed hierarchy. Fory recursively includes concrete sealed descendants and stops at each concrete open class, admitting that exact class but not its descendants. An open abstract branch is rejected. Inferred logical names are source simple names, including object names without a trailing $. A non-empty value remains an exact explicit subset. Input contains a logical subtype name, never a JVM class name. See Annotations for the property and wrapper shapes.

Supported Kotlin types​

Java/JDK scalar, temporal, Optional, atomic, array, collection, map, enum, and JSON tree types keep their normal Fory JSON representation when used from Kotlin:

Core familyKotlin-visible behavior
Any / Any?natural JSON Boolean, number, String, array, object, or null; dynamic runtime dispatch on writes
signed scalars and boxesBoolean, Byte, Short, Int, Long, Float, Double, Char, and Number use the core scalar codecs; non-finite floating values use quoted core forms
textString, exact CharSequence, StringBuilder, and StringBuffer use String shapes
arbitrary/reduced-precision numberBigInteger, BigDecimal, Fory Float16, and BFloat16 use their core numeric shapes and limits
enumquoted enum constant name
Java/Kotlin arraysnormal JSON arrays except ByteArray, which uses Base64 strings by default; @field:JsonByteArray(JsonByteArray.Format.ARRAY) selects numeric arrays; unsigned semantic arrays are listed below
Optional and atomicOptional<T>, primitive Optionals, atomic scalars/references, and atomic arrays keep their transparent core shapes subject to the nullability rules above
quoted JDK valuesCurrency, File, URI, Path, Pattern, UUID, Locale, Charset, and TimeZone keep their core String shapes
legacy date/timeDate, Calendar, and available java.sql.Date, Time, and Timestamp keep their epoch-millisecond shapes
Java timeLocalDate, LocalTime, LocalDateTime, Instant, java.time.Duration, ZoneOffset, ZoneId, ZonedDateTime, Year, YearMonth, MonthDay, Period, OffsetTime, OffsetDateTime, and supported chronology dates keep their exact core text grammars
other core valuesBitSet, ByteBuffer, JsonArray, and JsonObject keep their normal core shapes
collections/mapssupported Java/Kotlin Collection and Map interfaces and implementations use core array/object shapes; supported Guava immutable carriers remain optional
map keysString, enum, signed Byte/Short/Int/Long, and the Kotlin unsigned/value-class additions below; Boolean, floating, nullable, and arbitrary object keys need an explicit key or whole-map codec
fixed rejectionsClass, URL/network/socket/address families, unsupported JDK internal collection implementations, and unregistered Number/CharSequence subclasses remain rejected; an application may explicitly own a permitted custom representation subject to the normal type and security checks

See Object Mapping for the detailed core shapes. Kotlin-specific behavior is:

Type familyAutomatic JSON shape or decision
ordinary, data, and nested classesnamed JSON object
inner class, ordinary abstract class/interfaceclosed JsonSubTypes or exact custom codec only
sealed class/interfaceinferred or explicit closed JsonSubTypes table
enumquoted enum name
stateless object, data object, Unitstrict {}
stateful object, companion objectexact custom codec only
value classtransparent underlying value; exact binding required
Nothing? / Nothingnull-only / rejected
Pair, Triplenamed objects with first, second, and third
Result, Lazy, Kotlin standard property delegatesexact custom codec only
signed primitives, primitive arrays, Array<T>normal core number/Boolean/character and array shapes
UByte, UShort, UInt, ULong and their arraysunsigned decimal numbers and arrays
read-only and mutable collections/maps, Kotlin ArrayDequenormal core arrays/maps
Map.Entry, private collection carriers, Iterable, Sequence, iterators, EnumEntriesrejected automatically
CharRange, signed/unsigned integer ranges{"start":...,"endInclusive":...}
corresponding progressions{"first":...,"last":...,"step":...}
ClosedRange, OpenEndRange, abstract/open/floating rangesrejected automatically
kotlin.time.Durationquoted canonical Kotlin ISO duration
kotlin.time.Instantquoted canonical Kotlin ISO instant
TimedValue<T>{"value":...,"duration":...}
DurationUnit, RegexOptionquoted enum name
clocks, time sources/marks, Regex/match state, Randomexact custom codec only
kotlin.uuid.Uuidquoted canonical dashed UUID
complete generic class / declaration-site varianceexact substituted schema
out X / in X / star projectionexact final-or-closed X only / rejected / rejected
recursive genericsame exact recursive binding only; active expansion to another binding is rejected
typealiasits fully expanded type
function/suspend function, reflection types, coroutine/flow/channel staterejected
eligible third-party immutable Kotlin modelautomatic on the JVM; register an exact Mixin when applying annotation overlays

Kotlin experimental opt-in requirements for time and UUID APIs still apply to application source. The Fory artifact's supported compiler and metadata boundary does not turn an experimental Kotlin API into a cross-version Kotlin guarantee.

Security​

jsonTypeRef<T>(), annotations, Mixins, and codec registrations are application-declared schema. JSON input cannot select an arbitrary class, constructor, compiler default, object, companion, module, codec, or callable. A sealed hierarchy accepts only the logical subtype names in its validated inferred or explicit @JsonSubTypes table.

Kotlin arrays, collections, maps, and objects use the same maxDepth, graph-memory, input-buffer, field-name-cache, and type-checker controls as the core JSON runtime. There are no Kotlin-specific collection or workspace limits to configure. Model constructors, compiler defaults, validators, and custom codecs remain trusted application code and may have application-defined allocation or side effects.

See Security before decoding untrusted input.

GraalVM and Android​

On GraalVM Native Image, use the existing @ForyJsonProvider workflow, install ForyJsonKotlin, and enable code generation in the returned configuration. Annotate each reachable concrete Kotlin model with @JsonType, or register an exact reachable Mixin for a third-party target. Models not selected for code generation continue to use interpreted mapping. Only exact generic bindings reached through concrete roots are available. Do not add reflection configuration or package-wide opens.

On Android, use API 26 or later. Runtime JSON code generation remains disabled. Kotlin sealed inference needs no additional setup in an unminified build. Enable KSP when R8 or ProGuard is used or when a Kotlin-source Mixin adds inferred JsonSubTypes to a Java sealed target. The Mixin case also requires fory-annotation-processor and JDK 17 or newer. Follow the installation above and the Android guide when shrinking is enabled.

Kotlin/Native, Kotlin/JS, and Kotlin/Wasm are not supported by this JVM module.

See GraalVM Native Image and Android for complete platform setup.

Troubleshooting​

See Troubleshooting for Kotlin metadata, nullability, generic binding, Android shrinking, Native Image, syntax, limits, custom codecs, subtypes, and root-operation failures.

The source-aligned four-library benchmark methodology and publication status are in the Kotlin JSON benchmark report. No Kotlin result is inferred from the Java or Scala benchmark.