Case-Study · Subscription-API, Ajv 8.12 + Draft 2020-12
Case-Study: Tagged Unions mit if/then/else sauber abbilden
Subscription-Schema mit drei Plan-Typen (free, pro, enterprise). Jeder Typ hat andere Pflichtfelder. if/then/else statt oneOf-Workaround.
Schema · Draft 2020-12
Payload-Beispiel
{
"plan": "pro",
"user_id": "usr_abc12345",
"billing_email": "billing@example.com",
"renewal_at": "2027-05-14T00:00:00Z"
} Schema-Auszug
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["plan", "user_id"],
"properties": {
"plan": { "enum": ["free", "pro", "enterprise"] },
"user_id": { "type": "string", "pattern": "^usr_[a-z0-9]{8}$" }
},
"allOf": [
{
"if": { "properties": { "plan": { "const": "pro" } } },
"then": { "required": ["billing_email", "renewal_at"] }
},
{
"if": { "properties": { "plan": { "const": "enterprise" } } },
"then": { "required": ["billing_email", "renewal_at", "contract_id"] }
}
]
} Erkenntnisse aus dem Projekt
- ✓ if/then/else seit Draft 07 - voll von Ajv unterstützt
- ✓ Mehrere Bedingungen per allOf kombinieren
- ✓ if-Schema nur evaluieren, kein Errors-Reporting auf if-Fehler
- ✓ Fehlermeldungen sind ohne ajv-errors-Package suboptimal
- ✓ Alternative: oneOf + const auf Discriminator (etwas verboser)
Anforderung: Subscription-API mit drei Plan-Typen. Jeder Typ hat andere Pflichtfelder. Free hat nur user_id, Pro braucht zusätzlich billing_email und renewal_at, Enterprise braucht außerdem contract_id. Drei Lösungswege: oneOf, if/then/else, und custom validator.
Variante 1: oneOf mit Discriminator
{
"oneOf": [
{
"type": "object",
"required": ["plan", "user_id"],
"additionalProperties": false,
"properties": {
"plan": { "const": "free" },
"user_id": { "type": "string" }
}
},
{
"type": "object",
"required": ["plan", "user_id", "billing_email", "renewal_at"],
"additionalProperties": false,
"properties": {
"plan": { "const": "pro" },
"user_id": { "type": "string" },
"billing_email": { "type": "string", "format": "email" },
"renewal_at": { "type": "string", "format": "date-time" }
}
}
/* ... enterprise variant */
]
}
Funktioniert. Aber verbose: properties stehen pro Variante komplett dupliziert. Fehlermeldungen sind unklar - Ajv weiß nicht welcher der drei oneOf-Branches "gemeint" war.
Variante 2: if/then/else (empfohlen)
Siehe Schema-Sample oben. Wesentliche Eigenschaften:
- Properties stehen nur einmal - keine Duplikation
- Bedingungen sind sprechend: "wenn plan = pro, dann muss billing_email da sein"
- Mehrere Bedingungen per allOf-Wrapper
- Performance: marginal langsamer als oneOf mit Discriminator, vernachlässigbar
Fehlermeldungen verbessern
Ajv-Standard-Fehler bei if/then/else sind kryptisch ("must match then-schema"). Mit ajv-errors-Package bekommt man bessere Messages:
{
"if": { "properties": { "plan": { "const": "pro" } } },
"then": {
"required": ["billing_email", "renewal_at"],
"errorMessage": "Pro-Plans benötigen billing_email und renewal_at"
}
}
Variante 3: discriminatedUnion in Zod
Wenn das Schema nicht für externe APIs sondern nur für internen Frontend-Code ist:
const subscriptionSchema = z.discriminatedUnion("plan", [
z.object({ plan: z.literal("free"), user_id: z.string() }),
z.object({ plan: z.literal("pro"), user_id: z.string(),
billing_email: z.string().email(),
renewal_at: z.string().datetime() }),
]);
type Subscription = z.infer<typeof subscriptionSchema>;
Vorteile: TypeScript-Type-Inference, klarere Fehler. Nachteil: nicht als JSON Schema exportierbar (ohne zod-to-json-schema).
Ergebnis
| Variante | Lesbarkeit | Fehlerqualität | Cross-Language |
|---|---|---|---|
| oneOf + const | Schwach (Duplikate) | Mittel | Ja |
| if/then/else | Gut | Mit ajv-errors gut | Ja (Draft ≥ 07) |
| Zod discriminated | Exzellent | Exzellent | Nur TS |
Für JSON-Schema-Welt: if/then/else. Für TS-only-Stacks: Zod. oneOf-mit-const ist heute meist die schlechtere Wahl.