Annotate the fields you care about. The processor emits a *Differ
that compares two values of the same type. hasChanged is a bit test.
changes holds only what moved, keyed by id, never by list position.
The walk is ordinary generated Kotlin. No reflection, no JSON tree, no interpreter.
You mark the fields that matter. At compile time KSP writes a
*Differ of ordinary Kotlin ifs. There is no
reflection at runtime, no JSON tree, no interpreter. hasChanged
is a bit test. Old and new values are copied only for fields that moved.
Nested lists and maps are matched by id, so a shuffle is not a change.
You can write that walk yourself. On our bench it is a bit faster.
You then own every if, every bit, and every add/remove
branch. A new field is another edit. A rename can silently drop a check.
Differ regenerates the walk from the annotations. A tagged rename fails
the build. The result still has the bits, the sealed events, and the
keyed maps.
Both sides of a diff are the same Kotlin type. Convert
Avro, Mongo, or a Kafka payload when it arrives. If stored ids are
String and the event uses UUID, parse once at
that boundary. The generated walk then does plain ==.
| 256 child objects, nothing changed | time | vs this library |
|---|---|---|
| Hand-written walk of the same model | 13 µs | 0.6× |
| This library | 21 µs | 1× |
| JSON Patch (Jackson + zjsonpatch) | 0.82 ms | 39× |
| JaVers | 3.7 ms | 176× |
Java 17, Apple silicon. One nested document: site, two id-keyed link lists, an alarm set, a recipe map. All four rows match children by id and record old/new. JSON is Jackson plus zjsonpatch (RFC 6902). That library compares arrays by position, so the timed walk matches by id like Differ does. Re-measure on your documents.
// same type on both sides @Differ data class Pet( val id: String, @Tracked val name: String, @Tracked(captureValues = false) val notes: String, @Tracked val nickname: String?, @TrackedNested val owner: Person, @TrackedNested val vet: Person?, @TrackedList val toys: Set<Toy>, @TrackedList val walks: List<Walk>, @TrackedMap val tricks: Map<String, Trick>, @TrackedMap val tags: Map<String, String>, )
val diff = PetDiffer.diff(old, new) if (!diff.hasChanged) return if (diff.hasName) { /* bit test */ } for (change in diff.changes) { when (change) { is PetChange.ToysName -> { change.id change.old change.new } is PetChange.Vet -> change.new else -> {} } } diff.toys["t1"]?.name?.new diff.tags["color"]?.value?.new
Child types do not take @Differ. Their tagged fields fold into
the generated file. PetDiffer is the entry.
PetDiffer.diff returns a PetDiff. The bits and
events live on that result. A typo does not compile. A rename of a tagged
property fails the build. A new untagged field is invisible.
// generated. Do not edit. One hasX / event per tagged path. object PetDiffer { fun diff(old: Pet, new: Pet): PetDiff } class PetDiff { val hasChanged: Boolean val hasName: Boolean val hasNotes: Boolean val hasNickname: Boolean val hasOwnerAge: Boolean val hasToysName: Boolean val hasToysAdded: Boolean // ... val changes: List<PetChange> val toys: Map<String, PetToysChange> // ... } sealed interface PetChange { data class Name(val old: String, val new: String) data object Notes data class ToysName(val id: String, val old: String, val new: String) // ... }
This is the model KSP read. Each tagged path becomes a property on
PetDiff and a type on PetChange. The table
under it is one diff of that shape. There is no
has("toys[].name") string.
@Differ Pet id @Tracked name hasName @Tracked(captureValues = false) notes hasNotes @Tracked nickname: String? hasNickname @TrackedNested owner: Person @Tracked age hasOwnerAge @TrackedNested vet: Person? presence hasVet @Tracked age hasVetAge @TrackedList toys: Set<Toy> [+] [-] hasToysAdded hasToysRemoved @Tracked name hasToysName @TrackedList walks: List<Walk> [+] [-] hasWalksAdded hasWalksRemoved @Tracked park hasWalksPark @TrackedMap tricks: Map<String, Trick> [+] [-] hasTricksAdded hasTricksRemoved @Tracked level hasTricksLevel @TrackedMap tags: Map<String, String> [+] [-] hasTagsAdded hasTagsRemoved scalar value hasTags
| slot | generated bit | from | this run |
|---|---|---|---|
| 00 | hasName | @Tracked name | Hana → Hana-chan |
| 01 | hasNotes | @Tracked(captureValues = false) notes | bit on, no old/new |
| 02 | hasNickname | @Tracked nickname: String? | null → pup |
| 03 | hasOwnerAge | @TrackedNested owner + @Tracked age | 3 → 4 |
| 04 | hasVet | @TrackedNested vet: Person? | null → Kim. Presence only. No hasVetAge. |
| 05 | hasToysName | @TrackedList toys + @Tracked name | id t1, ball → squeaky |
| 06 | hasToysAdded | KSP [+] on toys | id t2, rope |
| 07 | hasToysRemoved | KSP [−] on toys | off |
| 08 | hasWalksPark | @TrackedList walks + @Tracked park | id w1, park → beach |
| 09 | hasWalksAdded | KSP [+] on walks | id w2, lake |
| 10 | hasTricksLevel | @TrackedMap tricks + @Tracked level | key sit, 1 → 2 |
| 11 | hasTricksAdded | KSP [+] on tricks | key paw |
| 12 | hasTags | @TrackedMap tags (scalar V) | key color, red → cream |
| 13 | hasTagsAdded | KSP [+] on tags | key size, shiba |
Two hundred toys still share hasToysName. Slots are kinds, not rows.
The mask is a Long, then a LongArray past 64 slots.
plugins {
kotlin("jvm") version "2.1.21" // or newer
id("com.google.devtools.ksp") version "2.1.21-2.0.2" // match your Kotlin
}
implementation("io.github.ultish:differ-annotations:0.1.12")
ksp("io.github.ultish:differ-processor:0.1.12")
Maven Central. Imports stay hana.differ.
Kotlin 2.1 is the floor. Apply a KSP plugin that matches
your Kotlin (2.1.21-2.0.2, 2.2.x-2.0.x,
2.3.x). The processor is the KSP 2.1 API. Newer KSP can
run it.
Lists, sets, and maps match by id (or map key), never by order.
TreeSet and TreeMap count. null list or map is empty.
Optional nested objects record presence. Both null is unchanged. One null
is a single event with the whole object. Primitive arrays are not keyed
collections. Mark them @Tracked and compare the whole array.