Skip to content
43 changes: 43 additions & 0 deletions Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md
Original file line number Diff line number Diff line change
Expand Up @@ -510,3 +510,46 @@ struct Customer {
}
}
```

#### Optional properties and null values

By default, optional properties (`Int?`, `String?`, etc.) accept both **missing** fields and **explicit `null` values**. To forbid `null` values (allowing only omission), use `optionalNulls: false`:

**Default behavior:**
```swift
@Schemable // Default: allows null for optional properties
struct User {
let name: String
let age: Int? // Accepts null (default behavior)
let email: String? // Accepts null (default behavior)
}
```

**Opt-out (forbid null):**
```swift
@Schemable(optionalNulls: false)
struct User {
let name: String
let age: Int? // Does NOT accept null (omission only)
let email: String? // Does NOT accept null (omission only)
}
```

**Per-property override:**
```swift
@Schemable(optionalNulls: false)
struct User {
let name: String

@SchemaOptions(.orNull(style: .type))
let age: Int? // Accepts null (overrides global setting)

let email: String? // Does NOT accept null (follows global setting)
}
```

The `.orNull()` modifier supports two styles:
- `.type`: Uses type array `["integer", "null"]` - best for scalar primitives, produces clearer validation errors
- `.union`: Uses oneOf composition - required for complex types (objects, arrays)

When `optionalNulls: true` (the default), the appropriate style is automatically selected based on the property type.
119 changes: 119 additions & 0 deletions Sources/JSONSchemaBuilder/JSONComponent/Modifier/OrNullModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import JSONSchema

/// Style for handling null values in schemas
public enum OrNullStyle {
/// Uses type array: {"type": ["integer", "null"]}
/// Best for scalar primitives - produces clearer validation errors
case type

/// Uses oneOf composition: {"oneOf": [{"type": "integer"}, {"type": "null"}]}
/// Required for complex types (objects, arrays, refs)
case union
}

extension JSONSchemaComponent {
/// Makes this component accept null values in addition to the component's type.
/// Returns nil when null is encountered.
///
/// - Parameter style: The style to use for null acceptance
/// - `.type`: Uses type array `["integer", "null"]` - best for primitives
/// - `.union`: Uses oneOf composition - required for complex types
///
/// - Returns: A component that accepts either the original type or null, returning an optional value
///
/// Example:
/// ```swift
/// JSONInteger()
/// .orNull(style: .type) // Accepts integers or null, returns Int?
/// ```
public func orNull(style: OrNullStyle) -> JSONComponents.AnySchemaComponent<Output?> {
switch style {
case .type:
return OrNullTypeComponent<Output, Self>(wrapped: self).eraseToAnySchemaComponent()
case .union:
return OrNullUnionComponent<Output, Self>(wrapped: self).eraseToAnySchemaComponent()
}
}
}

/// Implementation using type array
private struct OrNullTypeComponent<WrappedValue, Wrapped: JSONSchemaComponent>: JSONSchemaComponent
where Wrapped.Output == WrappedValue {
typealias Output = WrappedValue?

var wrapped: Wrapped

public var schemaValue: SchemaValue {
get {
var schema = wrapped.schemaValue

// If there's already a type keyword, convert it to an array with null
if case .object(var obj) = schema,
let typeValue = obj[Keywords.TypeKeyword.name]
{

// Convert single type to array with null
switch typeValue {
case .string(let typeStr):
obj[Keywords.TypeKeyword.name] = .array([
.string(typeStr), .string(JSONType.null.rawValue),
])
case .array(var types):
// Add null if not already present
let nullValue = JSONValue.string(JSONType.null.rawValue)
if !types.contains(nullValue) {
types.append(nullValue)
}
obj[Keywords.TypeKeyword.name] = .array(types)
default:
break
}

schema = .object(obj)
}

return schema
}
set {
// Not implemented - this modifier doesn't support schema value mutation
}
}

public func parse(_ value: JSONValue) -> Parsed<WrappedValue?, ParseIssue> {
// Accept null - return nil for the optional type
if case .null = value {
return .valid(nil)
}
return wrapped.parse(value).map(Optional.some)
}
}

/// Implementation using oneOf composition
private struct OrNullUnionComponent<WrappedValue, Wrapped: JSONSchemaComponent>: JSONSchemaComponent
where Wrapped.Output == WrappedValue {
typealias Output = WrappedValue?

var wrapped: Wrapped

public var schemaValue: SchemaValue {
get {
.object([
Keywords.OneOf.name: .array([
wrapped.schemaValue.value,
JSONNull().schemaValue.value,
])
])
}
set {
// Not implemented - this modifier doesn't support schema value mutation
}
}

public func parse(_ value: JSONValue) -> Parsed<WrappedValue?, ParseIssue> {
// Accept null - return nil for the optional type
if case .null = value {
return .valid(nil)
}
return wrapped.parse(value).map(Optional.some)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ extension JSONPropertyComponent {
/// This effectively transforms the validation output to be non-optional and therefore marks the property as required.
/// - Parameter transform: The transform to apply to the output.
/// - Returns: A new component that applies the transform.
func compactMap<NewOutput>(
public func compactMap<NewOutput>(
_ transform: @Sendable @escaping (Output) -> NewOutput?
) -> JSONPropertyComponents.CompactMap<Self, NewOutput> {
.init(upstream: self, transform: transform)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import JSONSchema

extension JSONPropertyComponent {
/// Flattens a double-optional output to a single optional.
/// This is specifically useful for optional properties that use `.orNull()`,
/// which creates a double-optional (T??) that needs to be flattened to T?.
/// - Returns: A new component that flattens the double-optional output.
public func flatMapOptional<Wrapped>()
-> JSONPropertyComponents.FlatMapOptional<Self, Wrapped>
where Output == Wrapped?? {
.init(upstream: self)
}
}

extension JSONPropertyComponents {
public struct FlatMapOptional<Upstream: JSONPropertyComponent, Wrapped>: JSONPropertyComponent
where Upstream.Output == Wrapped?? {
let upstream: Upstream

public var key: String { upstream.key }

public var isRequired: Bool { upstream.isRequired }

public var value: Upstream.Value { upstream.value }

public func parse(_ input: [String: JSONValue]) -> Parsed<Wrapped?, ParseIssue> {
switch upstream.parse(input) {
case .valid(let output):
// Flatten T?? to T?
// If output is nil (property missing), return nil
// If output is .some(nil) (property present but null), return nil
// If output is .some(.some(value)), return value
return .valid(output.flatMap { $0 })
case .invalid(let error): return .invalid(error)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,8 @@ extension SchemaTrait where Self == SchemaOptionsTrait {
public static func customSchema<S: Schemable>(_ conversion: S.Type) -> SchemaOptionsTrait {
fatalError(SchemaOptionsTrait.errorMessage)
}

public static func orNull(style: OrNullStyle) -> SchemaOptionsTrait {
fatalError(SchemaOptionsTrait.errorMessage)
}
}
3 changes: 2 additions & 1 deletion Sources/JSONSchemaBuilder/Macros/Schemable.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
@attached(extension, conformances: Schemable)
@attached(member, names: named(schema), named(keyEncodingStrategy))
public macro Schemable(
keyStrategy: KeyEncodingStrategies? = nil
keyStrategy: KeyEncodingStrategies? = nil,
optionalNulls: Bool = true
) = #externalMacro(module: "JSONSchemaMacro", type: "SchemableMacro")

public protocol Schemable {
Expand Down
6 changes: 6 additions & 0 deletions Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,13 @@ struct SchemaGenerator {
let members: MemberBlockItemListSyntax
let attributes: AttributeListSyntax
let keyStrategy: ExprSyntax?
let optionalNulls: Bool
let context: (any MacroExpansionContext)?

init(
fromClass classDecl: ClassDeclSyntax,
keyStrategy: ExprSyntax?,
optionalNulls: Bool = true,
accessLevel: String? = nil,
context: (any MacroExpansionContext)? = nil
) {
Expand All @@ -126,12 +128,14 @@ struct SchemaGenerator {
members = classDecl.memberBlock.members
attributes = classDecl.attributes
self.keyStrategy = keyStrategy
self.optionalNulls = optionalNulls
self.context = context
}

init(
fromStruct structDecl: StructDeclSyntax,
keyStrategy: ExprSyntax?,
optionalNulls: Bool = true,
accessLevel: String? = nil,
context: (any MacroExpansionContext)? = nil
) {
Expand All @@ -149,6 +153,7 @@ struct SchemaGenerator {
members = structDecl.memberBlock.members
attributes = structDecl.attributes
self.keyStrategy = keyStrategy
self.optionalNulls = optionalNulls
self.context = context
}

Expand All @@ -175,6 +180,7 @@ struct SchemaGenerator {
$0.generateSchema(
keyStrategy: keyStrategy,
typeName: name.text,
globalOptionalNulls: optionalNulls,
codingKeys: codingKeys,
context: context
)
Expand Down
28 changes: 22 additions & 6 deletions Sources/JSONSchemaMacro/Schemable/SchemableMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,21 @@ public struct SchemableMacro: MemberMacro, ExtensionMacro {
let accessModifier = accessLevel.map { "\($0) " } ?? ""

if let structDecl = declaration.as(StructDeclSyntax.self) {
let strategyArg = node.arguments?
.as(LabeledExprListSyntax.self)?
.first(where: { $0.label?.text == "keyStrategy" })?
let arguments = node.arguments?.as(LabeledExprListSyntax.self)
let strategyArg = arguments?.first(where: { $0.label?.text == "keyStrategy" })?.expression
let optionalNullsArg = arguments?.first(where: { $0.label?.text == "optionalNulls" })?
.expression
// Default to true if not specified, otherwise parse the boolean literal
let optionalNulls: Bool
if let boolLiteral = optionalNullsArg?.as(BooleanLiteralExprSyntax.self) {
optionalNulls = boolLiteral.literal.text == "true"
} else {
optionalNulls = true // default
}
let generator = SchemaGenerator(
fromStruct: structDecl,
keyStrategy: strategyArg,
optionalNulls: optionalNulls,
accessLevel: accessLevel,
context: context
)
Expand All @@ -112,13 +120,21 @@ public struct SchemableMacro: MemberMacro, ExtensionMacro {
}
return decls
} else if let classDecl = declaration.as(ClassDeclSyntax.self) {
let strategyArg = node.arguments?
.as(LabeledExprListSyntax.self)?
.first(where: { $0.label?.text == "keyStrategy" })?
let arguments = node.arguments?.as(LabeledExprListSyntax.self)
let strategyArg = arguments?.first(where: { $0.label?.text == "keyStrategy" })?.expression
let optionalNullsArg = arguments?.first(where: { $0.label?.text == "optionalNulls" })?
.expression
// Default to true if not specified, otherwise parse the boolean literal
let optionalNulls: Bool
if let boolLiteral = optionalNullsArg?.as(BooleanLiteralExprSyntax.self) {
optionalNulls = boolLiteral.literal.text == "true"
} else {
optionalNulls = true // default
}
let generator = SchemaGenerator(
fromClass: classDecl,
keyStrategy: strategyArg,
optionalNulls: optionalNulls,
accessLevel: accessLevel,
context: context
)
Expand Down
Loading