diff --git a/README.md b/README.md index eb9dc3e..5632004 100644 --- a/README.md +++ b/README.md @@ -373,9 +373,9 @@ type Object = FromSchema< // => { foo?: string; } ``` -`FromSchema` partially supports the `additionalProperties` and `patternProperties` keywords: +`FromSchema` partially supports the `additionalProperties`, `patternProperties` and `unevaluatedProperties` keywords: -- `additionalProperties` can be used to deny additional properties. +- `additionalProperties` and `unevaluatedProperties` can be used to deny additional properties. ```typescript const closedObjectSchema = { @@ -387,6 +387,29 @@ type Object = FromSchema; // => { foo: string; bar?: number; } ``` +```typescript +const closedObjectSchema = { + type: "object", + allOf: [ + { + properties: { + foo: { type: "string" }, + }, + required: ["foo"], + }, + { + properties: { + bar: { type: "number" }, + }, + }, + ], + unevaluatedProperties: false, +} as const; + +type Object = FromSchema; +// => { foo: string; bar?: number; } +``` + - Used on their own, `additionalProperties` and/or `patternProperties` can be used to type unnamed properties. ```typescript @@ -405,7 +428,36 @@ type Object = FromSchema; // => { [x: string]: string | number | boolean } ``` -- However, when used in combination with the `properties` keyword, extra properties will always be typed as `unknown` to avoid conflicts. +However: + +- When used in combination with the `properties` keyword, extra properties will always be typed as `unknown` to avoid conflicts. + +```typescript +const mixedObjectSchema = { + type: "object", + properties: { + foo: { enum: ["bar", "baz"] }, + }, + additionalProperties: { type: "string" }, +} as const; + +type Object = FromSchema; +// => { [x: string]: unknown; foo?: "bar" | "baz"; } +``` + +- Due to its context-dependent nature, `unevaluatedProperties` does not type extra-properties when used on its own. Use `additionalProperties` instead. + +```typescript +const openObjectSchema = { + type: "object", + unevaluatedProperties: { + type: "boolean", + }, +} as const; + +type Object = FromSchema; +// => { [x: string]: unknown } +``` ## Combining schemas diff --git a/package.json b/package.json index 7578f25..91eb09e 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ }, "dependencies": { "@babel/runtime": "^7.18.3", - "ts-algebra": "^1.2.2" + "ts-algebra": "^2.0.0" }, "devDependencies": { "@babel/cli": "^7.17.6", @@ -41,7 +41,7 @@ "@typescript-eslint/eslint-plugin": "^6.13.2", "@typescript-eslint/parser": "^6.13.2", "@zerollup/ts-transform-paths": "^1.7.18", - "ajv": "^8.10.0", + "ajv": "^8.13.0", "babel-plugin-module-resolver": "^4.1.0", "dependency-cruiser": "^11.18.0", "eslint": "^8.27.0", @@ -86,4 +86,4 @@ "url": "https://github.com/ThomasAribart/json-schema-to-ts/issues" }, "homepage": "https://github.com/ThomasAribart/json-schema-to-ts#readme" -} \ No newline at end of file +} diff --git a/src/definitions/jsonSchema.ts b/src/definitions/jsonSchema.ts index 1769c40..5826488 100644 --- a/src/definitions/jsonSchema.ts +++ b/src/definitions/jsonSchema.ts @@ -82,6 +82,7 @@ export type JSONSchema = properties?: Readonly>; patternProperties?: Readonly>; additionalProperties?: JSONSchema; + unevaluatedProperties?: JSONSchema; dependencies?: Readonly>; propertyNames?: JSONSchema; diff --git a/src/parse-schema/ajv.util.test.ts b/src/parse-schema/ajv.util.test.ts index 6e3626f..0770636 100644 --- a/src/parse-schema/ajv.util.test.ts +++ b/src/parse-schema/ajv.util.test.ts @@ -1,3 +1,3 @@ -import Ajv from "ajv"; +import Ajv2019 from "ajv/dist/2019"; -export const ajv = new Ajv({ strict: false }); +export const ajv = new Ajv2019({ strict: false }); diff --git a/src/parse-schema/allOf.unit.test.ts b/src/parse-schema/allOf.unit.test.ts index dbee17a..a741942 100644 --- a/src/parse-schema/allOf.unit.test.ts +++ b/src/parse-schema/allOf.unit.test.ts @@ -1,3 +1,5 @@ +import type { A } from "ts-toolbelt"; + import type { FromSchema } from "~/index"; import { ajv } from "./ajv.util.test"; @@ -477,6 +479,65 @@ describe("AllOf schemas", () => { expect(ajv.validate(objectSchema, objectInstance)).toBe(false); }); }); + + describe("Open to open object (w. unevaluated properties)", () => { + // Example from https://json-schema.org/understanding-json-schema/reference/object#unevaluatedproperties + const addressSchema = { + type: "object", + allOf: [ + { + type: "object", + properties: { + street_address: { type: "string" }, + city: { type: "string" }, + state: { type: "string" }, + }, + required: ["street_address", "city", "state"], + }, + ], + properties: { + type: { enum: ["residential", "business"] }, + }, + required: ["type"], + unevaluatedProperties: false, + } as const; + + type Address = FromSchema; + let address: Address; + + type ExpectedAddress = { + street_address: string; + city: string; + state: string; + type: "residential" | "business"; + }; + + type AssertAddress = A.Equals; + const assertAddress: AssertAddress = 1; + assertAddress; + + it("accepts valid objects", () => { + address = { + street_address: "1600 Pennsylvania Avenue NW", + city: "Washington", + state: "DC", + type: "business", + }; + expect(ajv.validate(addressSchema, address)).toBe(true); + }); + + it("rejects unevaluated properties", () => { + address = { + street_address: "1600 Pennsylvania Avenue NW", + city: "Washington", + state: "DC", + type: "business", + // @ts-expect-error + "something that doesn't belong": "hi!", + }; + expect(ajv.validate(addressSchema, address)).toBe(false); + }); + }); }); describe("Factored tuple properties", () => { diff --git a/src/parse-schema/ifThenElse.unit.test.ts b/src/parse-schema/ifThenElse.unit.test.ts index 358950e..c2efcec 100644 --- a/src/parse-schema/ifThenElse.unit.test.ts +++ b/src/parse-schema/ifThenElse.unit.test.ts @@ -1,3 +1,5 @@ +import type { A } from "ts-toolbelt"; + import type { FromSchema } from "~/index"; import { ajv } from "./ajv.util.test"; @@ -155,6 +157,80 @@ describe("If/Then/Else schemas", () => { }); }); + describe("Closed to closed object (unevaluated properties)", () => { + // Example from https://json-schema.org/understanding-json-schema/reference/object#unevaluatedproperties + const addressSchema = { + type: "object", + properties: { + street_address: { type: "string" }, + city: { type: "string" }, + state: { type: "string" }, + type: { enum: ["residential", "business"] }, + }, + required: ["street_address", "city", "state", "type"], + if: { + type: "object", + properties: { + type: { const: "business" }, + }, + required: ["type"], + }, + then: { + properties: { + department: { type: "string" }, + }, + }, + unevaluatedProperties: false, + } as const; + + type Address = FromSchema< + typeof addressSchema, + { parseIfThenElseKeywords: true } + >; + let address: Address; + + type ExpectedAddress = + | { + street_address: string; + city: string; + state: string; + type: "business"; + department?: string | undefined; + } + | { + street_address: string; + city: string; + state: string; + type: "residential"; + }; + type AssertAddress = A.Equals; + const assertAddress: AssertAddress = 1; + assertAddress; + + it("accepts valid objects", () => { + address = { + street_address: "1600 Pennsylvania Avenue NW", + city: "Washington", + state: "DC", + type: "business", + department: "HR", + }; + expect(ajv.validate(addressSchema, address)).toBe(true); + }); + + it("rejects unevaluated properties", () => { + address = { + street_address: "1600 Pennsylvania Avenue NW", + city: "Washington", + state: "DC", + type: "residential", + // @ts-expect-error + department: "HR", + }; + expect(ajv.validate(addressSchema, address)).toBe(false); + }); + }); + describe("additional items (incorrect)", () => { const petSchema = { type: "array", diff --git a/src/parse-schema/nullable.unit.test.ts b/src/parse-schema/nullable.unit.test.ts index 5fc8320..5f89fbe 100644 --- a/src/parse-schema/nullable.unit.test.ts +++ b/src/parse-schema/nullable.unit.test.ts @@ -151,10 +151,9 @@ describe("Nullable schemas", () => { }); it("rejects null on non-nullable property", () => { - // TOIMPROVE: Fix this: Use of allOf breaks the ifThenElse if exclusion somehow (works fine without allOf) + // @ts-expect-error objectInst = { preventNullable: "true", - // @ts-NOT-expect-error potentiallyNullable: null, }; expect(ajv.validate(objectWithNullablePropSchema, objectInst)).toBe( diff --git a/src/parse-schema/object.ts b/src/parse-schema/object.ts index ac4ade6..44320bb 100644 --- a/src/parse-schema/object.ts +++ b/src/parse-schema/object.ts @@ -40,12 +40,14 @@ export type ParseObjectSchema< >; }, GetRequired, - GetOpenProps + GetOpenProps, + GetClosedOnResolve > : M.$Object< {}, GetRequired, - GetOpenProps + GetOpenProps, + GetClosedOnResolve >; /** @@ -100,6 +102,17 @@ type GetOpenProps< ? PatternProps : M.Any; +/** + * Extracts and parses the unevaluated properties (if any exists) of an object JSON schema + * @param OBJECT_SCHEMA JSONSchema (object type) + * @param OPTIONS Parsing options + * @returns String + */ +type GetClosedOnResolve = + OBJECT_SCHEMA extends Readonly<{ unevaluatedProperties: false }> + ? true + : false; + /** * Extracts and parses the pattern properties of an object JSON schema * @param PATTERN_PROPERTY_SCHEMAS Record diff --git a/src/parse-schema/object.unit.test.ts b/src/parse-schema/object.unit.test.ts index e5c0b72..5e8ad82 100644 --- a/src/parse-schema/object.unit.test.ts +++ b/src/parse-schema/object.unit.test.ts @@ -97,6 +97,54 @@ describe("Object schemas", () => { }); }); + describe("Unevaluated properties", () => { + const setSchema = { + type: "object", + unevaluatedProperties: { type: "boolean" }, + } as const; + + type Set = FromSchema; + let setInstance: Set; + + it("accepts object with boolean values", () => { + setInstance = { a: true, b: false }; + expect(ajv.validate(setSchema, setInstance)).toBe(true); + }); + + it("rejects object with other values", () => { + // We do not handle this case for the moment + // @ts-NOT-expect-error + setInstance = { a: 42 }; + expect(ajv.validate(setSchema, setInstance)).toBe(false); + }); + + it("prioritizes additionalProperties and/or patternProterties if one is specified", () => { + const setSchema2 = { + type: "object", + additionalProperties: { type: "boolean" }, + unevaluatedProperties: { const: false }, + } as const; + + type Set2 = FromSchema; + const setInstance2: Set2 = { foo: true }; + + expect(ajv.validate(setSchema2, setInstance2)).toBe(true); + + const setSchema3 = { + type: "object", + patternProperties: { + "^f": { type: "boolean" }, + }, + unevaluatedProperties: { const: false }, + } as const; + + type Set3 = FromSchema; + const setInstance3: Set3 = { foo: true }; + + expect(ajv.validate(setSchema3, setInstance3)).toBe(true); + }); + }); + describe("Properties", () => { const catSchema = { type: "object", diff --git a/src/parse-schema/utils.ts b/src/parse-schema/utils.ts index 35019a5..3fa0dfa 100644 --- a/src/parse-schema/utils.ts +++ b/src/parse-schema/utils.ts @@ -13,13 +13,18 @@ type RemoveInvalidAdditionalItems = : Omit; /** - * Resets parent schema properties when merging a sub-schema into a parent schema + * Resets `additionalProperties` and `properties` from a sub-schema before merging it to a parent schema */ -type ParentSchemaOverrides = { - properties: {}; - additionalProperties: true; - required: []; -}; +type RemoveInvalidAdditionalProperties = + SCHEMA extends Readonly<{ additionalProperties: JSONSchema }> + ? SCHEMA extends Readonly<{ + properties: Readonly>; + }> + ? SCHEMA + : SCHEMA & Readonly<{ properties: {} }> + : SCHEMA extends boolean + ? SCHEMA + : Omit; /** * Merges a sub-schema into a parent schema. @@ -32,11 +37,16 @@ type ParentSchemaOverrides = { export type MergeSubSchema< PARENT_SCHEMA extends JSONSchema, SUB_SCHEMA extends JSONSchema, - CLEANED_SUB_SCHEMA extends - JSONSchema = RemoveInvalidAdditionalItems, - DEFAULTED_SUB_SCHEMA extends JSONSchema = Omit< - ParentSchemaOverrides, - keyof CLEANED_SUB_SCHEMA - > & - CLEANED_SUB_SCHEMA, -> = Omit & DEFAULTED_SUB_SCHEMA; + CLEANED_SUB_SCHEMA extends JSONSchema = RemoveInvalidAdditionalProperties< + RemoveInvalidAdditionalItems + >, +> = Omit< + PARENT_SCHEMA, + | keyof CLEANED_SUB_SCHEMA + | "additionalProperties" + | "patternProperties" + | "unevaluatedProperties" + | "required" + | "additionalItems" +> & + CLEANED_SUB_SCHEMA; diff --git a/src/tests/readme/object.type.test.ts b/src/tests/readme/object.type.test.ts index dbb2487..774758e 100644 --- a/src/tests/readme/object.type.test.ts +++ b/src/tests/readme/object.type.test.ts @@ -78,6 +78,64 @@ type AssertObjectWithTypedAdditionalProperties = A.Equals< const assertObjectWithTypedAdditionalProperties: AssertObjectWithTypedAdditionalProperties = 1; assertObjectWithTypedAdditionalProperties; +// Mixed schema + +const mixedObjectSchema = { + type: "object", + properties: { + foo: { enum: ["bar", "baz"] }, + }, + additionalProperties: { type: "string" }, +} as const; + +type ReceivedMixedObject = FromSchema; +type ExpectedMixedObject = { [x: string]: unknown; foo?: "bar" | "baz" }; + +type AssertMixedObject = A.Equals; +const assertMixedObject: AssertMixedObject = 1; +assertMixedObject; + +// Unevaluated properties schema + +const closedObjectSchema = { + type: "object", + allOf: [ + { + properties: { + foo: { type: "string" }, + }, + required: ["foo"], + }, + { + properties: { + bar: { type: "number" }, + }, + }, + ], + unevaluatedProperties: false, +} as const; + +type ReceivedClosedObject = FromSchema; +type ExpectedClosedObject = { foo: string; bar?: number }; + +type AssertClosedObject = A.Equals; +const assertClosedObject: AssertClosedObject = 1; +assertClosedObject; + +const openObjectSchema = { + type: "object", + unevaluatedProperties: { + type: "boolean", + }, +} as const; + +type ReceivedOpenObject = FromSchema; +type ExpectedOpenObject = { [x: string]: unknown }; + +type AssertOpenObject = A.Equals; +const assertOpenObject: AssertOpenObject = 1; +assertOpenObject; + // Defaulted property const objectWithDefaultedPropertySchema = { diff --git a/yarn.lock b/yarn.lock index 78b5742..650262f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2209,15 +2209,15 @@ ajv@^6.10.0, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ajv@^8.10.0: - version "8.10.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.10.0.tgz#e573f719bd3af069017e3b66538ab968d040e54d" - integrity sha512-bzqAEZOjkrUMl2afH8dknrq5KEk2SrwdBROR+vH1EKVQTqaUbJVPdc/gEdggTMM0Se+s+Ja4ju4TlNcStKl2Hw== +ajv@^8.13.0: + version "8.13.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.13.0.tgz#a3939eaec9fb80d217ddf0c3376948c023f28c91" + integrity sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA== dependencies: - fast-deep-equal "^3.1.1" + fast-deep-equal "^3.1.3" json-schema-traverse "^1.0.0" require-from-string "^2.0.2" - uri-js "^4.2.2" + uri-js "^4.4.1" ansi-escapes@^4.2.1: version "4.3.1" @@ -5841,10 +5841,10 @@ tr46@^2.1.0: dependencies: punycode "^2.1.1" -ts-algebra@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/ts-algebra/-/ts-algebra-1.2.2.tgz#b75d301c28cd4126cd344760a47b43e48e2872e0" - integrity sha512-kloPhf1hq3JbCPOTYoOWDKxebWjNb2o/LKnNfkWhxVVisFFmMJPPdJeGoGmM+iRLyoXAR61e08Pb+vUXINg8aA== +ts-algebra@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ts-algebra/-/ts-algebra-2.0.0.tgz#4e3e0953878f26518fce7f6bb115064a65388b7a" + integrity sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw== ts-api-utils@^1.0.1: version "1.0.3" @@ -6086,6 +6086,13 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +uri-js@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + url-parse@^1.5.3: version "1.5.10" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1"