json-validator.de

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

VarianteLesbarkeitFehlerqualitätCross-Language
oneOf + constSchwach (Duplikate)MittelJa
if/then/elseGutMit ajv-errors gutJa (Draft ≥ 07)
Zod discriminatedExzellentExzellentNur TS

Für JSON-Schema-Welt: if/then/else. Für TS-only-Stacks: Zod. oneOf-mit-const ist heute meist die schlechtere Wahl.