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

廉价感情. 提交于 2021-01-21 09:58:09

问题


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?


回答1:


In the following I assume we're using TS v3.1:

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 it just:

type AnyOfSample = HasName | HasEmail // Union type

Note that a further union with the intersection doesn't change things:

type AnyOfSample = HasName | HasEmail | (HasName & HasEmail) // same thing

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


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.

Anyway, hope that helps. Good luck!




回答2:


Actually, the idea of expressing a JSON Schema as a type definition is a paradigm mismatch. JSON Schema isn't designed for that kind of thing. It's trying to hammer a round peg into a square hole. It's never going to fit quite right.

JSON Schema is designed to translate into a function that can be used to validate a JSON document.



来源:https://stackoverflow.com/questions/52836812/how-do-json-schemas-anyof-type-translate-to-typescript

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!