Skip to content

Conversation

@ecito
Copy link
Contributor

@ecito ecito commented Nov 4, 2025

Description

Changes the default behavior for optional properties in the @Schemable macro to accept both missing fields and explicit null values. Previously, optional properties (Int?, String?, etc.) would accept missing fields but reject explicit null values, which didn't align with common JSON API practices where null is used interchangeably with field omission.

This PR implements the .orNull() modifier with two implementation styles and makes it the default behavior:

  • .type: Uses type array notation ["integer", "null"] for scalar primitives (clearer validation errors)
  • .union: Uses oneOf composition for complex types (objects, arrays, refs)

The macro now automatically applies .orNull() to all optional properties by default, with opt-out available for stricter validation.

Usage

Default behavior (allows null):

@Schemable
struct User {
  let name: String
  let age: Int?      // Accepts both null and omission (default)
  let email: String? // Accepts both null and omission (default)
}

Opt-out (forbid null):

@Schemable(optionalNulls: false)
struct User {
  let name: String
  let age: Int?      // Only accepts omission, rejects null
  let email: String? // Only accepts omission, rejects null
}

Per-property override:

@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)
}

When optionalNulls: true (the default), the macro automatically selects the appropriate style:

  • Scalar primitives (Int, String, Double, Bool, Float) use .type
  • Complex types (arrays, dictionaries, custom objects) use .union

Type of Change

  • New feature (adds .orNull() modifier)
  • Breaking change (changes default behavior for optional properties)
  • Documentation update

Implementation Details

Files Changed:

  1. Sources/JSONSchemaBuilder/JSONComponent/Modifier/OrNullModifier.swift (new)

    • Implements OrNullTypeComponent for type array style
    • Implements OrNullUnionComponent for oneOf composition style
    • Exports .orNull(style:) method on JSONSchemaComponent
  2. Sources/JSONSchemaBuilder/Macros/SchemaOptions/SchemaOptions.swift

    • Added .orNull(style:) option to @SchemaOptions macro
  3. Sources/JSONSchemaBuilder/Macros/Schemable.swift

    • Added optionalNulls: Bool = true parameter to @Schemable macro (default: true)
  4. Sources/JSONSchemaMacro/Schemable/SchemableMacro.swift

    • Extract and pass optionalNulls parameter to schema generators
    • Default to true when parameter is omitted
  5. Sources/JSONSchemaMacro/Schemable/SchemaGenerator.swift

    • Accept and propagate optionalNulls flag through schema generation
    • Default parameter value is true
  6. Sources/JSONSchemaMacro/Schemable/SchemableMember.swift

    • Detect .orNull() annotations in @SchemaOptions
    • Apply .orNull() when global flag is true (default behavior)
    • Automatically select .type or .union style based on type complexity
  7. Sources/JSONSchemaMacro/Schemable/SupportedPrimitive.swift

    • Added isScalar property to distinguish scalar primitives from complex types
  8. Sources/JSONSchemaBuilder/Documentation.docc/Articles/Macros.md

    • Updated documentation to reflect new default behavior
    • Added examples for opt-out with optionalNulls: false

Testing

Comprehensive test coverage including:

  • Default behavior (null acceptance for optional properties)
  • Explicit opt-out with optionalNulls: false
  • Per-property overrides with @SchemaOptions(.orNull(style:))
  • Mixed scenarios (some optional, some required)
  • Complex types (arrays, dictionaries, nested objects, custom types)
  • Integration tests with actual JSON parsing

All existing tests updated to reflect new defaults. Snapshot tests regenerated.

Design Decisions

  1. Opt-out by default (breaking change):

    • Most JSON APIs treat null and omission as equivalent for optional fields
    • Aligns with common expectations and reduces friction
    • More ergonomic: most users won't need to add any configuration
    • Opt-out available with optionalNulls: false for strict validation needs
  2. Two-style approach:

    • Uses .type array for primitives (better error messages)
    • Uses .union composition for complex types (required by JSON Schema spec)
    • Automatic selection removes cognitive overhead
  3. Boolean parameter over enum:

    • optionalNulls: true/false is more idiomatic than enum cases
    • No foreseeable third state needed
    • Simpler API surface
  4. No cascading:

    • Global flag only affects direct properties of the annotated type, not nested types
    • Each type must opt in/out independently for explicit control
    • Prevents unexpected behavior in deeply nested structures

Breaking Changes

⚠️ This is a breaking change for existing users:

Before: Optional properties rejected explicit null values by default

@Schemable struct User { let age: Int? }
// JSON: {"age": null} ❌ Rejected
// JSON: {} ✅ Accepted

After: Optional properties accept both null and omission by default

@Schemable struct User { let age: Int? }
// JSON: {"age": null} ✅ Accepted
// JSON: {} ✅ Accepted

Migration: Users who need the old strict behavior can opt-out:

@Schemable(optionalNulls: false)
struct User { let age: Int? }

Rationale for Breaking Change

After discussion in #124, the consensus is that:

  1. Industry practice: Most JSON APIs use null and omission interchangeably for optional values
  2. Developer expectations: Swift optionals naturally map to "nullable or absent" in JSON
  3. Reduced friction: The default behavior should match the most common use case
  4. Easy opt-out: Users with strict validation needs can still enforce omission-only with one parameter

The breaking change is justified by significantly improved ergonomics and alignment with ecosystem norms.

Additional Notes

Related to GitHub issue #124 about optional properties and null handling.

The optionalNulls flag is not cascading - it only applies to the properties of the type where it's declared. To enable/disable null acceptance for nested types, apply the appropriate @Schemable(optionalNulls:) parameter to each type individually.

ecito added 4 commits November 4, 2025 12:45
Implements .orNull() modifier with two styles:
- .type: Uses type array ["integer", "null"] for scalar primitives
- .union: Uses oneOf composition for complex types

Features:
- Per-property opt-in via @SchemaOptions(.orNull(style:))
- Global opt-in via @Schemable(optionalNulls: true)
- Automatic style selection (scalar primitives use .type, complex types use .union)

By default, optional properties do NOT accept null values (only omission),
maintaining minimal schemas. Users must explicitly opt in to null acceptance.
@ecito ecito marked this pull request as draft November 4, 2025 13:35
@ecito ecito marked this pull request as ready for review November 4, 2025 14:14
ecito added 3 commits November 5, 2025 12:38
Resolved merge conflicts by accepting both changes:
- Kept optionalNulls parameter from feature branch
- Integrated context parameter and diagnostic validation from main
- Updated all SchemaGenerator initializers to accept both parameters
- Updated SchemableMember.generateSchema to handle both features
BREAKING CHANGE: Optional properties now accept null by default

Previously, optional properties (Int?, String?, etc.) would reject
explicit null values and only accept field omission. This changes the
default behavior to accept both null and omission, which better aligns
with common JSON API practices.

Changes:
- Set optionalNulls parameter default to true in @Schemable macro
- Updated all tests to reflect new default behavior
- Updated documentation with new defaults and opt-out examples
- Regenerated snapshot tests

Migration:
Users who need the old strict behavior can opt-out:
@Schemable(optionalNulls: false)

Related to ajevans99#124
@ecito ecito changed the title Add opt-in null acceptance for optional properties Change default optional null handling to accept null values Nov 5, 2025
ecito added 4 commits November 5, 2025 13:04
Update test expectations to reflect that optional properties now get
.orNull() and .flatMapOptional() by default, matching the new behavior
where optionalNulls defaults to true.
@ajevans99 ajevans99 merged commit 6e33690 into ajevans99:main Nov 5, 2025
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants