Skip to content

Commit 02429bc

Browse files
WendellXYclaude
andcommitted
feat(core): add flexible type coercion for JSONValue
Add coercedBoolValue, coercedIntValue, coercedDoubleValue, and coercedStringValue accessors that convert across JSON types (e.g. string "true"/"yes"/"1" → Bool, numeric strings → Int/Double). Also extend Compatibility numeric accessors (int64Value, int8Value, numberValue) to coerce from .bool and .string. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c5e2731 commit 02429bc

3 files changed

Lines changed: 364 additions & 12 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
//
2+
// JSONValue+Coercion.swift
3+
// CodableKit
4+
//
5+
// Created by Wendell Wang on 2026/4/2.
6+
//
7+
8+
// MARK: - Coercing Type Accessors
9+
10+
extension JSONValue {
11+
12+
// MARK: Bool
13+
14+
private static let truthyStrings: Set<String> = ["true", "t", "yes", "y", "1"]
15+
private static let falsyStrings: Set<String> = ["false", "f", "no", "n", "0"]
16+
17+
/// Returns a `Bool` by coercing from the underlying value.
18+
///
19+
/// Coercion rules:
20+
/// - `.bool` → direct value
21+
/// - `.int` → `true` when non-zero
22+
/// - `.double` → `true` when non-zero
23+
/// - `.string` → `true` for `"true"`, `"t"`, `"yes"`, `"y"`, `"1"` (case-insensitive);
24+
/// `false` for `"false"`, `"f"`, `"no"`, `"n"`, `"0"` (case-insensitive);
25+
/// `nil` for all other strings
26+
/// - `.null`, `.array`, `.object` → `nil`
27+
public var coercedBoolValue: Bool? {
28+
switch self {
29+
case .bool(let value):
30+
return value
31+
case .int(let value):
32+
return value != 0
33+
case .double(let value):
34+
return value != 0
35+
case .string(let value):
36+
let lowered = value.lowercased()
37+
if Self.truthyStrings.contains(lowered) { return true }
38+
if Self.falsyStrings.contains(lowered) { return false }
39+
return nil
40+
case .null, .array, .object:
41+
return nil
42+
}
43+
}
44+
45+
// MARK: Int
46+
47+
/// Returns an `Int` by coercing from the underlying value.
48+
///
49+
/// Coercion rules:
50+
/// - `.int` → direct value
51+
/// - `.double` → exact conversion via `Int(exactly:)` (returns `nil` for fractional values)
52+
/// - `.bool` → `1` for `true`, `0` for `false`
53+
/// - `.string` → parsed via `Int(_:)`, falling back to `Double` → `Int(exactly:)` for strings like `"3.0"`
54+
/// - `.null`, `.array`, `.object` → `nil`
55+
public var coercedIntValue: Int? {
56+
switch self {
57+
case .int(let value):
58+
return value
59+
case .double(let value):
60+
return Int(exactly: value)
61+
case .bool(let value):
62+
return value ? 1 : 0
63+
case .string(let value):
64+
if let intValue = Int(value) { return intValue }
65+
if let doubleValue = Double(value) { return Int(exactly: doubleValue) }
66+
return nil
67+
case .null, .array, .object:
68+
return nil
69+
}
70+
}
71+
72+
// MARK: Double
73+
74+
/// Returns a `Double` by coercing from the underlying value.
75+
///
76+
/// Coercion rules:
77+
/// - `.double` → direct value
78+
/// - `.int` → widened to `Double`
79+
/// - `.bool` → `1.0` for `true`, `0.0` for `false`
80+
/// - `.string` → parsed via `Double(_:)`
81+
/// - `.null`, `.array`, `.object` → `nil`
82+
public var coercedDoubleValue: Double? {
83+
switch self {
84+
case .double(let value):
85+
return value
86+
case .int(let value):
87+
return Double(value)
88+
case .bool(let value):
89+
return value ? 1.0 : 0.0
90+
case .string(let value):
91+
return Double(value)
92+
case .null, .array, .object:
93+
return nil
94+
}
95+
}
96+
97+
// MARK: String
98+
99+
/// Returns a `String` by coercing from the underlying value.
100+
///
101+
/// Coercion rules:
102+
/// - `.string` → direct value
103+
/// - `.bool` → `"true"` or `"false"`
104+
/// - `.int` → string representation
105+
/// - `.double` → string representation
106+
/// - `.null` → `"null"`
107+
/// - `.array`, `.object` → `nil`
108+
public var coercedStringValue: String? {
109+
switch self {
110+
case .string(let value):
111+
return value
112+
case .bool(let value):
113+
return value ? "true" : "false"
114+
case .int(let value):
115+
return String(value)
116+
case .double(let value):
117+
return String(value)
118+
case .null:
119+
return "null"
120+
case .array, .object:
121+
return nil
122+
}
123+
}
124+
}

Sources/CodableKit/JSONValue+Compatibility.swift

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,30 +26,42 @@ extension JSONValue {
2626
// MARK: - Numeric Accessors
2727

2828
extension JSONValue {
29-
/// Returns the numeric value as `Int64`, converting from `.int` or truncating from `.double`.
29+
/// Returns the numeric value as `Int64`, converting from `.int`, `.double`, `.bool`, or numeric `.string`.
3030
public var int64Value: Int64? {
3131
switch self {
32-
case .int(let value): Int64(value)
33-
case .double(let value): Int64(exactly: value)
34-
default: nil
32+
case .int(let value): return Int64(value)
33+
case .double(let value): return Int64(exactly: value)
34+
case .bool(let value): return value ? 1 : 0
35+
case .string(let value):
36+
if let i = Int64(value) { return i }
37+
if let d = Double(value) { return Int64(exactly: d) }
38+
return nil
39+
default: return nil
3540
}
3641
}
3742

38-
/// Returns the numeric value as `Int8`, converting from `.int` or truncating from `.double`.
43+
/// Returns the numeric value as `Int8`, converting from `.int`, `.double`, `.bool`, or numeric `.string`.
3944
public var int8Value: Int8? {
4045
switch self {
41-
case .int(let value): Int8(exactly: value)
42-
case .double(let value): Int8(exactly: value)
43-
default: nil
46+
case .int(let value): return Int8(exactly: value)
47+
case .double(let value): return Int8(exactly: value)
48+
case .bool(let value): return value ? 1 : 0
49+
case .string(let value):
50+
if let i = Int(value), let i8 = Int8(exactly: i) { return i8 }
51+
if let d = Double(value) { return Int8(exactly: d) }
52+
return nil
53+
default: return nil
4454
}
4555
}
4656

47-
/// Returns the numeric value as `Double`, converting from `.int` or returning `.double` directly.
57+
/// Returns the numeric value as `Double`, converting from `.int`, `.double`, `.bool`, or numeric `.string`.
4858
public var numberValue: Double? {
4959
switch self {
50-
case .int(let value): Double(value)
51-
case .double(let value): value
52-
default: nil
60+
case .int(let value): return Double(value)
61+
case .double(let value): return value
62+
case .bool(let value): return value ? 1.0 : 0.0
63+
case .string(let value): return Double(value)
64+
default: return nil
5365
}
5466
}
5567
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
//
2+
// JSONValueCoercionTests.swift
3+
// CodableKitTests
4+
//
5+
// Created by Wendell Wang on 2026/4/2.
6+
//
7+
8+
import CodableKit
9+
import Testing
10+
11+
@Suite("JSONValue coercion tests")
12+
struct JSONValueCoercionTests {
13+
14+
// MARK: - coercedBoolValue
15+
16+
@Suite("coercedBoolValue")
17+
struct CoercedBool {
18+
@Test func from_bool() {
19+
#expect(JSONValue.bool(true).coercedBoolValue == true)
20+
#expect(JSONValue.bool(false).coercedBoolValue == false)
21+
}
22+
23+
@Test func from_int() {
24+
#expect(JSONValue.int(1).coercedBoolValue == true)
25+
#expect(JSONValue.int(0).coercedBoolValue == false)
26+
#expect(JSONValue.int(-1).coercedBoolValue == true)
27+
#expect(JSONValue.int(42).coercedBoolValue == true)
28+
}
29+
30+
@Test func from_double() {
31+
#expect(JSONValue.double(1.0).coercedBoolValue == true)
32+
#expect(JSONValue.double(0.0).coercedBoolValue == false)
33+
#expect(JSONValue.double(0.5).coercedBoolValue == true)
34+
}
35+
36+
@Test func from_truthy_strings() {
37+
for s in ["true", "True", "TRUE", "t", "T", "yes", "Yes", "YES", "y", "Y", "1"] {
38+
#expect(JSONValue.string(s).coercedBoolValue == true, "Expected true for \"\(s)\"")
39+
}
40+
}
41+
42+
@Test func from_falsy_strings() {
43+
for s in ["false", "False", "FALSE", "f", "F", "no", "No", "NO", "n", "N", "0"] {
44+
#expect(JSONValue.string(s).coercedBoolValue == false, "Expected false for \"\(s)\"")
45+
}
46+
}
47+
48+
@Test func from_unrecognized_string() {
49+
#expect(JSONValue.string("maybe").coercedBoolValue == nil)
50+
#expect(JSONValue.string("2").coercedBoolValue == nil)
51+
#expect(JSONValue.string("").coercedBoolValue == nil)
52+
}
53+
54+
@Test func from_null_array_object() {
55+
#expect(JSONValue.null.coercedBoolValue == nil)
56+
#expect(JSONValue.array([]).coercedBoolValue == nil)
57+
#expect(JSONValue.object([:]).coercedBoolValue == nil)
58+
}
59+
}
60+
61+
// MARK: - coercedIntValue
62+
63+
@Suite("coercedIntValue")
64+
struct CoercedInt {
65+
@Test func from_int() {
66+
#expect(JSONValue.int(42).coercedIntValue == 42)
67+
#expect(JSONValue.int(-1).coercedIntValue == -1)
68+
#expect(JSONValue.int(0).coercedIntValue == 0)
69+
}
70+
71+
@Test func from_double_exact() {
72+
#expect(JSONValue.double(3.0).coercedIntValue == 3)
73+
#expect(JSONValue.double(-5.0).coercedIntValue == -5)
74+
}
75+
76+
@Test func from_double_fractional() {
77+
#expect(JSONValue.double(3.5).coercedIntValue == nil)
78+
#expect(JSONValue.double(0.1).coercedIntValue == nil)
79+
}
80+
81+
@Test func from_bool() {
82+
#expect(JSONValue.bool(true).coercedIntValue == 1)
83+
#expect(JSONValue.bool(false).coercedIntValue == 0)
84+
}
85+
86+
@Test func from_numeric_string() {
87+
#expect(JSONValue.string("123").coercedIntValue == 123)
88+
#expect(JSONValue.string("-7").coercedIntValue == -7)
89+
#expect(JSONValue.string("0").coercedIntValue == 0)
90+
}
91+
92+
@Test func from_float_string_exact() {
93+
#expect(JSONValue.string("3.0").coercedIntValue == 3)
94+
}
95+
96+
@Test func from_float_string_fractional() {
97+
#expect(JSONValue.string("3.5").coercedIntValue == nil)
98+
}
99+
100+
@Test func from_non_numeric_string() {
101+
#expect(JSONValue.string("abc").coercedIntValue == nil)
102+
#expect(JSONValue.string("").coercedIntValue == nil)
103+
}
104+
105+
@Test func from_null_array_object() {
106+
#expect(JSONValue.null.coercedIntValue == nil)
107+
#expect(JSONValue.array([]).coercedIntValue == nil)
108+
#expect(JSONValue.object([:]).coercedIntValue == nil)
109+
}
110+
}
111+
112+
// MARK: - coercedDoubleValue
113+
114+
@Suite("coercedDoubleValue")
115+
struct CoercedDouble {
116+
@Test func from_double() {
117+
#expect(JSONValue.double(3.14).coercedDoubleValue == 3.14)
118+
}
119+
120+
@Test func from_int() {
121+
#expect(JSONValue.int(42).coercedDoubleValue == 42.0)
122+
}
123+
124+
@Test func from_bool() {
125+
#expect(JSONValue.bool(true).coercedDoubleValue == 1.0)
126+
#expect(JSONValue.bool(false).coercedDoubleValue == 0.0)
127+
}
128+
129+
@Test func from_numeric_string() {
130+
#expect(JSONValue.string("3.14").coercedDoubleValue == 3.14)
131+
#expect(JSONValue.string("42").coercedDoubleValue == 42.0)
132+
#expect(JSONValue.string("-1.5").coercedDoubleValue == -1.5)
133+
}
134+
135+
@Test func from_non_numeric_string() {
136+
#expect(JSONValue.string("abc").coercedDoubleValue == nil)
137+
#expect(JSONValue.string("").coercedDoubleValue == nil)
138+
}
139+
140+
@Test func from_null_array_object() {
141+
#expect(JSONValue.null.coercedDoubleValue == nil)
142+
#expect(JSONValue.array([]).coercedDoubleValue == nil)
143+
#expect(JSONValue.object([:]).coercedDoubleValue == nil)
144+
}
145+
}
146+
147+
// MARK: - coercedStringValue
148+
149+
@Suite("coercedStringValue")
150+
struct CoercedString {
151+
@Test func from_string() {
152+
#expect(JSONValue.string("hello").coercedStringValue == "hello")
153+
}
154+
155+
@Test func from_bool() {
156+
#expect(JSONValue.bool(true).coercedStringValue == "true")
157+
#expect(JSONValue.bool(false).coercedStringValue == "false")
158+
}
159+
160+
@Test func from_int() {
161+
#expect(JSONValue.int(42).coercedStringValue == "42")
162+
#expect(JSONValue.int(-1).coercedStringValue == "-1")
163+
}
164+
165+
@Test func from_double() {
166+
#expect(JSONValue.double(3.14).coercedStringValue == "3.14")
167+
}
168+
169+
@Test func from_null() {
170+
#expect(JSONValue.null.coercedStringValue == "null")
171+
}
172+
173+
@Test func from_array_object() {
174+
#expect(JSONValue.array([]).coercedStringValue == nil)
175+
#expect(JSONValue.object([:]).coercedStringValue == nil)
176+
}
177+
}
178+
179+
// MARK: - Compatibility numeric accessors with coercion
180+
181+
@Suite("Compatibility numeric coercion")
182+
struct CompatibilityNumeric {
183+
@Test func int64Value_from_string() {
184+
#expect(JSONValue.string("123").int64Value == 123)
185+
#expect(JSONValue.string("3.0").int64Value == 3)
186+
#expect(JSONValue.string("abc").int64Value == nil)
187+
}
188+
189+
@Test func int64Value_from_bool() {
190+
#expect(JSONValue.bool(true).int64Value == 1)
191+
#expect(JSONValue.bool(false).int64Value == 0)
192+
}
193+
194+
@Test func int8Value_from_string() {
195+
#expect(JSONValue.string("42").int8Value == 42)
196+
#expect(JSONValue.string("200").int8Value == nil) // overflow
197+
#expect(JSONValue.string("abc").int8Value == nil)
198+
}
199+
200+
@Test func int8Value_from_bool() {
201+
#expect(JSONValue.bool(true).int8Value == 1)
202+
#expect(JSONValue.bool(false).int8Value == 0)
203+
}
204+
205+
@Test func numberValue_from_string() {
206+
#expect(JSONValue.string("3.14").numberValue == 3.14)
207+
#expect(JSONValue.string("42").numberValue == 42.0)
208+
#expect(JSONValue.string("abc").numberValue == nil)
209+
}
210+
211+
@Test func numberValue_from_bool() {
212+
#expect(JSONValue.bool(true).numberValue == 1.0)
213+
#expect(JSONValue.bool(false).numberValue == 0.0)
214+
}
215+
}
216+
}

0 commit comments

Comments
 (0)