Skip to content

Commit

Permalink
Merge pull request #197 from ThomasAribart/support-unevaluated-proper…
Browse files Browse the repository at this point in the history
…ties-keyword

feat: Support unevaluated properties keyword
  • Loading branch information
ThomasAribart committed May 4, 2024
2 parents 86f1c24 + e806f2e commit ab6871c
Show file tree
Hide file tree
Showing 12 changed files with 361 additions and 36 deletions.
58 changes: 55 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -387,6 +387,29 @@ type Object = FromSchema<typeof closedObjectSchema>;
// => { 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<typeof closedObjectSchema>;
// => { foo: string; bar?: number; }
```

- Used on their own, `additionalProperties` and/or `patternProperties` can be used to type unnamed properties.

```typescript
Expand All @@ -405,7 +428,36 @@ type Object = FromSchema<typeof openObjectSchema>;
// => { [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<typeof mixedObjectSchema>;
// => { [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<typeof openObjectSchema>;
// => { [x: string]: unknown }
```

## Combining schemas

Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -86,4 +86,4 @@
"url": "https://github.com/ThomasAribart/json-schema-to-ts/issues"
},
"homepage": "https://github.com/ThomasAribart/json-schema-to-ts#readme"
}
}
1 change: 1 addition & 0 deletions src/definitions/jsonSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export type JSONSchema =
properties?: Readonly<Record<string, JSONSchema>>;
patternProperties?: Readonly<Record<string, JSONSchema>>;
additionalProperties?: JSONSchema;
unevaluatedProperties?: JSONSchema;
dependencies?: Readonly<Record<string, JSONSchema | readonly string[]>>;
propertyNames?: JSONSchema;

Expand Down
4 changes: 2 additions & 2 deletions src/parse-schema/ajv.util.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
61 changes: 61 additions & 0 deletions src/parse-schema/allOf.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { A } from "ts-toolbelt";

import type { FromSchema } from "~/index";

import { ajv } from "./ajv.util.test";
Expand Down Expand Up @@ -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<typeof addressSchema>;
let address: Address;

type ExpectedAddress = {
street_address: string;
city: string;
state: string;
type: "residential" | "business";
};

type AssertAddress = A.Equals<Address, ExpectedAddress>;
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", () => {
Expand Down
76 changes: 76 additions & 0 deletions src/parse-schema/ifThenElse.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { A } from "ts-toolbelt";

import type { FromSchema } from "~/index";

import { ajv } from "./ajv.util.test";
Expand Down Expand Up @@ -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<Address, ExpectedAddress>;
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",
Expand Down
3 changes: 1 addition & 2 deletions src/parse-schema/nullable.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 15 additions & 2 deletions src/parse-schema/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,14 @@ export type ParseObjectSchema<
>;
},
GetRequired<OBJECT_SCHEMA, OPTIONS>,
GetOpenProps<OBJECT_SCHEMA, OPTIONS>
GetOpenProps<OBJECT_SCHEMA, OPTIONS>,
GetClosedOnResolve<OBJECT_SCHEMA>
>
: M.$Object<
{},
GetRequired<OBJECT_SCHEMA, OPTIONS>,
GetOpenProps<OBJECT_SCHEMA, OPTIONS>
GetOpenProps<OBJECT_SCHEMA, OPTIONS>,
GetClosedOnResolve<OBJECT_SCHEMA>
>;

/**
Expand Down Expand Up @@ -100,6 +102,17 @@ type GetOpenProps<
? PatternProps<OBJECT_SCHEMA["patternProperties"], OPTIONS>
: 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 ObjectSchema> =
OBJECT_SCHEMA extends Readonly<{ unevaluatedProperties: false }>
? true
: false;

/**
* Extracts and parses the pattern properties of an object JSON schema
* @param PATTERN_PROPERTY_SCHEMAS Record<string, JSONSchema>
Expand Down
48 changes: 48 additions & 0 deletions src/parse-schema/object.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,54 @@ describe("Object schemas", () => {
});
});

describe("Unevaluated properties", () => {
const setSchema = {
type: "object",
unevaluatedProperties: { type: "boolean" },
} as const;

type Set = FromSchema<typeof setSchema>;
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<typeof setSchema2>;
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<typeof setSchema3>;
const setInstance3: Set3 = { foo: true };

expect(ajv.validate(setSchema3, setInstance3)).toBe(true);
});
});

describe("Properties", () => {
const catSchema = {
type: "object",
Expand Down
Loading

0 comments on commit ab6871c

Please sign in to comment.