Skip to content

Commit c5ef094

Browse files
authored
feat(gql): add discriminated union type support - ProductOrSubscription (#33)
Add ProductOrSubscription union type to GraphQL schema and implement discriminated union type narrowing across all platforms (TypeScript, Swift, Kotlin, Dart). Changes: - Add `union ProductOrSubscription = Product | ProductSubscription` to GraphQL schema - Update type generators to handle union of unions pattern - Optimize platform-specific type extraction with nested pattern matching - Fix TypeScript FetchProductsResult to support mixed arrays - Add ProductOrSubscription interface implementation to Product and ProductSubscription TypeScript: - Extend FetchProductsResult type to include `(Product | ProductSubscription)[]` - Fix Query.fetchProducts to use FetchProductsResult type alias Swift: - Use nested pattern matching for direct type extraction - Change `.productSubscription(let sub), case .productSubscriptionIos(let val)` to `.productSubscription(.productSubscriptionIos(let val))` Kotlin: - Add ProductOrSubscription interface implementation with override modifiers - Optimize type extraction to filter platform-specific types directly - Change from wrapper types to direct sealed interface casting Dart: - Add implements clause for Product and ProductSubscription - Handle union of unions in type generation Generator scripts: - Add getInterfaces() type check for union members in all generators - Remove manual ProductOrSubscription post-processing (now auto-generated) - Add post-processing to make unions implement ProductOrSubscription Benefits: - Enables type-safe discriminated union narrowing - Eliminates unnecessary intermediate object creation - Platform-specific code only handles its own types - Better IDE autocomplete and compile-time type safety <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Fetch mixed collections of products and subscriptions in a single "all" response. * Introduced a unified ProductOrSubscription type to handle products and subscriptions consistently across platforms. * **Chores** * Updated GraphQL generation and language-specific type generators to emit wrapper-based union handling. * Updated GraphQL generator version metadata. * **Refactor** * Simplified platform-specific extraction and matching logic for product/subscription items. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 1fedfee commit c5ef094

16 files changed

Lines changed: 318 additions & 274 deletions

File tree

openiap-versions.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"gql": "1.2.5",
2+
"gql": "1.2.4",
33
"docs": "1.2.5",
44
"google": "1.3.5",
55
"apple": "1.2.32"

packages/apple/Sources/Models/OpenIapSerialization.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,8 +213,7 @@ public enum OpenIapSerialization {
213213
return value
214214
}
215215
let iosSubscriptions = allItems.compactMap { item -> ProductSubscriptionIOS? in
216-
guard case .subscription(let subscription) = item,
217-
case .productSubscriptionIos(let value) = subscription
216+
guard case .productSubscription(.productSubscriptionIos(let value)) = item
218217
else { return nil }
219218
return value
220219
}

packages/apple/Sources/Models/Types.swift

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -245,43 +245,10 @@ public struct ExternalPurchaseNoticeResultIOS: Codable {
245245
public var result: ExternalPurchaseNoticeAction
246246
}
247247

248-
249-
// Union type for FetchProductsResult.all
250-
public enum ProductOrSubscription: Codable {
251-
case product(Product)
252-
case subscription(ProductSubscription)
253-
254-
public init(from decoder: Decoder) throws {
255-
let container = try decoder.singleValueContainer()
256-
if let product = try? container.decode(Product.self) {
257-
self = .product(product)
258-
return
259-
}
260-
if let subscription = try? container.decode(ProductSubscription.self) {
261-
self = .subscription(subscription)
262-
return
263-
}
264-
throw DecodingError.dataCorruptedError(
265-
in: container,
266-
debugDescription: "Cannot decode ProductOrSubscription"
267-
)
268-
}
269-
270-
public func encode(to encoder: Encoder) throws {
271-
var container = encoder.singleValueContainer()
272-
switch self {
273-
case .product(let product):
274-
try container.encode(product)
275-
case .subscription(let subscription):
276-
try container.encode(subscription)
277-
}
278-
}
279-
}
280-
281248
public enum FetchProductsResult {
249+
case all([ProductOrSubscription]?)
282250
case products([Product]?)
283251
case subscriptions([ProductSubscription]?)
284-
case all([ProductOrSubscription]?)
285252
}
286253

287254
public struct PricingPhaseAndroid: Codable {
@@ -1032,6 +999,11 @@ public enum Product: Codable, ProductCommon {
1032999
}
10331000
}
10341001

1002+
public enum ProductOrSubscription: Codable {
1003+
case product(Product)
1004+
case productSubscription(ProductSubscription)
1005+
}
1006+
10351007
public enum ProductSubscription: Codable, ProductCommon {
10361008
case productSubscriptionAndroid(ProductSubscriptionAndroid)
10371009
case productSubscriptionIos(ProductSubscriptionIOS)

packages/apple/Sources/OpenIapModule+ObjC.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,7 @@ import StoreKit
7474
return value
7575
}
7676
let subscriptionIOS = allItems.compactMap { item -> ProductSubscriptionIOS? in
77-
guard case .subscription(let subscription) = item,
78-
case .productSubscriptionIos(let value) = subscription
77+
guard case .productSubscription(.productSubscriptionIos(let value)) = item
7978
else { return nil }
8079
return value
8180
}

packages/apple/Sources/OpenIapStore.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,8 @@ public final class OpenIapStore: ObservableObject {
193193
return nil
194194
}
195195
subscriptions = allItems.compactMap { item in
196-
if case .subscription(let subscription) = item {
197-
return subscription
196+
if case .productSubscription(.productSubscriptionIos(let subscription)) = item {
197+
return .productSubscriptionIos(subscription)
198198
}
199199
return nil
200200
}

packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -712,21 +712,14 @@ public data class ExternalPurchaseNoticeResultIOS(
712712
)
713713
}
714714

715-
716-
// Union type for FetchProductsResult.all
717-
public sealed interface ProductOrSubscription {
718-
data class ProductItem(val value: Product) : ProductOrSubscription
719-
data class SubscriptionItem(val value: ProductSubscription) : ProductOrSubscription
720-
}
721-
722715
public sealed interface FetchProductsResult
723716

717+
public data class FetchProductsResultAll(val value: List<ProductOrSubscription>?) : FetchProductsResult
718+
724719
public data class FetchProductsResultProducts(val value: List<Product>?) : FetchProductsResult
725720

726721
public data class FetchProductsResultSubscriptions(val value: List<ProductSubscription>?) : FetchProductsResult
727722

728-
public data class FetchProductsResultAll(val value: List<ProductOrSubscription>?) : FetchProductsResult
729-
730723
public data class PricingPhaseAndroid(
731724
val billingCycleCount: Int,
732725
val billingPeriod: String,
@@ -2170,6 +2163,30 @@ public sealed interface Product : ProductCommon {
21702163
}
21712164
}
21722165

2166+
public sealed interface ProductOrSubscription {
2167+
fun toJson(): Map<String, Any?>
2168+
2169+
companion object {
2170+
fun fromJson(json: Map<String, Any?>): ProductOrSubscription {
2171+
return when (json["__typename"] as String?) {
2172+
"ProductAndroid" -> ProductItem(Product.fromJson(json))
2173+
"ProductIOS" -> ProductItem(Product.fromJson(json))
2174+
"ProductSubscriptionAndroid" -> ProductSubscriptionItem(ProductSubscription.fromJson(json))
2175+
"ProductSubscriptionIOS" -> ProductSubscriptionItem(ProductSubscription.fromJson(json))
2176+
else -> throw IllegalArgumentException("Unknown __typename for ProductOrSubscription: ${json["__typename"]}")
2177+
}
2178+
}
2179+
}
2180+
2181+
data class ProductItem(val value: Product) : ProductOrSubscription {
2182+
override fun toJson() = value.toJson()
2183+
}
2184+
2185+
data class ProductSubscriptionItem(val value: ProductSubscription) : ProductOrSubscription {
2186+
override fun toJson() = value.toJson()
2187+
}
2188+
}
2189+
21732190
public sealed interface ProductSubscription : ProductCommon {
21742191
fun toJson(): Map<String, Any?>
21752192

packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,15 +280,19 @@ class OpenIapStore(private val module: OpenIapProtocol) {
280280
}
281281
is FetchProductsResultAll -> {
282282
// Handle the all case - merge both products and subscriptions
283-
// The result.value is List<ProductOrSubscription>? containing union of Product and ProductSubscription
283+
// The result.value is List<ProductOrSubscription>? containing union wrappers
284284
val items = result.value ?: emptyList()
285285

286-
// Extract products and subscriptions from ProductOrSubscription union
286+
// Extract Android-specific products and subscriptions from wrapper classes
287287
val allProducts = items.mapNotNull {
288-
(it as? ProductOrSubscription.ProductItem)?.value
288+
(it as? ProductOrSubscription.ProductItem)?.value?.let { product ->
289+
if (product is ProductAndroid) product else null
290+
}
289291
}
290292
val allSubs = items.mapNotNull {
291-
(it as? ProductOrSubscription.SubscriptionItem)?.value
293+
(it as? ProductOrSubscription.ProductSubscriptionItem)?.value?.let { subscription ->
294+
if (subscription is ProductSubscriptionAndroid) subscription else null
295+
}
292296
}
293297

294298
// Merge products

packages/gql/scripts/fix-generated-types.mjs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,18 @@ for (const file of schemaDefinitionFiles) {
310310
}
311311
}
312312

313+
// Extend FetchProductsResult to support mixed arrays for 'all' type
314+
// MUST be done BEFORE interface parsing to ensure optionalUnionInterfaces map has the correct union
315+
// The generated union `Product[] | ProductSubscription[] | null` doesn't support mixed arrays
316+
// Add `(Product | ProductSubscription)[]` to the union to enable type narrowing
317+
const fetchProductsResultPattern = /export type FetchProductsResult = Product\[\] \| ProductSubscription\[\] \| null;/;
318+
if (fetchProductsResultPattern.test(content)) {
319+
content = content.replace(
320+
fetchProductsResultPattern,
321+
'export type FetchProductsResult = Product[] | ProductSubscription[] | (Product | ProductSubscription)[] | null;'
322+
);
323+
}
324+
313325
const singleFieldInterfaceTypes = new Map();
314326
const optionalUnionInterfaces = new Map();
315327
const interfacePattern = /export interface (\w+) \{\n([\s\S]*?)\n\}\n/g;
@@ -489,17 +501,6 @@ for (const [name, unionType] of optionalUnionInterfaces) {
489501
content = content.replace(pattern, `export type ${name} = ${unionType};\n\n`);
490502
}
491503

492-
// Extend FetchProductsResult to support mixed arrays for 'all' type
493-
// The generated union `Product[] | ProductSubscription[] | null` doesn't support mixed arrays
494-
// Add `(Product | ProductSubscription)[]` to the union to enable type narrowing
495-
const fetchProductsResultPattern = /export type FetchProductsResult = Product\[\] \| ProductSubscription\[\] \| null;/;
496-
if (fetchProductsResultPattern.test(content)) {
497-
content = content.replace(
498-
fetchProductsResultPattern,
499-
'export type FetchProductsResult = Product[] | ProductSubscription[] | (Product | ProductSubscription)[] | null;'
500-
);
501-
}
502-
503504
const futureFields = new Set();
504505
for (const file of schemaFiles) {
505506
let previousWasMarker = false;
@@ -548,6 +549,14 @@ for (const [name, unionType] of optionalUnionInterfaces) {
548549
}
549550
}
550551

552+
// Fix Query interface to use FetchProductsResult type alias instead of inline union
553+
// This ensures the Query['fetchProducts'] return type matches our implementation
554+
// Must be done AFTER singleFieldInterfaceTypes replacement expands the type
555+
content = content.replace(
556+
/fetchProducts: Promise<\(Product\[\] \| ProductSubscription\[\] \| \(Product \| ProductSubscription\)\[\] \| null\)>/g,
557+
'fetchProducts: Promise<FetchProductsResult>'
558+
);
559+
551560
content = content.replace(/^\s*_placeholder\??: [^;]+;\n/gm, '');
552561

553562
const ROOT_DEFINITIONS = ['Query', 'Mutation', 'Subscription'];

packages/gql/scripts/generate-dart-types.mjs

Lines changed: 79 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -668,16 +668,28 @@ const printUnion = (unionType) => {
668668
let sharedInterfaceNames = [];
669669
if (memberTypes.length > 0) {
670670
const [firstMember, ...otherMembers] = memberTypes;
671-
const firstInterfaces = new Set(firstMember.getInterfaces().map((iface) => iface.name));
672-
for (const member of otherMembers) {
673-
const memberInterfaces = new Set(member.getInterfaces().map((iface) => iface.name));
674-
for (const ifaceName of Array.from(firstInterfaces)) {
675-
if (!memberInterfaces.has(ifaceName)) {
676-
firstInterfaces.delete(ifaceName);
671+
// Check if member is a union (unions don't have getInterfaces)
672+
if (typeof firstMember.getInterfaces === 'function') {
673+
const firstInterfaces = new Set(firstMember.getInterfaces().map((iface) => iface.name));
674+
let allMembersHaveInterfaces = true;
675+
for (const member of otherMembers) {
676+
if (typeof member.getInterfaces === 'function') {
677+
const memberInterfaces = new Set(member.getInterfaces().map((iface) => iface.name));
678+
for (const ifaceName of Array.from(firstInterfaces)) {
679+
if (!memberInterfaces.has(ifaceName)) {
680+
firstInterfaces.delete(ifaceName);
681+
}
682+
}
683+
} else {
684+
// Member is a union, so no shared interfaces
685+
allMembersHaveInterfaces = false;
686+
break;
677687
}
678688
}
689+
if (allMembersHaveInterfaces) {
690+
sharedInterfaceNames = Array.from(firstInterfaces).sort();
691+
}
679692
}
680-
sharedInterfaceNames = Array.from(firstInterfaces).sort();
681693
}
682694

683695
const implementsClause = sharedInterfaceNames.length ? ` implements ${sharedInterfaceNames.join(', ')}` : '';
@@ -687,9 +699,54 @@ const printUnion = (unionType) => {
687699
lines.push(` factory ${unionType.name}.fromJson(Map<String, dynamic> json) {`);
688700
lines.push(` final typeName = json['__typename'] as String?;`);
689701
lines.push(' switch (typeName) {');
690-
members.forEach((member) => {
691-
lines.push(` case '${member}':`, ` return ${member}.fromJson(json);`);
702+
703+
// Flatten nested unions: if a member is itself a union, include its concrete members
704+
const concreteMembers = new Set();
705+
for (const memberType of memberTypes) {
706+
if (isUnionType(memberType)) {
707+
// Member is a union, get its concrete members
708+
const nestedMembers = memberType.getTypes();
709+
for (const nestedMember of nestedMembers) {
710+
concreteMembers.add(nestedMember.name);
711+
}
712+
} else {
713+
// Member is a concrete type
714+
concreteMembers.add(memberType.name);
715+
}
716+
}
717+
718+
// Track nested unions that need wrapper classes
719+
const nestedUnions = new Set();
720+
721+
// Generate case for each concrete member, wrapping nested unions
722+
const sortedConcreteMembers = Array.from(concreteMembers).sort();
723+
sortedConcreteMembers.forEach((concreteMember) => {
724+
// Find which direct member this concrete type belongs to
725+
let delegateTo = concreteMember;
726+
let isNestedUnion = false;
727+
728+
for (const memberType of memberTypes) {
729+
if (isUnionType(memberType)) {
730+
const nestedMembers = memberType.getTypes().map(t => t.name);
731+
if (nestedMembers.includes(concreteMember)) {
732+
delegateTo = memberType.name;
733+
isNestedUnion = true;
734+
nestedUnions.add(memberType.name);
735+
break;
736+
}
737+
}
738+
}
739+
740+
if (isNestedUnion) {
741+
// Wrap nested union in a typed wrapper class
742+
const wrapperName = `${unionType.name}${delegateTo}`;
743+
lines.push(` case '${concreteMember}':`, ` return ${wrapperName}(${delegateTo}.fromJson(json));`);
744+
} else {
745+
// Direct member, no wrapping needed
746+
lines.push(` case '${concreteMember}':`, ` return ${delegateTo}.fromJson(json);`);
747+
}
692748
});
749+
693750
lines.push(' }');
694751
lines.push(` throw ArgumentError('Unknown __typename for ${unionType.name}: $typeName');`);
695752
lines.push(' }', '');
@@ -722,6 +779,18 @@ const printUnion = (unionType) => {
722779

723780
lines.push(' Map<String, dynamic> toJson();');
724781
lines.push('}', '');
782+
783+
// Generate wrapper classes for nested unions
784+
for (const nestedUnionName of Array.from(nestedUnions).sort()) {
785+
const wrapperName = `${unionType.name}${nestedUnionName}`;
786+
lines.push(`class ${wrapperName} extends ${unionType.name} {`);
787+
lines.push(` const ${wrapperName}(this.value);`);
788+
lines.push(` final ${nestedUnionName} value;`);
789+
lines.push('');
790+
lines.push(' @override');
791+
lines.push(' Map<String, dynamic> toJson() => value.toJson();');
792+
lines.push('}', '');
793+
}
725794
};
726795

727796
const expandInputToParams = (inputTypeName) => {
@@ -989,45 +1058,9 @@ for (const [typeName, literals] of Object.entries(productTypeMapping)) {
9891058
}
9901059
}
9911060

992-
// Post-process: Add ProductOrSubscription union class
993-
// This allows FetchProductsResult.all to contain heterogeneous lists
994-
const productOrSubscriptionUnion = `
995-
// Union type for FetchProductsResult.all
996-
abstract class ProductOrSubscription {
997-
const ProductOrSubscription();
998-
}
999-
1000-
class ProductOrSubscriptionProduct extends ProductOrSubscription {
1001-
const ProductOrSubscriptionProduct(this.value);
1002-
final Product value;
1003-
}
1004-
1005-
class ProductOrSubscriptionSubscription extends ProductOrSubscription {
1006-
const ProductOrSubscriptionSubscription(this.value);
1007-
final ProductSubscription value;
1008-
}
1009-
`;
1010-
1061+
// All unions including nested ones are auto-generated with proper wrapper classes
10111062
let output = lines.join('\n');
10121063

1013-
// Insert ProductOrSubscription before FetchProductsResult
1014-
const fetchProductsResultAbstractPattern = /abstract class FetchProductsResult \{/;
1015-
if (fetchProductsResultAbstractPattern.test(output)) {
1016-
output = output.replace(
1017-
fetchProductsResultAbstractPattern,
1018-
productOrSubscriptionUnion + '\nabstract class FetchProductsResult {'
1019-
);
1020-
}
1021-
1022-
// Add the 'all' case to FetchProductsResult
1023-
const fetchProductsResultPattern = /(class FetchProductsResultSubscriptions extends FetchProductsResult \{\n const FetchProductsResultSubscriptions\(this\.value\);\n final List<ProductSubscription>\? value;\n\})/;
1024-
if (fetchProductsResultPattern.test(output)) {
1025-
output = output.replace(
1026-
fetchProductsResultPattern,
1027-
'$1\n\nclass FetchProductsResultAll extends FetchProductsResult {\n const FetchProductsResultAll(this.value);\n final List<ProductOrSubscription>? value;\n}'
1028-
);
1029-
}
1030-
10311064
// Fix enum default values - Dart uses PascalCase for enum values
10321065
output = output.replace(/IapPlatform\.ios/g, 'IapPlatform.IOS');
10331066
output = output.replace(/IapPlatform\.android/g, 'IapPlatform.Android');

0 commit comments

Comments
 (0)