microsoft / microsoft/typespec
Support JSON Schema 2020-12 `if`/`then`/`else` conditional schemas
- Dominant language
- Java
- Stars
- 5.9k
- Forks
- 394
- Avg merge
- 1d 23h
- Merged PRs (30d)
- 104
Description
# JSON Schema Conditional Validation in TypeSpec
This proposal adds support for [JSON Schema conditional validation](https://json-schema.org/understanding-json-schema/reference/conditionals.html) (`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:
```typescript
/**
* 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
```typescript
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
```typescript
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
```typescript
// 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
```typescript
// 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
```typescript
@if(#{ format: "uri" })
@then(#{ pattern: "^https://.*" })
model SecureUrl extends string;
```
## Technical Implementation
### Library State Keys
Add new state keys in `lib.ts`:
```typescript
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`:
```typescript
export const [
/** Get schema set by `@if` decorator */
getIf,
setIf,
/** {@inheritdoc IfDecorator} */
$if,
] = createDataDecorator(JsonSchemaStateKeys["JsonSchema.if"]);
export const [
/** Get schema set by `@then` decorator */
getThen,
setThen,
/** {@inheritdoc ThenDecorator} */
$then,
] = createDataDecorator(JsonSchemaStateKeys["JsonSchema.then"]);
export const [
/** Get schema set by `@else` decorator */
getElse,
setElse,
/** {@inheritdoc ElseDecorator} */
$else,
] = createDataDecorator(JsonSchemaStateKeys["JsonSchema.else"]);
```
### Schema Generation
Update `#applyConstraints` in `json-schema-emitter.ts`:
```typescript
#applyConstraints(
type: Scalar | Model | ModelProperty | Union | UnionVariant | Enum,
schema: ObjectBuilder,
) {
// ... 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`:
```typescript
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:
```typescript
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:
```typescript
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:
```typescript
@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
- [x] Follow our [Code of Conduct](https://github.com/microsoft/typespec/blob/main/CODE_OF_CONDUCT.md)
- [x] Read the [docs](https://typespec.io/docs/).
- [x] Check that there isn't already an issue that request the same feature to avoid creating a duplicate.
Contributor guide
Assessment
This issue has not been assessed yet.