Search code examples
typescriptjsonschema

How do JSON schema's anyOf type translate to typescript?


Let's say we have a schema like this (I borrowed the OpenAPI 3.0 format but I think the intention is clear):

{
  "components": {
    "schemas": {
      "HasName": {
        "type": "object",
        "properties": {
          "name": { "type": "string" }
        }
      },
      "HasEmail": {
        "type": "object",
        "properties": {
          "email": { "type": "string" }
        }
      },
      "OneOfSample": {
        "oneOf": [
          { "$ref": "#/components/schemas/HasName" },
          { "$ref": "#/components/schemas/HasEmail" }
        ]
      },
      "AllOfSample": {
        "allOf": [
          { "$ref": "#/components/schemas/HasName" },
          { "$ref": "#/components/schemas/HasEmail" }
        ]
      },
      "AnyOfSample": {
        "anyOf": [
          { "$ref": "#/components/schemas/HasName" },
          { "$ref": "#/components/schemas/HasEmail" }
        ]
      }
    }
  }
}

Based on this schema and the docs I read so far I would express types OneOfSample and AllOfSample like this:

type OneOfSample = HasName | HasEmail // Union type
type AllOfSample = HasName & HasEmail // Intersection type

But how would I express type AnyOfSample? Based on this page: https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/ I would think of something like this:

type AnyOfSample = HasName | HasEmail | (HasName & HasEmail)

The question is how do I correctly express the anyOf type in JSON schemas in typescript?


Solution

  • It looks like "OneOf" means "must match exactly one", while "AnyOf" means "must match at least one". It turns out that "at least one" is a more basic concept and corresponds to the union operation ("inclusive or") represented by the | symbol. Therefore, the answer to your question as stated is just:

    type AnyOfSample = HasName | HasEmail // Union type
    

    A further union with the intersection doesn't change what values are accepted:

    type AnyOfSample = HasName | HasEmail | (HasName & HasEmail) 
    

    because a union can only possibly add elements, and all of the elements of HasName & HasEmail are already present in HasName | HasEmail. Observe:

    type HasName = { name: string }
    type HasEmail = { email: string };
    
    type AnyOfIntersection = HasName | HasEmail | (HasName & HasEmail)
    type AnyOfWithout = HasName | HasEmail
    
    const aI: AnyOfIntersection = { name: "", email: "" }; // of course
    const aW: AnyOfWithout = { name: "", email: "" }; // also accepted
    

    You may want to keep the intersection if you are going to use the in operator to narrow values, which makes the technically-incorrect but often-useful assumption that if a key is not known to be present in a type, then it is not present:

    function processAnyOf(aI: AnyOfIntersection, aW: AnyOfWithout) {
        if (("name" in aI) && ("email" in aI)) {
            aI; // (HasName & HasEmail)
        }
        if (("name" in aW) && ("email" in aW)) {
            aW; // never
        }
    }
    

    Of course this means you have an incorrect definition for OneOfSample. This operation is more like a disjunctive union ("exclusive or"), although not exactly because when you have three or more sets, the usual definition of a disjunctive union means "matches an odd number", which is not what you want. As an aside, I can't find a widely used name for the type of disjunctive union we're talking about here, although here's an interesting paper that discusses it.

    So, how do we represent "matches exactly one" in TypeScript? This isn't straightforward because it is most easily built in terms of the negation or subtraction of types, which TypeScript can't currently do. That is, you want to say something like:

    type OneOfSample = (HasName | HasEmail) & Not<HasName & HasEmail>; // Not doesn't exist
    

    but there is no Not that works here. All you can do, therefore, is some kind of workaround... so what's possible? You can tell TypeScript that a type may not have a particular property. For example the type NoFoo may not have a foo key:

    type ProhibitKeys<K extends keyof any> = {[P in K]?: never}; 
    type NoFoo = ProhibitKeys<'foo'>; // becomes {foo?: never};
    

    And you can take a list of key names and remove key names from another list (that is, subtraction of string literals), using conditional types:

    type Subtract = Exclude<'a'|'b'|'c', 'c'|'d'>; // becomes 'a'|'b'
    

    This lets you do something like the following:

    type AllKeysOf<T> = T extends any ? keyof T : never; // get all keys of a union
    type ProhibitKeys<K extends keyof any> = {[P in K]?: never }; // from above
    type ExactlyOneOf<T extends any[]> = {
      [K in keyof T]: T[K] & ProhibitKeys<Exclude<AllKeysOf<T[number]>, keyof T[K]>>;
    }[number];
    

    In this case, ExactlyOneOf expects a tuple of types, and will represent a union of each element of the tuple explicitly prohibiting keys from other types. Let's see it in action:

    type HasName = { name: string };
    type HasEmail = { email: string };
    type OneOfSample = ExactlyOneOf<[HasName, HasEmail]>;
    

    If we inspect OneOfSample with IntelliSense, it is:

    type OneOfSample = (HasEmail & ProhibitKeys<"name">) | (HasName & ProhibitKeys<"email">);
    

    which is saying "either a HasEmail with no name property, or a HasName with no email property. Does it work?

    const okayName: OneOfSample = { name: "Rando" }; // okay
    const okayEmail: OneOfSample = { email: "rando@example.com" }; // okay
    const notOkay: OneOfSample = { name: "Rando", email: "rando@example.com" }; // error
    

    Looks like it.

    The tuple syntax lets you add three or more types:

    type HasCoolSunglasses = { shades: true };
    type AnotherOneOfSample = ExactlyOneOf<[HasName, HasEmail, HasCoolSunglasses]>;
    

    This inspects as

    type AnotherOneOfSample = (HasEmail & ProhibitKeys<"name" | "shades">) | 
      (HasName & ProhibitKeys<"email" | "shades">) | 
      (HasCoolSunglasses & ProhibitKeys<"email" | "name">)
    

    which, as you see, correctly distributes the prohibited keys around.


    There are other ways to do it, but that's how I'd proceed. It is a workaround and not a perfect solution because there are situations it doesn't handle properly, such as two types with the same keys whose properties are different types:

    declare class Animal { legs: number };
    declare class Dog extends Animal { bark(): void };
    declare class Cat extends Animal { meow(): void };
    type HasPetCat = { pet: Cat };
    type HasPetDog = { pet: Dog };
    type HasOneOfPetCatOrDog = ExactlyOneOf<[HasPetCat, HasPetDog]>;
    declare const abomination: Cat & Dog;
    const oops: HasOneOfPetCatOrDog = { pet: abomination }; // not an error
    

    In the above, ExactlyOneOf<> fails to recurse down into the properties of the pet property to make sure that it is not both a Cat and a Dog. This can be addressed, but it starts getting more complicated than you probably want. There are other edge cases too. It depends on what you need.

    Playground link to code