Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support JSON Schema 2020-12 dependentSchemas conditional schemas #6777

Open
3 tasks done
btiernay opened this issue Mar 31, 2025 · 0 comments
Open
3 tasks done

Support JSON Schema 2020-12 dependentSchemas conditional schemas #6777

btiernay opened this issue Mar 31, 2025 · 0 comments
Assignees

Comments

@btiernay
Copy link

btiernay commented Mar 31, 2025

JSON Schema Dependent Schemas in TypeSpec

This proposal adds support for JSON Schema dependent schemas validation (dependentSchemas) to the @typespec/json-schema package.

Background and Motivation

JSON Schema provides the dependentSchemas keyword that applies additional schema validation when certain properties exist in an object. Unlike dependentRequired which only checks for the presence of other properties, dependentSchemas allows for complex validation logic to be conditionally applied when specific properties are present.

The Problem Dependent Schemas Solve

In real-world APIs and data models, validation requirements often vary contextually based on the presence or value of certain fields. Consider these common scenarios:

  1. A payment form where different payment methods require different validation rules (card numbers need format checking, bank transfers need routing numbers)
  2. A user profile where certain account types require additional fields with specific validation rules
  3. Configuration objects where enabling a feature requires proper configuration of related options

Traditional validation approaches have significant limitations:

  • Fixed schema validation can't adapt to contextual requirements
  • Server-side custom validation often duplicates business rules across implementations
  • Clients lack clear guidance on conditional requirements until after submission

The dependentSchemas keyword addresses these challenges by allowing schemas to adapt based on the presence of specific properties, ensuring that validation rules match the actual data structure. This enables sophisticated validation patterns that can:

  1. Apply context-specific validation - Different rules for different scenarios
  2. Validate related fields together - Ensure coherent groups of fields
  3. Implement complex business rules - Encode business logic directly in the schema
  4. Provide better client guidance - Document conditional requirements explicitly

Currently, the @typespec/json-schema package doesn't provide an explicit decorator for this powerful validation capability, but it can be manually added via the generic @extension decorator, which is less intuitive and type-safe.

Proposal

Add a new decorator to the TypeSpec JSON Schema library:

/**
 * Specifies that if certain properties exist in an object, additional schema validation should be applied.
 * 
 * Maps directly to the JSON Schema `dependentSchemas` keyword, which applies additional
 * validation rules when specific properties are present in the instance data.
 * Unlike `dependentRequired`, which only checks for presence of other properties,
 * this decorator allows complex validation logic to be applied conditionally.
 * 
 * @param schemas An object whose keys are property names and values are schema objects 
 *                that are applied when the corresponding property is present
 * @example
 * model Payment {
 *   method?: "credit" | "bank";
 *   credit_card?: string;
 *   
 *   @dependentSchemas(#{
 *     "method": #{ 
 *       oneOf: [
 *         { 
 *           properties: { 
 *             method: { const: "credit" },
 *             credit_card: { pattern: "^[0-9]{16}$" }
 *           }, 
 *           required: ["credit_card"] 
 *         },
 *         // Other payment method schemas...
 *       ]
 *     }
 *   })
 * }
 */
extern dec dependentSchemas(target: Model | Reflection.ModelProperty, schemas: unknown | valueof object);

This decorator would enable powerful conditional validation based on property presence while providing better discoverability, type safety, and integration with TypeSpec's type system.

Examples

Basic Dependent Schema Validation

model PaymentForm {
  name: string; // Required customer name
  payment_method?: "credit_card" | "bank_transfer" | "paypal"; // The selected payment method
  
  // Method-specific fields, all optional at the TypeSpec level
  credit_card_number?: string;
  bank_account_number?: string;
  paypal_email?: string;
  
  // When payment_method is present, apply specific validation rules
  // based on which method was selected
  @dependentSchemas(#{ 
    "payment_method": #{ 
      oneOf: [
        // Credit card validation rules
        { 
          properties: { 
            payment_method: { const: "credit_card" }, 
            credit_card_number: { pattern: "^[0-9]{16}$" } 
          }, 
          required: ["credit_card_number"] 
        },
        
        // Bank transfer validation rules
        { 
          properties: { 
            payment_method: { const: "bank_transfer" }, 
            bank_account_number: { minLength: 10 } 
          }, 
          required: ["bank_account_number"] 
        },
        
        // PayPal validation rules
        { 
          properties: { 
            payment_method: { const: "paypal" }, 
            paypal_email: { format: "email" } 
          }, 
          required: ["paypal_email"] 
        }
      ]
    }
  })
}

This example demonstrates how dependentSchemas allows for both requiring certain fields and applying additional validation constraints when a specific payment method is selected. The schema dynamically adapts based on which payment method the user chooses:

  • If payment_method is "credit_card", then credit_card_number is required and must match a 16-digit pattern
  • If payment_method is "bank_transfer", then bank_account_number is required and must be at least 10 characters
  • If payment_method is "paypal", then paypal_email is required and must be a valid email format

This provides a clear advantage over dependentRequired by combining field requirements with format validation in a single declaration.

Using Model References for Schema Reuse

// Reusable validation model for credit card payments
model CreditCardValidation {
  properties: {
    credit_card_number: { 
      pattern: "^[0-9]{16}$",  // Must be exactly 16 digits
      description: "16-digit credit card number without spaces"
    }
  };
  required: ["credit_card_number"];
}

// Reusable validation model for bank transfers
model BankTransferValidation {
  properties: {
    bank_account_number: {
      minLength: 10,
      maxLength: 34,  // International account number standard
      pattern: "^[A-Z0-9]+$"  // Alphanumeric characters only
    }
  };
  required: ["bank_account_number"];
}

// Main payment form model that reuses the validation models
model PaymentForm {
  payment_method?: "credit_card" | "bank_transfer";
  credit_card_number?: string;
  bank_account_number?: string;
  
  // Apply different validation schemas based on payment_method
  // by referencing reusable validation models
  @dependentSchemas(#{ 
    "payment_method": #{ 
      oneOf: [
        // For credit card payments, include the credit card validation schema
        { 
          properties: { payment_method: { const: "credit_card" } }, 
          allOf: [CreditCardValidation]  // Reference to validation model
        },
        // For bank transfers, include the bank validation schema
        { 
          properties: { payment_method: { const: "bank_transfer" } }, 
          allOf: [BankTransferValidation]  // Reference to validation model
        }
      ]
    }
  })
  
  // Teaching point: By using model references, we can maintain complex
  // validation rules separately and reuse them across multiple models
}

This example showcases a powerful pattern for organizing complex validation rules: separating validation logic into reusable TypeSpec models and then referencing them within the dependent schemas. This approach:

  1. Improves maintainability by isolating validation rules
  2. Enables reuse of validation patterns across multiple models
  3. Makes the schema more readable by abstracting complex validation details

The model references will be resolved to their JSON Schema representation during emission, creating a complete schema structure.

Complex Business Rule Validation

model TaxForm {
  // Base tax form fields
  income_type: "employment" | "self_employment" | "investment";
  employment_income?: number;
  business_income?: number;
  business_expenses?: number;
  investment_income?: number;
  tax_rate?: number;
  
  // Apply complex nested conditional validation rules based on income type
  @dependentSchemas(#{ 
    "income_type": #{ 
      // First check if income type is "employment"
      if: { properties: { income_type: { const: "employment" } } },
      then: { 
        // For employment income, these rules apply
        required: ["employment_income"],
        properties: {
          employment_income: { minimum: 0 },  // Must be non-negative
          tax_rate: { enum: [0.15, 0.25, 0.35] }  // Limited tax rate options
        }
      },
      else: { 
        // If not employment, check if it's "self_employment"
        if: { properties: { income_type: { const: "self_employment" } } },
        then: { 
          // For self-employment, these rules apply
          required: ["business_income"],  // Business income is required
          properties: {
            business_income: { minimum: 0 },  // Must be non-negative
            business_expenses: { minimum: 0 },  // If present, must be non-negative
            tax_rate: { enum: [0.15, 0.25, 0.35, 0.45] }  // More tax rate options
          }
        },
        else: {
          // For investment income (the only remaining option), these rules apply
          required: ["investment_income"],  // Investment income is required
          properties: {
            investment_income: { minimum: 0 },  // Must be non-negative
            tax_rate: { enum: [0.15, 0.20] }  // Limited tax rate options for investments
          }
        }
      }
    }
  })
  
  // Teaching point: Nested if/then/else structures within dependentSchemas
  // allow for complex decision trees in validation logic
}

This sophisticated example demonstrates how dependentSchemas can implement complex business rules using nested conditional logic:

  1. The validation rules adapt based on the income_type property
  2. For each income type, different fields are required and have different constraints
  3. Tax rates are restricted to specific values based on income type
  4. The nested if/then/else pattern creates a decision tree for validation

This level of validation would traditionally require extensive application code, but with dependentSchemas, it's expressed declaratively in the schema itself, ensuring consistent validation across implementations.

Technical Implementation

Library State Keys

Add a new state key in lib.ts:

export const $lib = createTypeSpecLibrary({
  // ... existing code ...
  state: {
    // ... existing state ...
    "JsonSchema.dependentSchemas": { 
      description: "Contains data configured with @dependentSchemas decorator" 
    },
  },
} as const);

Decorator Implementation

Add getter/setter in decorators.ts:

export const [
  /** Get schemas set by `@dependentSchemas` decorator */
  getDependentSchemas,
  setDependentSchemas,
  /** {@inheritdoc DependentSchemasDecorator} */
  $dependentSchemas,
] = createDataDecorator<DependentSchemasDecorator, Type | object>(
  JsonSchemaStateKeys["JsonSchema.dependentSchemas"]
);

Schema Generation

Update #applyConstraints in json-schema-emitter.ts:

#applyConstraints(
  type: Scalar | Model | ModelProperty | Union | UnionVariant | Enum,
  schema: ObjectBuilder<unknown>,
) {
  // ... existing code ...

  // For handling dependent schemas
  const dependentSchemas = getDependentSchemas(this.emitter.getProgram(), type);
  if (dependentSchemas !== undefined) {
    if (isType(dependentSchemas)) {
      // It's a TypeSpec model reference
      const ref = this.emitter.emitTypeReference(dependentSchemas);
      compilerAssert(ref.kind === "code", "Unexpected non-code result from emit reference");
      schema.set("dependentSchemas", ref.value);
    } else {
      // It's an object value provided with #{} syntax
      schema.set("dependentSchemas", dependentSchemas);
    }
  }
  
  // ... remainder of existing code ...
}

Runtime Behavior and Semantics

  • The dependentSchemas decorator emits the JSON Schema dependentSchemas keyword as defined in JSON Schema 2020-12.
  • Schema validation occurs at runtime, not at TypeSpec compile time.
  • The property names in the dependentSchemas object refer to properties in the model.
  • When a referenced property exists in the instance data, its associated schema is applied for validation.
  • Multiple @dependentSchemas decorators on the same target will be merged by the TypeSpec compiler.
  • Model references within schemas are resolved to their JSON Schema representation during emission.
  • Complex schema structures including logical operators (allOf, anyOf, oneOf, not) and conditional keywords (if, then, else) are supported within dependent schemas.

Alternative Approaches Considered

1. Enhanced dependentRequired Decorator

Instead of creating a separate dependentSchemas decorator, extend the dependentRequired decorator to handle both simple property presence and complex schema validation:

extern dec propertyDependencies(
  target: Model | Reflection.ModelProperty,
  dependencies: unknown | valueof object
);

Pros:

  • Single decorator for handling all property dependencies
  • Might be simpler for users to remember

Cons:

  • Mixes two different JSON Schema keywords with different semantics
  • Could lead to confusion in implementation and usage
  • Doesn't align with JSON Schema's structure

2. Using Existing Extension Decorator

The existing @extension decorator could handle this without a new decorator:

@extension("dependentSchemas", #{ "payment_method": { ... } })

Pros:

  • No new decorators needed
  • Already supported

Cons:

  • Less discoverable
  • No specific type checking for the complex structure
  • Doesn't communicate intent as clearly
  • Makes code harder to maintain and understand

Technical Considerations

1. Schema Reference Handling

The implementation must correctly handle both inline schema objects and references to TypeSpec model types used in dependent schemas. This requires proper resolution of model references to their JSON Schema representation.

2. Complex Schema Structures

dependentSchemas can contain complex schema structures including nested conditionals (if/then/else), logical operators (allOf, anyOf, oneOf, not), and property constraints. The implementation should ensure all these structures are properly serialized.

3. Integration with Other Validation Keywords

The implementation should ensure that dependentSchemas works correctly alongside other validation keywords like @minProperties, @maxProperties, and potentially @dependentRequired.

4. JSON Schema Version Compatibility

The implementation should generate dependentSchemas compatible with JSON Schema 2020-12, which is the version currently used by the emitter.

Optional Enhancements

  • Schema Builder API: A future version could provide a more TypeScript-friendly API for building complex schemas without using raw objects.
  • Validation Helpers: Helper functions for common validation patterns could make complex schemas more concise and less error-prone.
  • Visual Schema Editor: A UI tool could help visualize complex dependent schema relationships for documentation purposes.

Real-World Use Cases

1. Sophisticated User Registration Forms

// User registration form with role-specific validation
model UserRegistration {
  // Common user information
  username: string;
  email: string;
  password: string;
  
  // Role selection
  role: "customer" | "vendor" | "admin";
  
  // Role-specific fields
  shipping_address?: Address; // For customers
  company_name?: string;      // For vendors
  department?: string;        // For vendors and admins
  admin_code?: string;        // For admins
  
  // Apply different validation rules based on the selected role
  @dependentSchemas(#{
    "role": #{
      oneOf: [
        // Customer validation
        {
          properties: {
            role: { const: "customer" },
            shipping_address: { required: ["street", "city", "zip"] }
          },
          required: ["shipping_address"]
        },
        
        // Vendor validation
        {
          properties: {
            role: { const: "vendor" },
            company_name: { minLength: 2 },
            department: { enum: ["sales", "support", "development"] }
          },
          required: ["company_name", "department"]
        },
        
        // Admin validation
        {
          properties: {
            role: { const: "admin" },
            admin_code: { pattern: "^ADM-[0-9]{6}$" },
            department: { enum: ["it", "hr", "finance"] }
          },
          required: ["admin_code", "department"]
        }
      ]
    }
  })
  
  // Teaching point: This pattern allows a single form model
  // to adapt its validation based on user selections
}

This example shows how a user registration form can dynamically adapt validation requirements based on the selected role, ensuring that each user type provides the necessary information in the correct format.

2. Configuration Systems with Feature Flags

// System configuration with conditional feature configuration
model SystemConfig {
  // Feature flags
  enable_logging?: boolean;
  enable_metrics?: boolean;
  enable_authentication?: boolean;
  
  // Feature-specific configuration
  log_level?: "debug" | "info" | "warning" | "error";
  log_format?: "json" | "text";
  log_destination?: "file" | "stdout" | "remote";
  log_file_path?: string;
  log_remote_url?: string;
  
  metrics_interval?: number;
  metrics_destination?: string;
  
  auth_provider?: "oauth" | "ldap" | "local";
  oauth_settings?: OAuthSettings;
  ldap_settings?: LdapSettings;
  local_auth_settings?: LocalAuthSettings;
  
  // Apply validation rules based on which features are enabled
  @dependentSchemas(#{
    // Logging configuration validation
    "enable_logging": #{
      properties: {
        log_level: { type: "string" },
        log_format: { type: "string" },
        log_destination: { type: "string" }
      },
      required: ["log_level", "log_format", "log_destination"],
      if: {
        properties: { log_destination: { const: "file" } }
      },
      then: {
        required: ["log_file_path"]
      },
      else: {
        if: {
          properties: { log_destination: { const: "remote" } }
        },
        then: {
          required: ["log_remote_url"],
          properties: {
            log_remote_url: { format: "uri" }
          }
        }
      }
    },
    
    // Metrics configuration validation
    "enable_metrics": #{
      properties: {
        metrics_interval: { type: "number", minimum: 1, maximum: 3600 },
        metrics_destination: { type: "string", format: "uri" }
      },
      required: ["metrics_interval", "metrics_destination"]
    },
    
    // Authentication configuration validation
    "enable_authentication": #{
      properties: {
        auth_provider: { type: "string" }
      },
      required: ["auth_provider"],
      if: {
        properties: { auth_provider: { const: "oauth" } }
      },
      then: {
        required: ["oauth_settings"]
      },
      else: {
        if: {
          properties: { auth_provider: { const: "ldap" } }
        },
        then: {
          required: ["ldap_settings"]
        },
        else: {
          required: ["local_auth_settings"]
        }
      }
    }
  })
  
  // Teaching point: This pattern is excellent for configuration objects
  // where enabling a feature requires proper configuration of related options
}

This example demonstrates how dependentSchemas can be used to validate configuration systems with feature flags, ensuring that when a feature is enabled, all required configuration options for that feature are provided and properly formatted.

3. Multi-step Form Validation

// Multi-step order process with validation at each step
model OrderProcess {
  // Step tracking
  current_step: "product_selection" | "shipping" | "payment" | "confirmation";
  completed_steps: string[];
  
  // Product selection step
  product_ids?: string[];
  quantities?: number[];
  
  // Shipping step
  shipping_address?: Address;
  shipping_method?: "standard" | "express" | "overnight";
  
  // Payment step
  payment_method?: "credit_card" | "paypal" | "bank_transfer";
  credit_card?: CreditCardInfo;
  paypal_email?: string;
  bank_account?: BankAccountInfo;
  
  // Apply different validation rules based on the current step and completed steps
  @dependentSchemas(#{
    "current_step": #{
      // Product selection validation
      if: { properties: { current_step: { const: "product_selection" } } },
      then: {
        required: ["product_ids", "quantities"],
        properties: {
          product_ids: { type: "array", minItems: 1 },
          quantities: { type: "array", minItems: 1, items: { type: "integer", minimum: 1 } }
        }
      },
      else: {
        // Shipping step validation
        if: { properties: { current_step: { const: "shipping" } } },
        then: {
          required: ["product_ids", "quantities", "shipping_address", "shipping_method"],
          properties: {
            shipping_address: { required: ["street", "city", "country", "postal_code"] },
            shipping_method: { type: "string" }
          }
        },
        else: {
          // Payment step validation
          if: { properties: { current_step: { const: "payment" } } },
          then: {
            required: ["product_ids", "quantities", "shipping_address", "shipping_method", "payment_method"],
            oneOf: [
              { 
                properties: { 
                  payment_method: { const: "credit_card" } 
                },
                required: ["credit_card"] 
              },
              { 
                properties: { 
                  payment_method: { const: "paypal" } 
                },
                required: ["paypal_email"] 
              },
              { 
                properties: { 
                  payment_method: { const: "bank_transfer" } 
                },
                required: ["bank_account"] 
              }
            ]
          },
          else: {
            // Confirmation step - everything must be complete
            required: ["product_ids", "quantities", "shipping_address", "shipping_method", "payment_method", "completed_steps"],
            properties: {
              completed_steps: { 
                contains: { const: "product_selection" },
                contains: { const: "shipping" },
                contains: { const: "payment" }
              }
            }
          }
        }
      }
    }
  })
  
  // Teaching point: This pattern enables stateful validation that adapts
  // as the user progresses through a multi-step process
}

This example shows how dependentSchemas can handle stateful validation for multi-step processes, ensuring that all required information for each step is provided and that steps are completed in the proper sequence.

Benefits

  1. Advanced Validation Capabilities: Enables sophisticated validation rules that adapt to the presence of specific properties, supporting complex business requirements.

  2. Better API Design: Helps API designers create more intuitive and robust data models by expressing conditional validation rules explicitly.

  3. Schema Reuse: Allows referencing TypeSpec models for schema fragments, promoting code reuse and maintainability.

  4. Improved Developer Experience: Makes conditional schema validation more discoverable and type-safe compared to using the generic @extension decorator.

  5. Business Logic in Schema: Moves some business validation rules from application code to the schema, ensuring consistent validation across multiple platforms and implementations.

  6. Clearer API Documentation: Generated OpenAPI or JSON Schema documents include these conditional validation rules, making API behavior more predictable for consumers.

  7. Reduced Backend Validation Code: Minimizes duplicate validation logic in client and server implementations.

Limitations

  1. Complexity: The schema structures can become complex and harder to understand for advanced use cases.

  2. Validation Cascade: When used with other validation keywords, it may not be immediately obvious in which order validations are applied.

  3. Schema Size: Complex dependent schemas can significantly increase the size of the generated JSON Schema.

  4. Learning Curve: Developers need to understand JSON Schema conditional validation concepts to use this feature effectively.

  5. Runtime Only: Validation only happens at runtime, not during TypeSpec compilation.

  6. Limited IDE Support: Complex schema structures might not be fully validated by development tools.

Checklist

  • Follow our Code of Conduct
  • Read the docs.
  • Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants