diff --git a/README.md b/README.md index cd4fc00..4314a68 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,24 @@ type Object = FromSchema; // => { [x: string]: unknown } ``` +- `FromSchema` comes with an `omitAdditionalProperties` option which will cause objects to be always typed as closed (without index signature type): + +```typescript +const openObjectSchema = { + type: "object", + additionalProperties: true, + properties: { + foo: { type: "string" }, + }, +} as const; + +type Object = FromSchema< + typeof tupleSchema, + { omitAdditionalProperties: true } +>; +// => { foo?: string } +``` + ## Combining schemas ### AnyOf diff --git a/package.json b/package.json index b65b620..42e4102 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "eslint-plugin-prefer-arrow": "^1.2.3", "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-unused-imports": "^2.0.0", + "expect-type": "^1.2.2", "jest": "^27.5.1", "prettier": "^3.1.0", "rollup": "^2.67.3", @@ -86,4 +87,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/fromSchemaOptions.ts b/src/definitions/fromSchemaOptions.ts index 959215c..79288b1 100644 --- a/src/definitions/fromSchemaOptions.ts +++ b/src/definitions/fromSchemaOptions.ts @@ -15,6 +15,7 @@ export type FromSchemaOptions = { keepDefaultedPropertiesOptional?: boolean; references?: JSONSchemaReference[] | false; deserialize?: DeserializationPattern[] | false; + omitAdditionalProperties?: boolean; }; /** @@ -26,6 +27,7 @@ export type FromExtendedSchemaOptions = { keepDefaultedPropertiesOptional?: boolean; references?: ExtendedJSONSchemaReference[] | false; deserialize?: DeserializationPattern[] | false; + omitAdditionalProperties?: boolean; }; /** @@ -37,4 +39,5 @@ export type FromSchemaDefaultOptions = { keepDefaultedPropertiesOptional: false; references: false; deserialize: false; + omitAdditionalProperties: false; }; diff --git a/src/parse-options.ts b/src/parse-options.ts index fc1442e..93c71ae 100644 --- a/src/parse-options.ts +++ b/src/parse-options.ts @@ -43,4 +43,7 @@ export type ParseOptions< deserialize: OPTIONS["deserialize"] extends DeserializationPattern[] | false ? OPTIONS["deserialize"] : FromSchemaDefaultOptions["deserialize"]; + omitAdditionalProperties: OPTIONS["omitAdditionalProperties"] extends boolean + ? OPTIONS["omitAdditionalProperties"] + : FromSchemaDefaultOptions["omitAdditionalProperties"]; }; diff --git a/src/parse-options.type.test.ts b/src/parse-options.type.test.ts index 7ed7ca2..d06862d 100644 --- a/src/parse-options.type.test.ts +++ b/src/parse-options.type.test.ts @@ -33,6 +33,7 @@ type ExpectedOptions = { deserialize: FromSchemaDefaultOptions["deserialize"]; rootSchema: RootSchema; references: IndexReferencesById; + omitAdditionalProperties: FromSchemaDefaultOptions["omitAdditionalProperties"]; }; const assertOptions: A.Equals = 1; diff --git a/src/parse-schema/index.ts b/src/parse-schema/index.ts index 6081b0b..78e9fba 100644 --- a/src/parse-schema/index.ts +++ b/src/parse-schema/index.ts @@ -47,6 +47,10 @@ export type ParseSchemaOptions = { * To override inferred types if some pattern is matched */ deserialize: DeserializationPattern[] | false; + /** + * Ignore additionalProperties value and always infer closed objects + */ + omitAdditionalProperties: boolean; }; /** diff --git a/src/parse-schema/object.ts b/src/parse-schema/object.ts index 44320bb..5c2b345 100644 --- a/src/parse-schema/object.ts +++ b/src/parse-schema/object.ts @@ -41,13 +41,13 @@ export type ParseObjectSchema< }, GetRequired, GetOpenProps, - GetClosedOnResolve + GetClosedOnResolve > : M.$Object< {}, GetRequired, GetOpenProps, - GetClosedOnResolve + GetClosedOnResolve >; /** @@ -108,8 +108,12 @@ type GetOpenProps< * @param OPTIONS Parsing options * @returns String */ -type GetClosedOnResolve = - OBJECT_SCHEMA extends Readonly<{ unevaluatedProperties: false }> +type GetClosedOnResolve< + OBJECT_SCHEMA extends ObjectSchema, + OPTIONS extends ParseSchemaOptions, +> = OPTIONS["omitAdditionalProperties"] extends true + ? true + : OBJECT_SCHEMA extends Readonly<{ unevaluatedProperties: false }> ? true : false; diff --git a/src/parse-schema/object.unit.test.ts b/src/parse-schema/object.unit.test.ts index 5e8ad82..b75f47b 100644 --- a/src/parse-schema/object.unit.test.ts +++ b/src/parse-schema/object.unit.test.ts @@ -1,3 +1,5 @@ +import { expectTypeOf } from "expect-type"; + import type { FromSchema } from "~/index"; import { ajv } from "./ajv.util.test"; @@ -45,6 +47,17 @@ describe("Object schemas", () => { setInstance = { a: 42 }; expect(ajv.validate(setSchema, setInstance)).toBe(false); }); + + describe("with omitAdditionalProperties option", () => { + it("rejects additional properties", () => { + type Set2 = FromSchema< + typeof setSchema, + { omitAdditionalProperties: true } + >; + + expectTypeOf().toEqualTypeOf<{}>(); + }); + }); }); describe("Pattern properties", () => { @@ -95,6 +108,17 @@ describe("Object schemas", () => { objInstance = { B: true, S: "str", I: 42, N: null }; expect(ajv.validate(boolStrOrNumObjSchema, objInstance)).toBe(false); }); + + describe("with omitAdditionalProperties option", () => { + it("rejects object with boolean value", () => { + type Set = FromSchema< + typeof boolStrOrNumObjSchema, + { omitAdditionalProperties: true } + >; + + expectTypeOf().toEqualTypeOf<{}>(); + }); + }); }); describe("Unevaluated properties", () => { @@ -286,7 +310,7 @@ describe("Object schemas", () => { expect(ajv.validate(addressSchema, addressInstance)).toBe(true); }); - it("accepts object with missing required properties", () => { + it("rejects object with missing required properties", () => { // @ts-expect-error addressInstance = { number: 13, @@ -295,6 +319,25 @@ describe("Object schemas", () => { }; expect(ajv.validate(addressSchema, addressInstance)).toBe(false); }); + + describe("with omitAdditionalProperties option", () => { + it("rejects object with additional properties", () => { + type Address2 = FromSchema< + typeof addressSchema, + { omitAdditionalProperties: true } + >; + + const addressInstance2: Address2 = { + number: 13, + streetName: "Champs Elysées", + streetType: "Avenue", + direction: "NW", + // @ts-expect-error + additionalProperty: ["any", "value"], + }; + expect(ajv.validate(addressSchema, addressInstance2)).toBe(true); + }); + }); }); describe("Required + Typed additional properties", () => { @@ -345,6 +388,24 @@ describe("Object schemas", () => { }; expect(ajv.validate(addressSchema, addressInstance)).toBe(false); }); + + describe("with omitAdditionalProperties option", () => { + it("rejects object with valid additional properties", () => { + type Address2 = FromSchema< + typeof addressSchema, + { omitAdditionalProperties: true } + >; + + const addressInstance2: Address2 = { + number: 13, + streetName: "Champs Elysées", + streetType: "Avenue", + // @ts-expect-error + additionalProperty: "additionalProperty", + }; + expect(ajv.validate(addressSchema, addressInstance2)).toBe(true); + }); + }); }); describe("Required missing in properties", () => { diff --git a/src/tests/readme/object.type.test.ts b/src/tests/readme/object.type.test.ts index 774758e..9fdf281 100644 --- a/src/tests/readme/object.type.test.ts +++ b/src/tests/readme/object.type.test.ts @@ -168,3 +168,29 @@ type AssertObjectWithDefaultedProperty2 = A.Equals< >; const assertObjectWithDefaultedProperty2: AssertObjectWithDefaultedProperty2 = 1; assertObjectWithDefaultedProperty2; + +// With omitAdditionalProperties option + +const openObjectSchema2 = { + type: "object", + additionalProperties: true, + properties: { + foo: { type: "string" }, + }, +} as const; + +type ReceivedOpenObjectWithOmitAdditionalPropertiesOption = FromSchema< + typeof openObjectSchema2, + { omitAdditionalProperties: true } +>; +type ExpectedOpenObjectWithOmitAdditionalPropertiesOption = { + foo?: string; +}; + +type AssertOpenObjectWithOmitAdditionalPropertiesOption = A.Equals< + ReceivedOpenObjectWithOmitAdditionalPropertiesOption, + ExpectedOpenObjectWithOmitAdditionalPropertiesOption +>; + +const assertOpenObjectWithOmitAdditionalPropertiesOption: AssertOpenObjectWithOmitAdditionalPropertiesOption = 1; +assertOpenObjectWithOmitAdditionalPropertiesOption; diff --git a/yarn.lock b/yarn.lock index 650262f..b5896f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3340,6 +3340,11 @@ exit@^0.1.2: resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" integrity sha1-BjJjj42HfMghB9MKD/8aF8uhzQw= +expect-type@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.2.tgz#c030a329fb61184126c8447585bc75a7ec6fbff3" + integrity sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA== + expect@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/expect/-/expect-27.5.1.tgz#83ce59f1e5bdf5f9d2b94b61d2050db48f3fef74"