From 86a0cfd95180acab45fa0a85a73c0315b64617d2 Mon Sep 17 00:00:00 2001 From: Ekjot <43255916+ekjotmultani@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:22:53 -0700 Subject: [PATCH 1/2] fix(dart-codegen): emit nullable type for unions with a null member A oneOf containing a {"type":"null"} member (which arises from optional record values) previously fell through to `dynamic`. Strip the null member before union detection so the remaining arms drive nullable/discriminated-union resolution, and mark the resolved type nullable when a null member was present. A string-discriminated union with a null member now resolves to a nullable sealed class (e.g. `GetNotificationResult?`) and is decoded via its generated `fromJson`. --- .../dart/client.dart | 91 ++++++++++++++++++- .../blocks_codegen/lib/src/generator.dart | 2 + .../blocks_codegen/lib/src/parser.dart | 29 +++--- 3 files changed, 108 insertions(+), 14 deletions(-) diff --git a/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart b/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart index 2c6b9803..ce4b548f 100644 --- a/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart +++ b/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart @@ -10,6 +10,93 @@ export 'package:blocks_runtime/blocks_runtime.dart' show BlocksClient, BlocksRpc // --- API Namespaces --- +sealed class GetNotificationResult { + const GetNotificationResult(); + Map toJson(); + static GetNotificationResult fromJson(Map json) { + switch (json['type'] as String) { + case 'email': return EmailGetNotificationResult.fromJson(json); + case 'sms': return SmsGetNotificationResult.fromJson(json); + default: throw ArgumentError('Unknown type: ${json['type']}'); + } + } +} + +class EmailGetNotificationResult extends GetNotificationResult { + final String subject; + final String body; + + const EmailGetNotificationResult({ + required this.subject, + required this.body, + }); + + factory EmailGetNotificationResult.fromJson(Map json) { + return EmailGetNotificationResult( + subject: json['subject'] as String, + body: json['body'] as String, + ); + } + + @override + Map toJson() { + return { + 'type': 'email', + 'subject': subject, + 'body': body, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is EmailGetNotificationResult && + subject == other.subject && + body == other.body; + + @override + int get hashCode => Object.hash(subject, body); + + @override + String toString() => 'EmailGetNotificationResult(subject: $subject, body: $body)'; +} + +class SmsGetNotificationResult extends GetNotificationResult { + final String message; + + const SmsGetNotificationResult({ + required this.message, + }); + + factory SmsGetNotificationResult.fromJson(Map json) { + return SmsGetNotificationResult( + message: json['message'] as String, + ); + } + + @override + Map toJson() { + return { + 'type': 'sms', + 'message': message, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SmsGetNotificationResult && + message == other.message; + + @override + int get hashCode => message.hashCode; + + @override + String toString() => 'SmsGetNotificationResult(message: $message)'; +} + + + class ApiApi { final BlocksClient _client; ApiApi(this._client); @@ -22,12 +109,12 @@ class ApiApi { return (result as Map).map((k, v) => MapEntry(k, v as dynamic)); } - Future getNotification({required String id}) async { + Future getNotification({required String id}) async { final params = { 'id': id, }; final result = await _client.call('api.getNotification', params); - return result as dynamic; + return result == null ? null : GetNotificationResult.fromJson(result as Map); } } diff --git a/native/dart/packages/blocks_codegen/lib/src/generator.dart b/native/dart/packages/blocks_codegen/lib/src/generator.dart index 472734f8..08b5285b 100644 --- a/native/dart/packages/blocks_codegen/lib/src/generator.dart +++ b/native/dart/packages/blocks_codegen/lib/src/generator.dart @@ -729,6 +729,8 @@ class DartCodeGenerator { '$accessor == null ? null : $name.fromJson($accessor as Map)', SchemaReference(name: final name) => '$accessor == null ? null : $name.fromJson($accessor as Map)', + SealedClassType(name: final name) => + '$accessor == null ? null : $name.fromJson($accessor as Map)', _ => '$accessor', }; } diff --git a/native/dart/packages/blocks_codegen/lib/src/parser.dart b/native/dart/packages/blocks_codegen/lib/src/parser.dart index 1c74a19a..1d4ba00b 100644 --- a/native/dart/packages/blocks_codegen/lib/src/parser.dart +++ b/native/dart/packages/blocks_codegen/lib/src/parser.dart @@ -194,20 +194,25 @@ class OpenRpcParser { } TypeRef _parseOneOf(List> oneOf) { - // Check nullable pattern: [T, {type: "null"}] - if (oneOf.length == 2) { - final nullIdx = oneOf.indexWhere((s) => s['type'] == 'null'); - if (nullIdx != -1) { - final inner = oneOf[nullIdx == 0 ? 1 : 0]; - return NullableRef(_parseTypeRef(inner)); - } + // A `{"type":"null"}` member (e.g. from TS `Partial>`) does + // not participate in discrimination and must not become a sealed-class + // variant. Strip such members out, and if any were present mark the + // resolved union nullable. The remaining (non-null) arms drive the + // nullable/discriminated-union detection below. + final hasNullMember = oneOf.any((s) => s['type'] == 'null'); + final nonNull = oneOf.where((s) => s['type'] != 'null').toList(); + + // Nullable of a single member: [T, {type: "null"}] → T? + if (hasNullMember && nonNull.length == 1) { + return NullableRef(_parseTypeRef(nonNull.first)); } // Check discriminated union: all objects with a shared single-value enum field - if (oneOf.every((v) => v['type'] == 'object' && v.containsKey('properties'))) { - final discriminant = _findDiscriminant(oneOf); + if (nonNull.isNotEmpty && + nonNull.every((v) => v['type'] == 'object' && v.containsKey('properties'))) { + final discriminant = _findDiscriminant(nonNull); if (discriminant != null) { - final variants = oneOf.map((v) { + final variants = nonNull.map((v) { final props = (v['properties'] as Map) .map((k, val) => MapEntry(k, _parseTypeRef(val as Map))); final required = (v['required'] as List?) @@ -238,12 +243,12 @@ class OpenRpcParser { embeddedUnion: embeddedUnion, ); }).toList(); - return DiscriminatedUnionRef(discriminant: discriminant, variants: variants); + final union = DiscriminatedUnionRef(discriminant: discriminant, variants: variants); + return hasNullMember ? NullableRef(union) : union; } } // Fallback: treat as nullable of first non-null - final nonNull = oneOf.where((s) => s['type'] != 'null').toList(); if (nonNull.length == 1) { return NullableRef(_parseTypeRef(nonNull.first)); } From 74e72874a291ce4a13f7610c731812214f07b25a Mon Sep 17 00:00:00 2001 From: Ekjot <43255916+ekjotmultani@users.noreply.github.com> Date: Mon, 22 Jun 2026 13:30:18 -0700 Subject: [PATCH 2/2] feat(dart-codegen): support boolean-enum discriminated unions A oneOf whose arms discriminate on a boolean field ({"type":"boolean","enum":[true|false]}) previously resolved to `dynamic`. Generate a sealed class for it instead: - parser: detect a boolean discriminant (string still preferred) and read its value via toString() rather than casting a bool to String; - model/builder: carry a discriminantIsBoolean flag and name boolean arms after the field + value (e.g. IsUpdatedTrue / IsUpdatedFalse); - generator: switch on the real bool with exhaustive true/false cases and emit unquoted true/false in toJson; decode object/sealed map values via their generated fromJson. A nullable boolean-discriminated union used as a map value now resolves to `Map`. The string-discriminator path is unchanged and existing goldens remain byte-identical. --- .../dart/client.dart | 117 +++++++++++++++++- .../blocks_codegen/lib/src/builder.dart | 22 +++- .../blocks_codegen/lib/src/generator.dart | 66 ++++++++-- .../blocks_codegen/lib/src/model.dart | 11 +- .../blocks_codegen/lib/src/parser.dart | 39 +++++- 5 files changed, 236 insertions(+), 19 deletions(-) diff --git a/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart b/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart index ce4b548f..f49d646d 100644 --- a/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart +++ b/native/codegen-fixtures/24-nullable-discriminated-union/dart/client.dart @@ -8,8 +8,121 @@ export 'package:blocks_runtime/blocks_runtime.dart' show BlocksClient, BlocksRpc // --- Models --- +class IsUpdatedFalseNextStep { + final String name; + final String destination; + + const IsUpdatedFalseNextStep({ + required this.name, + required this.destination, + }); + + factory IsUpdatedFalseNextStep.fromJson(Map json) { + return IsUpdatedFalseNextStep( + name: json['name'] as String, + destination: json['destination'] as String, + ); + } + + Map toJson() { + return { + 'name': name, + 'destination': destination, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is IsUpdatedFalseNextStep && + name == other.name && + destination == other.destination; + + @override + int get hashCode => Object.hash(name, destination); + + @override + String toString() => 'IsUpdatedFalseNextStep(name: $name, destination: $destination)'; +} + + // --- API Namespaces --- +sealed class UpdateAttributesResult { + const UpdateAttributesResult(); + Map toJson(); + static UpdateAttributesResult fromJson(Map json) { + switch (json['isUpdated'] as bool) { + case true: return IsUpdatedTrue.fromJson(json); + case false: return IsUpdatedFalse.fromJson(json); + } + } +} + +class IsUpdatedTrue extends UpdateAttributesResult { + + const IsUpdatedTrue(); + + factory IsUpdatedTrue.fromJson(Map json) { + return IsUpdatedTrue( + ); + } + + @override + Map toJson() { + return { + 'isUpdated': true, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is IsUpdatedTrue; + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'IsUpdatedTrue()'; +} + +class IsUpdatedFalse extends UpdateAttributesResult { + final IsUpdatedFalseNextStep nextStep; + + const IsUpdatedFalse({ + required this.nextStep, + }); + + factory IsUpdatedFalse.fromJson(Map json) { + return IsUpdatedFalse( + nextStep: IsUpdatedFalseNextStep.fromJson(json['nextStep'] as Map), + ); + } + + @override + Map toJson() { + return { + 'isUpdated': false, + 'nextStep': nextStep.toJson(), + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is IsUpdatedFalse && + nextStep == other.nextStep; + + @override + int get hashCode => nextStep.hashCode; + + @override + String toString() => 'IsUpdatedFalse(nextStep: $nextStep)'; +} + + + sealed class GetNotificationResult { const GetNotificationResult(); Map toJson(); @@ -101,12 +214,12 @@ class ApiApi { final BlocksClient _client; ApiApi(this._client); - Future> updateAttributes({required Map attributes}) async { + Future> updateAttributes({required Map attributes}) async { final params = { 'attributes': attributes, }; final result = await _client.call('api.updateAttributes', params); - return (result as Map).map((k, v) => MapEntry(k, v as dynamic)); + return (result as Map).map((k, v) => MapEntry(k, v == null ? null : UpdateAttributesResult.fromJson(v as Map))); } Future getNotification({required String id}) async { diff --git a/native/dart/packages/blocks_codegen/lib/src/builder.dart b/native/dart/packages/blocks_codegen/lib/src/builder.dart index f168f7c4..ca8a58ae 100644 --- a/native/dart/packages/blocks_codegen/lib/src/builder.dart +++ b/native/dart/packages/blocks_codegen/lib/src/builder.dart @@ -47,10 +47,15 @@ class SealedClassType extends ResolvedType { String name; final String discriminant; final List variants; + + /// True when the discriminant is a boolean enum (true/false) rather than a + /// string enum. Drives bool (vs String) emission in the generator. + final bool discriminantIsBoolean; SealedClassType({ required this.name, required this.discriminant, required this.variants, + this.discriminantIsBoolean = false, }); } @@ -330,7 +335,7 @@ class CodegenModelBuilder { final fk = fieldKeys.map((f) => '${f.name}:${_typeKey(f.type)}${f.isRequired ? '!' : ''}').join(','); return '${v.discriminantValue}{$fk}'; }); - return 'sealed[${sealed.discriminant}]{${parts.join('|')}}'; + return 'sealed[${sealed.discriminant}${sealed.discriminantIsBoolean ? ':bool' : ''}]{${parts.join('|')}}'; } ResolvedType _resolveEnum(List values, String? hint, [String? path]) { @@ -405,7 +410,13 @@ class CodegenModelBuilder { final variants = groups.entries.map((entry) { final discValue = entry.key; final group = entry.value; - final className = '${_capitalize(discValue)}${_inferSuffix(name)}'; + // String discriminants keep the established `` scheme + // (e.g. `EmailGetNotificationResult`). A boolean discriminant has no + // descriptive value, so name the arm after the field + value + // (e.g. `IsUpdatedTrue`/`IsUpdatedFalse`), mirroring the Kotlin output. + final className = ref.discriminantIsBoolean + ? '${_capitalize(ref.discriminant)}${_capitalize(discValue)}' + : '${_capitalize(discValue)}${_inferSuffix(name)}'; final variantPath = path == null ? null : '$path>$discValue'; final List fields; @@ -454,7 +465,12 @@ class CodegenModelBuilder { ); }).toList(); - final sealed = SealedClassType(name: name, discriminant: ref.discriminant, variants: variants); + final sealed = SealedClassType( + name: name, + discriminant: ref.discriminant, + variants: variants, + discriminantIsBoolean: ref.discriminantIsBoolean, + ); // Structural dedup: reuse an existing sealed class with the same shape // (ported from #682 — recursive structural + sealed-class dedup). diff --git a/native/dart/packages/blocks_codegen/lib/src/generator.dart b/native/dart/packages/blocks_codegen/lib/src/generator.dart index 08b5285b..fa14db4f 100644 --- a/native/dart/packages/blocks_codegen/lib/src/generator.dart +++ b/native/dart/packages/blocks_codegen/lib/src/generator.dart @@ -265,12 +265,23 @@ class DartCodeGenerator { buf.writeln(' const ${sealed.name}();'); buf.writeln(' Map toJson();'); buf.writeln(' static ${sealed.name} fromJson(Map json) {'); - buf.writeln(" switch (json['${sealed.discriminant}'] as String) {"); - for (final v in sealed.variants) { - buf.writeln(" case '${v.discriminantValue}': return ${v.className}.fromJson(json);"); + if (sealed.discriminantIsBoolean) { + // Boolean discriminant: switch on the real bool; case labels and the + // serialized value are unquoted true/false literals. true/false are + // exhaustive for a bool, so no (unreachable) default clause is emitted. + buf.writeln(" switch (json['${sealed.discriminant}'] as bool) {"); + for (final v in sealed.variants) { + buf.writeln(" case ${v.discriminantValue}: return ${v.className}.fromJson(json);"); + } + buf.writeln(' }'); + } else { + buf.writeln(" switch (json['${sealed.discriminant}'] as String) {"); + for (final v in sealed.variants) { + buf.writeln(" case '${v.discriminantValue}': return ${v.className}.fromJson(json);"); + } + buf.writeln(" default: throw ArgumentError('Unknown ${sealed.discriminant}: \${json['${sealed.discriminant}']}');"); + buf.writeln(' }'); } - buf.writeln(" default: throw ArgumentError('Unknown ${sealed.discriminant}: \${json['${sealed.discriminant}']}');"); - buf.writeln(' }'); buf.writeln(' }'); buf.writeln('}'); buf.writeln(); @@ -328,7 +339,11 @@ class DartCodeGenerator { buf.writeln(' @override'); buf.writeln(' Map toJson() {'); buf.writeln(' return {'); - buf.writeln(" '${sealed.discriminant}': '${v.discriminantValue}',"); + if (sealed.discriminantIsBoolean) { + buf.writeln(" '${sealed.discriminant}': ${v.discriminantValue},"); + } else { + buf.writeln(" '${sealed.discriminant}': '${v.discriminantValue}',"); + } for (final f in v.fields) { final ident = _escapeIdentifier(f.name); final expr = _toJsonExpr(ident, f.type, allTypes, !f.isRequired); @@ -712,7 +727,7 @@ class DartCodeGenerator { PrimitiveType(dartType: final dt) => dt == 'int' ? '($accessor as num).toInt()' : '$accessor as $dt', NullableType(inner: final inner) => _deserializeNullable(accessor, inner, allTypes), ListType(items: final items) => _deserializeList(accessor, items, allTypes), - MapType(valueType: final vt) => '($accessor as Map).map((k, v) => MapEntry(k, v as ${_dartTypeStr(vt, allTypes)}))', + MapType(valueType: final vt) => '($accessor as Map).map((k, v) => MapEntry(k, ${_mapValueFromJson('v', vt, allTypes)}))', RecordType(name: final name) => '$name.fromJson($accessor as Map)', SchemaReference(name: final name) => _deserializeSchema(accessor, name, allTypes), SealedClassType(name: final name) => '$name.fromJson($accessor as Map)', @@ -735,6 +750,43 @@ class DartCodeGenerator { }; } + /// Per-value deserialization for a `Map` entry value `v` (dynamic). + /// Primitive value types keep the plain `v as T` cast (byte-identical to the + /// prior output); object-like value types (records, sealed classes) — and the + /// nullable variants thereof — are decoded via their generated `fromJson`. + String _mapValueFromJson(String v, ResolvedType valueType, Map allTypes) { + switch (valueType) { + case SealedClassType(name: final n): + return '$n.fromJson($v as Map)'; + case RecordType(name: final n): + return '$n.fromJson($v as Map)'; + case SchemaReference(name: final n): + final resolved = allTypes[n]; + if (resolved is RecordType || resolved is SealedClassType) { + return '$n.fromJson($v as Map)'; + } + if (resolved is EnumType) { + return '$n.fromJson($v as String)'; + } + return '$v as $n'; + case NullableType(inner: final inner): + final innerName = switch (inner) { + SealedClassType(name: final n) => n, + RecordType(name: final n) => n, + SchemaReference(name: final n) + when allTypes[n] is RecordType || allTypes[n] is SealedClassType => + n, + _ => null, + }; + if (innerName != null) { + return '$v == null ? null : $innerName.fromJson($v as Map)'; + } + return '$v as ${_dartTypeStr(valueType, allTypes)}'; + default: + return '$v as ${_dartTypeStr(valueType, allTypes)}'; + } + } + String _deserializeList(String accessor, ResolvedType items, Map allTypes) { final itemExpr = switch (items) { RecordType(name: final name) => '(e) => $name.fromJson(e as Map)', diff --git a/native/dart/packages/blocks_codegen/lib/src/model.dart b/native/dart/packages/blocks_codegen/lib/src/model.dart index 13077fb1..802cc9b2 100644 --- a/native/dart/packages/blocks_codegen/lib/src/model.dart +++ b/native/dart/packages/blocks_codegen/lib/src/model.dart @@ -68,7 +68,16 @@ class UnionLiteralRef extends TypeRef { class DiscriminatedUnionRef extends TypeRef { final String discriminant; final List variants; - const DiscriminatedUnionRef({required this.discriminant, required this.variants}); + + /// True when the discriminant is a boolean-enum (`{"type":"boolean", + /// "enum":[true|false]}`) rather than the usual string enum. Drives bool + /// (vs String) discriminant handling in the generator. + final bool discriminantIsBoolean; + const DiscriminatedUnionRef({ + required this.discriminant, + required this.variants, + this.discriminantIsBoolean = false, + }); } class UnionVariant { diff --git a/native/dart/packages/blocks_codegen/lib/src/parser.dart b/native/dart/packages/blocks_codegen/lib/src/parser.dart index 1d4ba00b..adb57cc8 100644 --- a/native/dart/packages/blocks_codegen/lib/src/parser.dart +++ b/native/dart/packages/blocks_codegen/lib/src/parser.dart @@ -212,6 +212,14 @@ class OpenRpcParser { nonNull.every((v) => v['type'] == 'object' && v.containsKey('properties'))) { final discriminant = _findDiscriminant(nonNull); if (discriminant != null) { + // A boolean-enum discriminant (`{"type":"boolean","enum":[true|false]}`) + // is handled distinctly from the usual string enum: its JSON value is a + // real bool, not a quoted string. + final discriminantIsBoolean = nonNull.every((v) { + final p = (v['properties'] as Map)[discriminant] + as Map; + return p['type'] == 'boolean'; + }); final variants = nonNull.map((v) { final props = (v['properties'] as Map) .map((k, val) => MapEntry(k, _parseTypeRef(val as Map))); @@ -219,9 +227,13 @@ class OpenRpcParser { ?.cast() .toSet() ?? {}; + // `.toString()` yields the literal value for both string ("create") + // and boolean (true/false) discriminants; `as String` would throw on + // a bool. final discValue = ((v['properties'] as Map)[discriminant] - as Map)['enum'][0] as String; + as Map)['enum'][0] + .toString(); // Remove discriminant from properties final filteredProps = Map.from(props)..remove(discriminant); final filteredRequired = Set.from(required)..remove(discriminant); @@ -243,7 +255,11 @@ class OpenRpcParser { embeddedUnion: embeddedUnion, ); }).toList(); - final union = DiscriminatedUnionRef(discriminant: discriminant, variants: variants); + final union = DiscriminatedUnionRef( + discriminant: discriminant, + variants: variants, + discriminantIsBoolean: discriminantIsBoolean, + ); return hasNullMember ? NullableRef(union) : union; } } @@ -258,16 +274,27 @@ class OpenRpcParser { String? _findDiscriminant(List> variants) { final firstProps = (variants.first['properties'] as Map).keys.toList(); - for (final field in firstProps) { - final allMatch = variants.every((v) { + // A discriminant is a required-shaped single-value enum field shared by + // every arm. String discriminants are the classic case; a boolean-enum + // field (`{"type":"boolean","enum":[true|false]}`) also discriminates. + bool isDiscriminantField(String field, String type) { + return variants.every((v) { final props = v['properties'] as Map; if (!props.containsKey(field)) return false; final p = props[field] as Map; - return p['type'] == 'string' && + return p['type'] == type && p.containsKey('enum') && (p['enum'] as List).length == 1; }); - if (allMatch) return field; + } + + // Prefer a string discriminant (preserves existing selection), then fall + // back to a boolean one. + for (final field in firstProps) { + if (isDiscriminantField(field, 'string')) return field; + } + for (final field in firstProps) { + if (isDiscriminantField(field, 'boolean')) return field; } return null; }