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 if/then/else conditional schemas #6772

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

Support JSON Schema 2020-12 if/then/else conditional schemas #6772

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

Comments

@btiernay
Copy link

btiernay commented Mar 29, 2025

JSON Schema Conditional Validation in TypeSpec

This proposal adds support for JSON Schema conditional validation (if/then/else) to the @typespec/json-schema package.

Background

JSON Schema provides conditional validation keywords that allow the schema to adapt based on the input data:

  • if: Specifies a subschema to test against the instance
  • then: Applied when the instance matches the if subschema
  • else: Applied when the instance doesn't match the if subschema

These keywords enable more complex validation logic and are part of the JSON Schema 2020-12 standard. Currently, the @typespec/json-schema package doesn't provide explicit decorators for these keywords, but they can be added manually via the generic @extension decorator.

Proposal

Add three new decorators to the TypeSpec JSON Schema library:

/**
 * Specifies a JSON Schema conditional validation. When the given schema matches
 * the instance, the schema in the corresponding `@then` decorator is applied.
 * Otherwise, the schema in the corresponding `@else` decorator (if present) is applied.
 *
 * The schema can be provided as a model reference or as an object value using the `#{}` syntax.
 *
 * @param schema The schema to test against the instance, either a model reference or an object value
 */
extern dec if(target: Model | Scalar | Enum | Union | Reflection.ModelProperty, schema: unknown | valueof object);

/**
 * Specifies a JSON Schema that applies when the corresponding `@if` schema matches.
 * Must be used in conjunction with the `@if` decorator on the same target.
 *
 * The schema can be provided as a model reference or as an object value using the `#{}` syntax.
 *
 * @param schema The schema to apply when the condition matches, either a model reference or an object value
 */
extern dec then(target: Model | Scalar | Enum | Union | Reflection.ModelProperty, schema: unknown | valueof object);

/**
 * Specifies a JSON Schema that applies when the corresponding `@if` schema doesn't match.
 * Must be used in conjunction with the `@if` decorator on the same target.
 *
 * The schema can be provided as a model reference or as an object value using the `#{}` syntax.
 *
 * @param schema The schema to apply when the condition doesn't match, either a model reference or an object value
 */
extern dec else(target: Model | Scalar | Enum | Union | Reflection.ModelProperty, schema: unknown | valueof object);

These decorators would allow direct and intuitive specification of conditional schemas in TypeSpec.

Examples

Basic Conditional Validation

model Contact {
  // Validate email format only if type is "email"
  @if(#{ properties: #{ type: #{ const: "email" } } })
  @then(#{ format: "email" })
  value: string;
  
  type: "email" | "phone";
}

Using Both Then and Else

model VerificationCode {
  // Different pattern validation based on format
  @if(#{ properties: #{ format: #{ const: "numeric" } } })
  @then(#{ pattern: "^[0-9]{6}$" })
  @else(#{ pattern: "^[A-Za-z0-9]{8}$" })
  code: string;
  
  format: "numeric" | "alphanumeric";
}

Using Model References

// Define reusable validation patterns
model EmailPattern {
  pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$";
}

model PhonePattern {
  pattern: "^\\+[0-9]{1,3}\\s[0-9]{9,15}$"; 
}

model FormattedContact {
  // Apply appropriate pattern based on contact type
  @if(#{ properties: #{ type: #{ const: "email" } } })
  @then(EmailPattern)
  @else(PhonePattern)
  value: string;
  
  type: "email" | "phone";
}

Complex Conditions with Model Composition

// Define validation tiers
model WeakPasswordRule {
  description: "Weak password";
}

model MediumPasswordRule {
  pattern: "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[A-Za-z\\d]{8,}$";
  description: "Medium strength password";
}

model StrongPasswordRule {
  pattern: "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{12,}$";
  description: "Strong password";
}

// Create intermediate conditions
model MediumOrWeakPassword {
  @if(#{ minLength: 8 })
  @then(MediumPasswordRule)
  @else(WeakPasswordRule)
}

// Apply multiple layers of conditions
model Password {
  @if(#{ minLength: 12 })
  @then(StrongPasswordRule)
  @else(MediumOrWeakPassword)
  value: string;
}

Constraining String Formats

@if(#{ format: "uri" })
@then(#{ pattern: "^https://.*" })
model SecureUrl extends string;

Technical Implementation

Library State Keys

Add new state keys in lib.ts:

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

Decorator Implementation

Add getters/setters in decorators.ts:

export const [
  /** Get schema set by `@if` decorator */
  getIf,
  setIf,
  /** {@inheritdoc IfDecorator} */
  $if,
] = createDataDecorator<IfDecorator, Type | object>(JsonSchemaStateKeys["JsonSchema.if"]);

export const [
  /** Get schema set by `@then` decorator */
  getThen,
  setThen,
  /** {@inheritdoc ThenDecorator} */
  $then,
] = createDataDecorator<ThenDecorator, Type | object>(JsonSchemaStateKeys["JsonSchema.then"]);

export const [
  /** Get schema set by `@else` decorator */
  getElse,
  setElse,
  /** {@inheritdoc ElseDecorator} */
  $else,
] = createDataDecorator<ElseDecorator, Type | object>(JsonSchemaStateKeys["JsonSchema.else"]);

Schema Generation

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

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

  // For handling conditional schemas
  const applyConditionalConstraint = (
    fn: (p: Program, t: Type) => Type | object | undefined,
    key: string
  ) => {
    const constraint = fn(this.emitter.getProgram(), type);
    if (constraint === undefined) return;

    if (isType(constraint)) {
      // It's a TypeSpec model reference
      const ref = this.emitter.emitTypeReference(constraint);
      compilerAssert(ref.kind === "code", "Unexpected non-code result from emit reference");
      schema.set(key, ref.value);
    } else {
      // It's an object value provided with #{} syntax
      schema.set(key, constraint);
    }
  };

  applyConditionalConstraint(getIf, "if");
  applyConditionalConstraint(getThen, "then");
  applyConditionalConstraint(getElse, "else");
  
  // ... remainder of existing code ...
}

Decorator Validation

Add diagnostics code in lib.ts:

export const $lib = createTypeSpecLibrary({
  name: "@typespec/json-schema",
  diagnostics: {
    // ... existing diagnostics ...
    "then-without-if": {
      severity: "warning",
      messages: {
        default: paramMessage`@then decorator used without corresponding @if decorator on the same target`,
      },
    },
    "else-without-if": {
      severity: "warning",
      messages: {
        default: paramMessage`@else decorator used without corresponding @if decorator on the same target`,
      },
    },
  },
  // ... remainder of existing code ...
} as const);

Implement validation in the decorator functions:

export const $then: ThenDecorator = (context: DecoratorContext, target: Type, schema: unknown) => {
  if (!getIf(context.program, target)) {
    reportDiagnostic(context.program, {
      code: "then-without-if",
      target: context.decoratorTarget,
    });
  }
  setThen(context.program, target, schema);
};

export const $else: ElseDecorator = (context: DecoratorContext, target: Type, schema: unknown) => {
  if (!getIf(context.program, target)) {
    reportDiagnostic(context.program, {
      code: "else-without-if",
      target: context.decoratorTarget,
    });
  }
  setElse(context.program, target, schema);
};

Alternative Approaches Considered

1. Single Conditional Decorator

Instead of three separate decorators, a single decorator could be used:

extern dec conditional(
  target: Model | Scalar | Enum | Union | Reflection.ModelProperty, 
  ifSchema: unknown | valueof object,
  thenSchema?: unknown | valueof object,
  elseSchema?: unknown | valueof object
);

Pros:

  • Ensures the three parts are always defined together
  • Prevents misuse where @then or @else are used without @if
  • May be more intuitive for simple cases

Cons:

  • Less flexible for complex scenarios
  • Doesn't match the JSON Schema keyword structure as directly
  • Requires all schemas to be defined at once

2. Using Existing Extension Decorator

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

@extension("if", #{ properties: #{ type: #{ const: "email" } } })
@extension("then", #{ format: "email" })

Pros:

  • No new decorators needed
  • Already supported

Cons:

  • Less discoverable
  • Less specific type checking
  • Doesn't communicate intent as clearly

Technical Considerations

1. State Data Types

The createDataDecorator function may need modification to properly handle both Type and object values. This implementation will need to ensure that both model references and object values are properly stored and retrieved.

2. Proper JSON Schema Generation

The implementation should ensure that JSON Schema conditionals are properly generated according to the JSON Schema specification. The 2020-12 version of JSON Schema is already used by the emitter.

3. Model Reference Resolution

When model references are used as schema values, they need to be properly resolved to JSON Schema. The existing type reference resolution logic should handle this correctly.

4. Nested Conditionals

While decorators can't be nested, the proposal supports complex conditions through model composition. This is a natural extension of TypeSpec's modeling capabilities.

Benefits

  1. Direct Access to JSON Schema Conditionals: Provides explicit decorators for a powerful JSON Schema feature.

  2. Improved Developer Experience: More intuitive and discoverable than using generic @extension.

  3. Flexibility: Supports both simple inline conditions and complex scenarios through model composition.

  4. Type Safety: Leverages TypeSpec's type system for schema validation.

  5. Consistency: Follows the pattern of other JSON Schema feature decorators.

Limitations

  1. No Direct Decorator Nesting: Complex conditions require model composition, which is more verbose than direct nesting.

  2. Learning Curve: Developers need to understand how to compose models for complex conditional scenarios.

  3. Decorator Co-dependency: @then and @else only make sense when used with @if, which creates a usage dependency.

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