-
Notifications
You must be signed in to change notification settings - Fork 350
Support JSON Schema 2020-12 if/then/else conditional schemas #6772
Description
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 instancethen: Applied when the instance matches theifsubschemaelse: Applied when the instance doesn't match theifsubschema
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
@thenor@elseare 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
-
Direct Access to JSON Schema Conditionals: Provides explicit decorators for a powerful JSON Schema feature.
-
Improved Developer Experience: More intuitive and discoverable than using generic
@extension. -
Flexibility: Supports both simple inline conditions and complex scenarios through model composition.
-
Type Safety: Leverages TypeSpec's type system for schema validation.
-
Consistency: Follows the pattern of other JSON Schema feature decorators.
Limitations
-
No Direct Decorator Nesting: Complex conditions require model composition, which is more verbose than direct nesting.
-
Learning Curve: Developers need to understand how to compose models for complex conditional scenarios.
-
Decorator Co-dependency:
@thenand@elseonly 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.