Effect-TS / schema

Modeling the schema of data structures as first-class values
https://effect.website
MIT License
497 stars 38 forks source link

JSON Schema: literal should be converted to enum instead of anyOf #579

Closed huypham50 closed 11 months ago

huypham50 commented 12 months ago

What is the problem this feature would solve?

Coming from pydantic, literals are converted to enums in json schema:

import json
from enum import Enum
from typing import Literal

from pydantic import BaseModel

class Gender(str, Enum):
    male = 'male'
    female = 'female'
    other = 'other'
    not_given = 'not_given'

class Schema(BaseModel):
    degree: Literal['bachelor', 'master', 'doctor']
    gender: Gender

print(json.dumps(Schema.model_json_schema(), indent=2))

{
  "$defs": {
    "Gender": {
      "enum": [
        "male",
        "female",
        "other",
        "not_given"
      ],
      "title": "Gender",
      "type": "string"
    }
  },
  "properties": {
    "degree": {
      "enum": [
        "bachelor",
        "master",
        "doctor"
      ],
      "title": "Degree",
      "type": "string"
    },
    "gender": {
      "$ref": "#/$defs/Gender"
    }
  },
  "required": [
    "degree",
    "gender"
  ],
  "title": "Schema",
  "type": "object"
}

However, in effect, literals will be converted to anyOf

const degreeSchema = S.literal('bachelor', 'master', 'doctor')
const jsonSchema = JSONSchema.to(degreeSchema);

"gender": {
  "anyOf": [
    {
      "type": "string",
      "const": "bachelor"
    },
    {
      "type": "string",
      "const": "master"
    },
    {
      "type": "string",
      "const": "doctor"
    }
  ],
}

Would slightly prefer enums in this case because it's stricter than anyOf (literals have strict nature imo).

What is the feature you are proposing to solve the problem?

Literals should be converted to enums instead of anyof

What alternatives have you considered?

Current workaround is using enums (not recommended) or records (too verbose)

  const degree = S.enums({
    HighSchool: 'HighSchool',
    University: 'University',
    Graduate: 'Graduate',
  } as const)
gcanti commented 12 months ago

Problem with enums is that AFAIK there's no room for meta data such as title, description, etc..., we could use oneOf + const:

"gender": {
  "oneOf": [
    {
      "const": "male",
      "description": "..."
    },
    {
      "const": "female"
    }
  ],
}
huypham50 commented 12 months ago

Maybe something like this?

const enumJsonSchema = S.literal('male', 'female')
const anyOfJsonSchema = S.union(S.literal('male'), S.literal('female'))
gcanti commented 11 months ago

Yeah, however S.literal('male', 'female') is just a shorthand for S.union(S.literal('male'), S.literal('female')), they yield the same schema.

So I guess we must identify the members of a union that are literals without any meta data, group them toghether and generate for them a JSON Schema in the form of { enum: [...] }.

Like:

const schema = S.union(
  S.literal(1, 2),
  S.literal(true).pipe(S.description("description")),
  S.string
)

const jsonSchema = JSONSchema.to(schema)

expect(jsonSchema).toEqual({
  "$schema": "http://json-schema.org/draft-07/schema#",
  "anyOf": [
    { "const": true, "description": "description" },
    {
      "type": "string",
      "description": "a string",
      "title": "string"
    },
    { "enum": [1, 2] }
  ]
})

And then S.literal(...) is just a special case of the above

const schema = S.literal(1, 2)

const jsonSchema = JSONSchema.to(schema)

expect(jsonSchema).toEqual({
  "$schema": "http://json-schema.org/draft-07/schema#",
  "enum": [1, 2]
})