From fa3b20990490b8160ee62b27fd5afba023c5eb78 Mon Sep 17 00:00:00 2001 From: Nikhil Unni Date: Fri, 31 Oct 2025 08:38:26 -0400 Subject: [PATCH 1/8] feat(kotlin): Support for date and datetime from JSON schema --- .../language/Kotlin/KotlinJacksonRenderer.ts | 1 + .../src/language/Kotlin/KotlinRenderer.ts | 49 ++++++++++++++++++- .../src/language/Kotlin/language.ts | 14 ++++++ test/languages.ts | 4 +- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts index 3401ed1fe..c7c9b6532 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts @@ -49,6 +49,7 @@ export class KotlinJacksonRenderer extends KotlinRenderer { // and enums in the same union (_enumType) => "is TextNode", (_unionType) => mustNotHappen(), + (_transformedStringType) => "is TextNode", ); } diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts index 5e1c45b6b..544845b70 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts @@ -1,3 +1,5 @@ +import { iterableSome } from "collection-utils"; + import { anyTypeIssueAnnotation, nullTypeIssueAnnotation, @@ -19,7 +21,7 @@ import { type EnumType, MapType, type ObjectType, - type PrimitiveType, + PrimitiveType, type Type, type UnionType, } from "../../Type"; @@ -158,6 +160,15 @@ export class KotlinRenderer extends ConvenienceRenderer { ]; } + protected haveTransformedStringType( + kind: "date-time" | "date" | "time", + ): boolean { + return iterableSome( + this.typeGraph.allTypesUnordered(), + (t) => t instanceof PrimitiveType && t.kind === kind, + ); + } + protected kotlinType( t: Type, withIssues = false, @@ -194,6 +205,21 @@ export class KotlinRenderer extends ConvenienceRenderer { return [this.kotlinType(nullable, withIssues), optional]; return this.nameForNamedType(unionType); }, + (transformedStringType) => { + if (transformedStringType.kind === "date-time") { + return "OffsetDateTime"; + } + + if (transformedStringType.kind === "date") { + return "LocalDate"; + } + + if (transformedStringType.kind === "time") { + return "OffsetTime"; + } + + return "String"; + }, ); } @@ -211,6 +237,27 @@ export class KotlinRenderer extends ConvenienceRenderer { this.ensureBlankLine(); this.emitLine("package ", this._kotlinOptions.packageName); this.ensureBlankLine(); + + // Check if we need to import java.time classes + const usesDateTime = this.haveTransformedStringType("date-time"); + const usesDate = this.haveTransformedStringType("date"); + const usesTime = this.haveTransformedStringType("time"); + + if (usesDateTime) { + this.emitLine("import java.time.OffsetDateTime"); + } + + if (usesDate) { + this.emitLine("import java.time.LocalDate"); + } + + if (usesTime) { + this.emitLine("import java.time.OffsetTime"); + } + + if (usesDateTime || usesDate || usesTime) { + this.ensureBlankLine(); + } } protected emitTopLevelPrimitive(t: PrimitiveType, name: Name): void { diff --git a/packages/quicktype-core/src/language/Kotlin/language.ts b/packages/quicktype-core/src/language/Kotlin/language.ts index ce72896ac..3b0e03677 100644 --- a/packages/quicktype-core/src/language/Kotlin/language.ts +++ b/packages/quicktype-core/src/language/Kotlin/language.ts @@ -8,6 +8,11 @@ import { import { AcronymStyleOptions, acronymOption } from "../../support/Acronyms"; import { assertNever } from "../../support/Support"; import { TargetLanguage } from "../../TargetLanguage"; +import type { + PrimitiveStringTypeKind, + TransformedStringTypeKind, +} from "../../Type"; +import type { StringTypeMapping } from "../../Type/TypeBuilderUtils"; import type { LanguageName, RendererOptions } from "../../types"; import { KotlinJacksonRenderer } from "./KotlinJacksonRenderer"; @@ -56,6 +61,15 @@ export class KotlinTargetLanguage extends TargetLanguage< return true; } + public get stringTypeMapping(): StringTypeMapping { + const mapping: Map = + new Map(); + mapping.set("date", "date"); + mapping.set("time", "time"); + mapping.set("date-time", "date-time"); + return mapping; + } + protected makeRenderer( renderContext: RenderContext, untypedOptionValues: RendererOptions, diff --git a/test/languages.ts b/test/languages.ts index 9573f3e57..14855564d 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1093,7 +1093,7 @@ export const KotlinLanguage: Language = { "76ae1.json", ], allowMissingNull: true, - features: ["enum", "union", "no-defaults"], + features: ["enum", "union", "no-defaults", "date-time"], output: "TopLevel.kt", topLevel: "TopLevel", skipJSON: [ @@ -1178,7 +1178,7 @@ export const KotlinJacksonLanguage: Language = { "76ae1.json", ], allowMissingNull: true, - features: ["enum", "union", "no-defaults"], + features: ["enum", "union", "no-defaults", "date-time"], output: "TopLevel.kt", topLevel: "TopLevel", skipJSON: [ From 3d45b9f5e7c0a564fe95b3e91d2a86d9ae75cf93 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 17:27:42 -0400 Subject: [PATCH 2/8] Kotlin/Jackson: make java.time date/time round-trip work Register strict ISO converters on the generated ObjectMapper for OffsetDateTime, LocalDate and OffsetTime via the existing convert() infrastructure instead of requiring jackson-datatype-jsr310: the module's serializers don't round-trip faithfully (OffsetTime pads "23:20:50.52Z" to "23:20:50.520Z"), while the ISO formatters do, and this way generated code needs no extra dependency. Also fix union deserialization when several members are guarded by the same node type: date, time, date-time and enum values are all TextNode, so the previous output had duplicate 'is TextNode ->' branches of which only the first could ever match. Members sharing a guard now parse in a single branch that tries each member in sequence, strictest first (transformed strings, then enums, then plain strings). Co-Authored-By: Claude Fable 5 --- .../language/Kotlin/KotlinJacksonRenderer.ts | 93 +++++++++++++++++-- .../src/language/Kotlin/utils.ts | 22 +++++ 2 files changed, 108 insertions(+), 7 deletions(-) diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts index 6382b6200..f32b04a8d 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinJacksonRenderer.ts @@ -20,7 +20,7 @@ import { matchType, nullableFromUnion } from "../../Type/TypeUtils.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; import type { kotlinOptions } from "./language.js"; -import { stringEscape } from "./utils.js"; +import { stringEscape, unionMemberMatchPriority } from "./utils.js"; export class KotlinJacksonRenderer extends KotlinRenderer { public constructor( @@ -89,7 +89,17 @@ import com.fasterxml.jackson.module.kotlin.*`); this.typeGraph.allNamedTypes(), (c) => c instanceof ClassType && c.getProperties().size === 0, ); - if (hasUnions || this.haveEnums || hasEmptyObjects) { + const usesDateTime = this.haveTransformedStringType("date-time"); + const usesDate = this.haveTransformedStringType("date"); + const usesTime = this.haveTransformedStringType("time"); + if ( + hasUnions || + this.haveEnums || + hasEmptyObjects || + usesDateTime || + usesDate || + usesTime + ) { this.emitGenericConverter(); } @@ -97,6 +107,39 @@ import com.fasterxml.jackson.module.kotlin.*`); // if (hasEmptyObjects) { // converters.push([["convert(JsonNode::class,"], [" { it },"], [" { writeValueAsString(it) })"]]); // } + // We don't use jackson-datatype-jsr310's JavaTimeModule because its + // serializers don't round-trip faithfully (e.g. OffsetTime pads + // "23:20:50.52Z" to "23:20:50.520Z"); the ISO formatters do. + if (usesDateTime) { + converters.push([ + ["convert(OffsetDateTime::class,"], + [" { OffsetDateTime.parse(it.asText()) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(it)}\\"" })', + ], + ]); + } + + if (usesDate) { + converters.push([ + ["convert(LocalDate::class,"], + [" { LocalDate.parse(it.asText()) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_LOCAL_DATE.format(it)}\\"" })', + ], + ]); + } + + if (usesTime) { + converters.push([ + ["convert(OffsetTime::class,"], + [" { OffsetTime.parse(it.asText()) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_TIME.format(it)}\\"" })', + ], + ]); + } + this.forEachEnum("none", (_, name) => { converters.push([ ["convert(", name, "::class,"], @@ -347,19 +390,55 @@ private fun ObjectMapper.convert(k: kotlin.reflect.KClass<*>, fromJson: (Jso " = when (jn) {", ); this.indent(() => { - const table: Sourcelike[][] = []; + // Members whose JSON representations share a node type + // (several transformed string types, or a transformed string + // type and an enum, are all TextNode) must share a single + // guard and be tried in sequence, most specific parse first. + const groups: Array<{ + guard: string; + members: Array<{ name: Name; t: Type }>; + }> = []; this.forEachUnionMember( u, nonNulls, "none", null, (name, t) => { - table.push([ - [this.unionMemberJsonValueGuard(t, "jn")], - [" -> ", name, "(mapper.treeToValue(jn))"], - ]); + const guard = this.sourcelikeToString( + this.unionMemberJsonValueGuard(t, "jn"), + ); + const group = groups.find((g) => g.guard === guard); + if (group === undefined) { + groups.push({ guard, members: [{ name, t }] }); + } else { + group.members.push({ name, t }); + } }, ); + const table: Sourcelike[][] = []; + for (const { guard, members } of groups) { + const ordered = [...members].sort( + (a, b) => + unionMemberMatchPriority(a.t) - + unionMemberMatchPriority(b.t), + ); + let expr: Sourcelike = [ + ordered[ordered.length - 1].name, + "(mapper.treeToValue(jn))", + ]; + for (let i = ordered.length - 2; i >= 0; i--) { + expr = [ + "try { ", + ordered[i].name, + "(mapper.treeToValue(jn)) } catch (e: Exception) { ", + expr, + " }", + ]; + } + + table.push([[guard], [" -> ", expr]]); + } + if (maybeNull !== null) { const name = this.nameForUnionMember(u, maybeNull); table.push([ diff --git a/packages/quicktype-core/src/language/Kotlin/utils.ts b/packages/quicktype-core/src/language/Kotlin/utils.ts index 927d21de8..b1ffc7e01 100644 --- a/packages/quicktype-core/src/language/Kotlin/utils.ts +++ b/packages/quicktype-core/src/language/Kotlin/utils.ts @@ -1,3 +1,5 @@ +import type { Type } from "../../Type/index.js"; + import { allLowerWordStyle, allUpperWordStyle, @@ -55,3 +57,23 @@ export function stringEscape(s: string): string { // "$this" is a template string in Kotlin so we have to escape $ return _stringEscape(s).replace(/\$/g, "\\$"); } + +// When several union members are guarded by the same JSON node/value type +// (e.g. date, time and enum values are all strings), parse attempts must be +// ordered strictest first: transformed string types (strict ISO parsers), +// then enums (known values only), then plain strings (accept anything). +export function unionMemberMatchPriority(t: Type): number { + if (t.kind === "date-time" || t.kind === "date" || t.kind === "time") { + return 0; + } + + if (t.kind === "enum") { + return 1; + } + + if (t.kind === "string") { + return 2; + } + + return 3; +} From 2d37aeac5548e686d8efb2848acbdba1b1ae2146 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 17:27:42 -0400 Subject: [PATCH 3/8] Kotlin/Klaxon: support date/time types from JSON schema The klaxon renderer's matchType calls were missing the transformedStringType matcher the base renderer maps to java.time types, so any schema with date/time formats in a union crashed generation with "Unsupported type date-time in non-exhaustive match". Add the matchers, register Klaxon converters that parse the java.time types from ISO strings and re-serialize them via the ISO formatters (exact round-trip), and give union members that share the 'is String' guard the same sequential-parse treatment as the Jackson renderer. Co-Authored-By: Claude Fable 5 --- .../language/Kotlin/KotlinKlaxonRenderer.ts | 117 ++++++++++++++++-- 1 file changed, 104 insertions(+), 13 deletions(-) diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts index 8b8ba8cd4..5223d7b18 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinKlaxonRenderer.ts @@ -20,7 +20,7 @@ import { matchType, nullableFromUnion } from "../../Type/TypeUtils.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; import type { kotlinOptions } from "./language.js"; -import { stringEscape } from "./utils.js"; +import { stringEscape, unionMemberMatchPriority } from "./utils.js"; export class KotlinKlaxonRenderer extends KotlinRenderer { public constructor( @@ -65,6 +65,21 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { ".fromValue(it) }", ], (_unionType) => mustNotHappen(), + (transformedStringType) => { + if (transformedStringType.kind === "date-time") { + return [e, ".string?.let { OffsetDateTime.parse(it) }"]; + } + + if (transformedStringType.kind === "date") { + return [e, ".string?.let { LocalDate.parse(it) }"]; + } + + if (transformedStringType.kind === "time") { + return [e, ".string?.let { OffsetTime.parse(it) }"]; + } + + return [e, ".string"]; + }, ); } @@ -86,6 +101,7 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { // and enums in the same union (_enumType) => "is String", (_unionType) => mustNotHappen(), + (_transformedStringType) => "is String", ); } @@ -116,11 +132,51 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { this.typeGraph.allNamedTypes(), (c) => c instanceof ClassType && c.getProperties().size === 0, ); - if (hasUnions || this.haveEnums || hasEmptyObjects) { + const usesDateTime = this.haveTransformedStringType("date-time"); + const usesDate = this.haveTransformedStringType("date"); + const usesTime = this.haveTransformedStringType("time"); + if ( + hasUnions || + this.haveEnums || + hasEmptyObjects || + usesDateTime || + usesDate || + usesTime + ) { this.emitGenericConverter(); } const converters: Sourcelike[][] = []; + if (usesDateTime) { + converters.push([ + [".convert(OffsetDateTime::class,"], + [" { OffsetDateTime.parse(it.string!!) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(it)}\\"" })', + ], + ]); + } + + if (usesDate) { + converters.push([ + [".convert(LocalDate::class,"], + [" { LocalDate.parse(it.string!!) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_LOCAL_DATE.format(it)}\\"" })', + ], + ]); + } + + if (usesTime) { + converters.push([ + [".convert(OffsetTime::class,"], + [" { OffsetTime.parse(it.string!!) },"], + [ + ' { "\\"${java.time.format.DateTimeFormatter.ISO_OFFSET_TIME.format(it)}\\"" })', + ], + ]); + } + if (hasEmptyObjects) { converters.push([ [".convert(JsonObject::class,"], @@ -376,25 +432,60 @@ export class KotlinKlaxonRenderer extends KotlinRenderer { " = when (jv.inside) {", ); this.indent(() => { - const table: Sourcelike[][] = []; + // Members whose JSON representations share a value type + // (several transformed string types, or a transformed string + // type and an enum, are all strings) must share a single + // guard and be tried in sequence, most specific parse first. + const groups: Array<{ + guard: string; + members: Array<{ name: Name; t: Type }>; + }> = []; this.forEachUnionMember( u, nonNulls, "none", null, (name, t) => { - table.push([ - [this.unionMemberJsonValueGuard(t, "jv.inside")], - [ - " -> ", - name, - "(", - this.unionMemberFromJsonValue(t, "jv"), - "!!)", - ], - ]); + const guard = this.sourcelikeToString( + this.unionMemberJsonValueGuard(t, "jv.inside"), + ); + const group = groups.find((g) => g.guard === guard); + if (group === undefined) { + groups.push({ guard, members: [{ name, t }] }); + } else { + group.members.push({ name, t }); + } }, ); + const table: Sourcelike[][] = []; + for (const { guard, members } of groups) { + const ordered = [...members].sort( + (a, b) => + unionMemberMatchPriority(a.t) - + unionMemberMatchPriority(b.t), + ); + const last = ordered[ordered.length - 1]; + let expr: Sourcelike = [ + last.name, + "(", + this.unionMemberFromJsonValue(last.t, "jv"), + "!!)", + ]; + for (let i = ordered.length - 2; i >= 0; i--) { + expr = [ + "try { ", + ordered[i].name, + "(", + this.unionMemberFromJsonValue(ordered[i].t, "jv"), + "!!) } catch (e: Exception) { ", + expr, + " }", + ]; + } + + table.push([[guard], [" -> ", expr]]); + } + if (maybeNull !== null) { const name = this.nameForUnionMember(u, maybeNull); table.push([ From 68516b428f1aef8ca6ae8f538831e50b16c4afec Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 17:27:42 -0400 Subject: [PATCH 4/8] Kotlin fixtures: skip misc JSONs whose date-times can't round-trip java.time With date/time inference now mapping to java.time types, two groups of misc inputs fail the round-trip comparison: files with non-RFC3339 date-times (space-separated, no offset) that OffsetDateTime.parse rejects, and files whose fractional seconds have trailing zeros that the ISO formatters legitimately trim (".000Z" -> "Z"). Skip them for both Kotlin variants, mirroring what Go already does for the first group. Co-Authored-By: Claude Fable 5 --- test/languages.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/languages.ts b/test/languages.ts index 825ca10a9..9bd62a4a1 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1182,6 +1182,27 @@ export const KotlinLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", + // Contain date-time values in non-RFC3339 formats that java.time + // cannot parse + "437e7.json", + "127a1.json", + "e0ac7.json", + "7681c.json", + "26b49.json", + "f6a65.json", + "0cffa.json", + "c3303.json", + // Contain date-time values with trailing zeros in the fractional + // seconds, which java.time does not re-serialize identically + "54d32.json", + "77392.json", + "0a358.json", + "80aff.json", + "b4865.json", + "9ac3b.json", + "337ed.json", + "734ad.json", + "d23d5.json", ], skipSchema: [ // Very weird - the types are correct, but it can (de)serialize the string, @@ -1267,6 +1288,27 @@ export const KotlinJacksonLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", + // Contain date-time values in non-RFC3339 formats that java.time + // cannot parse + "437e7.json", + "127a1.json", + "e0ac7.json", + "7681c.json", + "26b49.json", + "f6a65.json", + "0cffa.json", + "c3303.json", + // Contain date-time values with trailing zeros in the fractional + // seconds, which java.time does not re-serialize identically + "54d32.json", + "77392.json", + "0a358.json", + "80aff.json", + "b4865.json", + "9ac3b.json", + "337ed.json", + "734ad.json", + "d23d5.json", ], skipSchema: [ // Very weird - the types are correct, but it can (de)serialize the string, From c1946947174db33a8e62389c7bf4db05ae6bf7d0 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 12 Jul 2026 20:07:00 -0400 Subject: [PATCH 5/8] Drop kotlin skips for non-RFC3339 date-times, obsoleted by strict inference With the default recognizer now requiring RFC 3339 (T separator and timezone offset), space-separated timestamps like "2013-06-15 21:10:28" are inferred as plain strings, so these eight misc fixtures round-trip for both kotlin variants without skips (verified per file; f6a65.json also verified end-to-end with kotlinc). The nine remaining skips are unchanged: their values are RFC 3339-valid but carry trailing-zero fractional seconds (e.g. ".000Z") that java.time does not re-serialize identically. Co-Authored-By: Claude Fable 5 --- test/languages.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/test/languages.ts b/test/languages.ts index c37c7a212..67302b988 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1152,16 +1152,6 @@ export const KotlinLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", - // Contain date-time values in non-RFC3339 formats that java.time - // cannot parse - "437e7.json", - "127a1.json", - "e0ac7.json", - "7681c.json", - "26b49.json", - "f6a65.json", - "0cffa.json", - "c3303.json", // Contain date-time values with trailing zeros in the fractional // seconds, which java.time does not re-serialize identically "54d32.json", @@ -1258,16 +1248,6 @@ export const KotlinJacksonLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", - // Contain date-time values in non-RFC3339 formats that java.time - // cannot parse - "437e7.json", - "127a1.json", - "e0ac7.json", - "7681c.json", - "26b49.json", - "f6a65.json", - "0cffa.json", - "c3303.json", // Contain date-time values with trailing zeros in the fractional // seconds, which java.time does not re-serialize identically "54d32.json", From 9b3fffef0a9e9dfc015a961697dc24558ea86d5b Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 12:07:35 -0400 Subject: [PATCH 6/8] fix(kotlin): emit kotlinx serializers for java.time types The Kotlin language's stringTypeMapping maps JSON Schema date, time, and date-time formats to java.time types for every framework, but the kotlinx renderer was never taught about them: it emitted @Serializable data classes with OffsetDateTime/LocalDate/OffsetTime properties, which don't compile because kotlinx.serialization has no built-in serializers for java.time ("Serializer has not been found for type 'OffsetDateTime'"). Emit custom KSerializer objects for the java.time types that appear in the type graph and register them file-wide with @file:UseSerializers. Like the Jackson and Klaxon converters, they parse with .parse and format with the ISO formatters so values round-trip faithfully. There is no kotlinx fixture in CI, so cover this with a unit test asserting the serializers and file annotation are emitted (and not emitted when no date/time types occur). Co-Authored-By: Claude Fable 5 --- .../src/language/Kotlin/KotlinRenderer.ts | 7 ++ .../src/language/Kotlin/KotlinXRenderer.ts | 67 +++++++++++++++ .../unit/kotlinx-datetime-serializers.test.ts | 85 +++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 test/unit/kotlinx-datetime-serializers.test.ts diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts index 52e8ae35c..5b238ae93 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinRenderer.ts @@ -227,6 +227,12 @@ export class KotlinRenderer extends ConvenienceRenderer { // To be overridden } + // Emitted between the usage header and the package declaration — + // the only place Kotlin allows `@file:`-targeted annotations. + protected emitFileAnnotations(): void { + // To be overridden + } + protected emitHeader(): void { if (this.leadingComments !== undefined) { this.emitComments(this.leadingComments); @@ -235,6 +241,7 @@ export class KotlinRenderer extends ConvenienceRenderer { } this.ensureBlankLine(); + this.emitFileAnnotations(); this.emitLine("package ", this._kotlinOptions.packageName); this.ensureBlankLine(); diff --git a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts index 981204bf4..cf1bea8fa 100644 --- a/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts +++ b/packages/quicktype-core/src/language/Kotlin/KotlinXRenderer.ts @@ -15,6 +15,31 @@ import { KotlinRenderer } from "./KotlinRenderer.js"; import type { kotlinOptions } from "./language.js"; import { stringEscape } from "./utils.js"; +// kotlinx.serialization has no built-in serializers for java.time, so we +// emit our own and register them file-wide with `@file:UseSerializers`. +// Like the Jackson converters, they parse with `.parse` and format with the +// ISO formatters so values round-trip faithfully. +const dateTimeSerializers = [ + { + kind: "date-time", + name: "OffsetDateTimeSerializer", + type: "OffsetDateTime", + formatter: "ISO_OFFSET_DATE_TIME", + }, + { + kind: "date", + name: "LocalDateSerializer", + type: "LocalDate", + formatter: "ISO_LOCAL_DATE", + }, + { + kind: "time", + name: "OffsetTimeSerializer", + type: "OffsetTime", + formatter: "ISO_OFFSET_TIME", + }, +] as const; + /** * Currently supports simple classes, enums, and TS string unions (which are also enums). * TODO: Union, Any, Top Level Array, Top Level Map @@ -28,6 +53,21 @@ export class KotlinXRenderer extends KotlinRenderer { super(targetLanguage, renderContext, _kotlinOptions); } + protected forbiddenNamesForGlobalNamespace(): readonly string[] { + return [ + ...super.forbiddenNamesForGlobalNamespace(), + ...dateTimeSerializers.map((s) => s.name), + ]; + } + + private usedDateTimeSerializers(): Array< + (typeof dateTimeSerializers)[number] + > { + return dateTimeSerializers.filter((s) => + this.haveTransformedStringType(s.kind), + ); + } + protected anySourceType(optional: string): Sourcelike { return ["JsonElement", optional]; } @@ -95,6 +135,18 @@ export class KotlinXRenderer extends KotlinRenderer { this.emitTable(table); } + protected emitFileAnnotations(): void { + const serializers = this.usedDateTimeSerializers(); + if (serializers.length === 0) return; + + this.emitLine( + "@file:UseSerializers(", + serializers.map((s) => `${s.name}::class`).join(", "), + ")", + ); + this.ensureBlankLine(); + } + protected emitHeader(): void { super.emitHeader(); @@ -104,6 +156,21 @@ export class KotlinXRenderer extends KotlinRenderer { this.emitLine("import kotlinx.serialization.encoding.*"); } + protected emitSourceStructure(): void { + super.emitSourceStructure(); + + for (const serializer of this.usedDateTimeSerializers()) { + this.ensureBlankLine(); + this.emitMultiline(`object ${serializer.name} : KSerializer<${serializer.type}> { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("${serializer.type}", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): ${serializer.type} = ${serializer.type}.parse(decoder.decodeString()) + override fun serialize(encoder: Encoder, value: ${serializer.type}) { + encoder.encodeString(java.time.format.DateTimeFormatter.${serializer.formatter}.format(value)) + } +}`); + } + } + protected emitClassAnnotations(_c: Type, _className: Name): void { this.emitLine("@Serializable"); } diff --git a/test/unit/kotlinx-datetime-serializers.test.ts b/test/unit/kotlinx-datetime-serializers.test.ts new file mode 100644 index 000000000..083cfc7eb --- /dev/null +++ b/test/unit/kotlinx-datetime-serializers.test.ts @@ -0,0 +1,85 @@ +// Kotlin's language-wide stringTypeMapping maps JSON Schema "date", +// "time", and "date-time" formats to java.time types for all frameworks. +// kotlinx.serialization has no built-in serializers for java.time, so the +// kotlinx renderer must emit custom KSerializer objects and register them +// with a `@file:UseSerializers(...)` annotation — otherwise the generated +// code doesn't compile ("Serializer has not been found for type +// 'OffsetDateTime'"). There is no kotlinx fixture in CI, so this unit test +// covers it. + +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { describe, expect, test } from "vitest"; + +async function kotlinxOutput( + properties: Record, +): Promise { + const schema = JSON.stringify({ + $schema: "http://json-schema.org/draft-06/schema#", + type: "object", + properties, + required: Object.keys(properties), + }); + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ name: "TopLevel", schema }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ + inputData, + lang: "kotlin", + rendererOptions: { framework: "kotlinx" }, + }); + return result.lines.join("\n"); +} + +describe("kotlinx date/time serializers", () => { + test("emits KSerializers and @file:UseSerializers for date/time types", async () => { + const output = await kotlinxOutput({ + date: { type: "string", format: "date" }, + time: { type: "string", format: "time" }, + dateTime: { type: "string", format: "date-time" }, + }); + + expect(output).toContain( + "@file:UseSerializers(OffsetDateTimeSerializer::class, LocalDateSerializer::class, OffsetTimeSerializer::class)", + ); + for (const [serializer, javaType] of [ + ["OffsetDateTimeSerializer", "OffsetDateTime"], + ["LocalDateSerializer", "LocalDate"], + ["OffsetTimeSerializer", "OffsetTime"], + ]) { + expect(output).toContain( + `object ${serializer} : KSerializer<${javaType}>`, + ); + expect(output).toContain(`import java.time.${javaType}`); + } + + // The file annotation must precede the package declaration. + expect(output.indexOf("@file:UseSerializers")).toBeLessThan( + output.indexOf("package "), + ); + }); + + test("emits only the serializers that are used", async () => { + const output = await kotlinxOutput({ + date: { type: "string", format: "date" }, + }); + + expect(output).toContain( + "@file:UseSerializers(LocalDateSerializer::class)", + ); + expect(output).toContain("object LocalDateSerializer"); + expect(output).not.toContain("OffsetDateTimeSerializer"); + expect(output).not.toContain("OffsetTimeSerializer"); + }); + + test("emits no serializer machinery without date/time types", async () => { + const output = await kotlinxOutput({ + name: { type: "string" }, + }); + + expect(output).not.toContain("@file:UseSerializers"); + expect(output).not.toContain("KSerializer"); + expect(output).not.toContain("java.time"); + }); +}); From 347969baed505a7b5c8fb5ae5f71b56574c3a108 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 13:34:39 -0400 Subject: [PATCH 7/8] test(kotlinx): enable date-time feature and adjust skips for date inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merging master brought in the kotlinx fixture coverage from #2950, whose skip lists were tuned against master, where Kotlin had no date-time support. With this branch's date/time mapping: - Declare the "date-time" feature for KotlinXLanguage. date-time.schema itself stays skipped because its union-array properties hit the kotlinx sealed-class union limitation; the emitted KSerializers are exercised by JSON inputs with inferred date-times instead. - Skip the misc JSON inputs whose date-times carry trailing zeros in the fractional seconds, which java.time doesn't re-serialize identically — the same set already skipped for klaxon and jackson. - Skip date-time-or-string.schema: its string|date-time property was a plain string before and now becomes a union. Co-Authored-By: Claude Fable 5 --- test/languages.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/languages.ts b/test/languages.ts index fc1687ba1..0deb4cc8a 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1323,7 +1323,11 @@ export const KotlinXLanguage: Language = { // No "union": the kotlinx renderer emits unions as sealed classes // without any serializer wiring, so they don't (de)serialize // (documented TODO in KotlinXRenderer.ts). - features: ["enum", "no-defaults"], + // "date-time" is supported via emitted KSerializers, but note that + // date-time.schema itself stays skipped: its union-array properties + // hit the union limitation above. The serializers are exercised by + // the JSON inputs with inferred date-times instead. + features: ["enum", "no-defaults", "date-time"], output: "TopLevel.kt", topLevel: "TopLevel", skipJSON: [ @@ -1398,6 +1402,16 @@ export const KotlinXLanguage: Language = { // the @SerialName annotations don't match the JSON keys. "blns-object.json", "identifiers.json", + // Contain date-time values with trailing zeros in the fractional + // seconds, which java.time does not re-serialize identically + // (77392.json and b4865.json are already skipped above). + "54d32.json", + "0a358.json", + "80aff.json", + "9ac3b.json", + "337ed.json", + "734ad.json", + "d23d5.json", ], skipSchema: [ // Unions render as sealed classes without serializer wiring, so @@ -1408,6 +1422,9 @@ export const KotlinXLanguage: Language = { "class-map-union.schema", "class-with-additional.schema", "date-time.schema", + // The string|date-time property becomes a union once Kotlin maps + // date-time (it was a plain string before). + "date-time-or-string.schema", "description.schema", "direct-union.schema", "enum.schema", // enum.3.json contains an int|string union From 165d394fa70ed081e1a392374baa469863b074ed Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Sun, 19 Jul 2026 14:02:49 -0400 Subject: [PATCH 8/8] fix(kotlin): only infer date-times that java.time round-trips faithfully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of skipping test inputs whose date-time strings java.time does not re-serialize byte-identically, give Kotlin a DateTimeRecognizer (the existing facility for this, mirroring SwiftDateTimeRecognizer) that refuses to infer date/time types from such strings in the first place. Rejected strings simply stay plain strings. The rules, all verified against java.time's actual parse/format behavior (ISO_LOCAL_DATE / ISO_OFFSET_TIME / ISO_OFFSET_DATE_TIME): - no trailing zeros in fractional seconds (java.time formats the shortest fraction: ".000" disappears, ".500" becomes ".5"), - at most 9 fractional digits (java.time's nanosecond precision; more don't parse), - offset "Z" or a nonzero "±hh:mm" within java.time's ±18:00 range (zero offsets format back as "Z", larger ones don't parse), - uppercase "T" and "Z" (lowercase forms format back in uppercase), - no Feb 29 outside leap years (java.time refuses to parse it). This only affects inference from JSON samples — JSON Schema's "format": "date-time" maps to OffsetDateTime regardless, which is the correct behavior for schema-driven input. The nine date-time-related misc-JSON skips on the Kotlin fixtures are lifted; every date/time-like string in the test inputs that the new recognizer accepts was verified to round-trip byte-identically through java.time (5692 strings checked). 77392.json and b4865.json stay skipped for kotlinx only, for the unrelated top-level-JsonArray reason. Co-Authored-By: Claude Fable 5 --- .../src/language/Kotlin/language.ts | 8 +++ .../src/language/Kotlin/utils.ts | 63 +++++++++++++++++ test/languages.ts | 32 --------- test/unit/date-time-recognizer.test.ts | 68 +++++++++++++++++++ 4 files changed, 139 insertions(+), 32 deletions(-) diff --git a/packages/quicktype-core/src/language/Kotlin/language.ts b/packages/quicktype-core/src/language/Kotlin/language.ts index 4d4bbb17a..28adca237 100644 --- a/packages/quicktype-core/src/language/Kotlin/language.ts +++ b/packages/quicktype-core/src/language/Kotlin/language.ts @@ -1,4 +1,5 @@ import type { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; +import type { DateTimeRecognizer } from "../../DateTime.js"; import type { RenderContext } from "../../Renderer.js"; import { BooleanOption, @@ -20,6 +21,7 @@ import { KotlinJacksonRenderer } from "./KotlinJacksonRenderer.js"; import { KotlinKlaxonRenderer } from "./KotlinKlaxonRenderer.js"; import { KotlinRenderer } from "./KotlinRenderer.js"; import { KotlinXRenderer } from "./KotlinXRenderer.js"; +import { KotlinDateTimeRecognizer } from "./utils.js"; export const kotlinOptions = { justTypes: new BooleanOption("just-types", "Plain types only", false), @@ -71,6 +73,12 @@ export class KotlinTargetLanguage extends TargetLanguage< return mapping; } + // Only infer date/time types from JSON strings that java.time's ISO + // formatters round-trip byte-identically; see KotlinDateTimeRecognizer. + public get dateTimeRecognizer(): DateTimeRecognizer { + return new KotlinDateTimeRecognizer(); + } + protected makeRenderer( renderContext: RenderContext, untypedOptionValues: RendererOptions, diff --git a/packages/quicktype-core/src/language/Kotlin/utils.ts b/packages/quicktype-core/src/language/Kotlin/utils.ts index 5810d6182..6c20ebd47 100644 --- a/packages/quicktype-core/src/language/Kotlin/utils.ts +++ b/packages/quicktype-core/src/language/Kotlin/utils.ts @@ -1,3 +1,4 @@ +import { DefaultDateTimeRecognizer } from "../../DateTime.js"; import type { Type } from "../../Type/index.js"; import { @@ -58,6 +59,68 @@ export function stringEscape(s: string): string { return _stringEscape(s).replace(/\$/g, "\\$"); } +// The generated code round-trips date/time values through java.time's +// ISO_LOCAL_DATE / ISO_OFFSET_TIME / ISO_OFFSET_DATE_TIME, so we only +// recognize strings those formatters reproduce byte-identically (all +// verified against java.time): +// +// - Fractional seconds must not end in "0" — java.time formats the +// shortest fraction (".500" becomes ".5", ".000" disappears) — and +// have at most 9 digits, java.time's nanosecond precision. +// - The UTC offset must be "Z" or a nonzero "±hh:mm": zero offsets +// format back as "Z", and java.time rejects offsets beyond ±18:00. +// - Lowercase "t"/"z" format back in uppercase. +// - February 29 only exists in leap years; java.time refuses to parse +// it in other years (the default recognizer always allows it). +// +// Anything rejected here simply stays a plain string. This only affects +// inference from JSON samples — JSON Schema's "format": "date-time" is +// mapped regardless. +const KOTLIN_TIME = /^(\d\d):(\d\d):(\d\d)(?:\.(\d{1,9}))?(Z|[+-]\d\d:\d\d)$/; + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +export class KotlinDateTimeRecognizer extends DefaultDateTimeRecognizer { + public isDate(str: string): boolean { + if (!super.isDate(str)) return false; + + const [year, month, day] = str.split("-").map(Number); + return month !== 2 || day !== 29 || isLeapYear(year); + } + + public isTime(str: string): boolean { + const matches = KOTLIN_TIME.exec(str); + if (matches === null) return false; + + const hour = +matches[1]; + const minute = +matches[2]; + const second = +matches[3]; + if (hour > 23 || minute > 59 || second > 59) return false; + + const fraction = matches[4]; + if (fraction?.endsWith("0")) return false; + + const offset = matches[5]; + if (offset === "Z") return true; + + const offsetHour = +offset.slice(1, 3); + const offsetMinute = +offset.slice(4, 6); + if (offsetHour === 0 && offsetMinute === 0) return false; + return offsetMinute <= 59 && offsetHour * 60 + offsetMinute <= 18 * 60; + } + + public isDateTime(str: string): boolean { + const dateTime = str.split("T"); + return ( + dateTime.length === 2 && + this.isDate(dateTime[0]) && + this.isTime(dateTime[1]) + ); + } +} + // When several union members are guarded by the same JSON node/value type // (e.g. date, time and enum values are all strings), parse attempts must be // ordered strictest first: transformed string types (strict ISO parsers), diff --git a/test/languages.ts b/test/languages.ts index 0deb4cc8a..94f9828e3 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1156,17 +1156,6 @@ export const KotlinLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", - // Contain date-time values with trailing zeros in the fractional - // seconds, which java.time does not re-serialize identically - "54d32.json", - "77392.json", - "0a358.json", - "80aff.json", - "b4865.json", - "9ac3b.json", - "337ed.json", - "734ad.json", - "d23d5.json", ], skipSchema: [ // Very weird - the types are correct, but it can (de)serialize the string, @@ -1256,17 +1245,6 @@ export const KotlinJacksonLanguage: Language = { "af2d1.json", "32431.json", "bug427.json", - // Contain date-time values with trailing zeros in the fractional - // seconds, which java.time does not re-serialize identically - "54d32.json", - "77392.json", - "0a358.json", - "80aff.json", - "b4865.json", - "9ac3b.json", - "337ed.json", - "734ad.json", - "d23d5.json", ], skipSchema: [ // Very weird - the types are correct, but it can (de)serialize the string, @@ -1402,16 +1380,6 @@ export const KotlinXLanguage: Language = { // the @SerialName annotations don't match the JSON keys. "blns-object.json", "identifiers.json", - // Contain date-time values with trailing zeros in the fractional - // seconds, which java.time does not re-serialize identically - // (77392.json and b4865.json are already skipped above). - "54d32.json", - "0a358.json", - "80aff.json", - "9ac3b.json", - "337ed.json", - "734ad.json", - "d23d5.json", ], skipSchema: [ // Unions render as sealed classes without serializer wiring, so diff --git a/test/unit/date-time-recognizer.test.ts b/test/unit/date-time-recognizer.test.ts index dee7e5396..5ee1e52b3 100644 --- a/test/unit/date-time-recognizer.test.ts +++ b/test/unit/date-time-recognizer.test.ts @@ -6,6 +6,7 @@ // producing generated code whose strict date parsers (Go, Swift, java.time, // ...) could not read the very samples the types were inferred from. import { DefaultDateTimeRecognizer } from "quicktype-core/dist/DateTime.js"; +import { KotlinDateTimeRecognizer } from "quicktype-core/dist/language/Kotlin/utils.js"; import { describe, expect, test } from "vitest"; const recognizer = new DefaultDateTimeRecognizer(); @@ -83,3 +84,70 @@ describe("isTime", () => { expect(recognizer.isTime("02:45:60Z")).toBe(false); }); }); + +// The Kotlin recognizer additionally requires that java.time's ISO +// formatters (which the generated Kotlin code round-trips through) +// reproduce the string byte-identically. All rules below were verified +// against java.time's actual parse/format behavior. +describe("KotlinDateTimeRecognizer", () => { + const kotlin = new KotlinDateTimeRecognizer(); + + test("accepts date-times java.time round-trips identically", () => { + expect(kotlin.isDateTime("2018-08-14T02:45:50Z")).toBe(true); + expect(kotlin.isDateTime("2010-12-01T00:00:00Z")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50+05:30")).toBe(true); + expect(kotlin.isDateTime("2015-04-24T01:46:50.342496Z")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50.5Z")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50.123456789Z")).toBe(true); + expect(kotlin.isDateTime("2016-02-29T00:00:00Z")).toBe(true); + }); + + test("rejects fractional seconds with trailing zeros", () => { + // java.time formats the shortest fraction: ".000" disappears, + // ".500" becomes ".5", ".828000" becomes ".828". + expect(kotlin.isDateTime("2010-01-12T00:00:00.000Z")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50.50Z")).toBe(false); + expect(kotlin.isDateTime("2008-09-10T13:21:30.828000Z")).toBe(false); + expect(kotlin.isDateTime("2015-04-24T02:04:02.997570Z")).toBe(false); + }); + + test("rejects fractions beyond nanosecond precision", () => { + // java.time refuses to parse more than 9 fractional digits. + expect(kotlin.isDateTime("2018-08-14T02:45:50.1234567891Z")).toBe( + false, + ); + }); + + test("rejects zero offsets, which format back as Z", () => { + expect(kotlin.isDateTime("2018-08-14T02:45:50+00:00")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50-00:00")).toBe(false); + expect(kotlin.isTime("10:30:00+00:00")).toBe(false); + }); + + test("rejects offsets outside java.time's ±18:00 range", () => { + expect(kotlin.isDateTime("2018-08-14T02:45:50+18:00")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50-18:00")).toBe(true); + expect(kotlin.isDateTime("2018-08-14T02:45:50+18:01")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50+19:00")).toBe(false); + }); + + test("rejects lowercase t and z, which format back in uppercase", () => { + expect(kotlin.isDateTime("2018-08-14t02:45:50Z")).toBe(false); + expect(kotlin.isDateTime("2018-08-14T02:45:50z")).toBe(false); + expect(kotlin.isTime("02:45:50z")).toBe(false); + }); + + test("rejects Feb 29 outside leap years, which java.time cannot parse", () => { + expect(kotlin.isDate("2016-02-29")).toBe(true); + expect(kotlin.isDate("2000-02-29")).toBe(true); + expect(kotlin.isDate("2015-02-29")).toBe(false); + expect(kotlin.isDate("1900-02-29")).toBe(false); + expect(kotlin.isDateTime("2015-02-29T00:00:00Z")).toBe(false); + }); + + test("accepts times java.time round-trips identically", () => { + expect(kotlin.isTime("02:45:50Z")).toBe(true); + expect(kotlin.isTime("23:20:50.52Z")).toBe(true); + expect(kotlin.isTime("23:20:50.520Z")).toBe(false); + }); +});