Skip to content

Commit 1432be7

Browse files
author
Forbes Lindesay
authored
feat: handle disjoint unions cleanly (#24)
This gives a clear error message for disjoint unions, providing there is a single key that is a string, number of enum and has a unique, distinct value in all items within the union. If this is the case, instead of construcing an `anyOf` we use a series of `if`/`then`/`else` constructs to select the appropriate schema based on the value of that key, if the key doesn't match any schemas, we default to a schema that lists the valid values for that key.
1 parent cd3bec5 commit 1432be7

10 files changed

+461
-9
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"prebuild": "yarn clean",
2222
"build": "tsc",
2323
"build:watch": "yarn build -w",
24-
"postbuild": "node lib/usage && node lib/cli src/Example.ts ExampleType && rimraf lib/__tests__",
24+
"postbuild": "node lib/usage && node lib/cli src/Example.ts ExampleType && node lib/cli src/DisjointUnionExample.ts --collection && rimraf lib/__tests__",
2525
"precommit": "pretty-quick --staged",
2626
"prepush": "yarn prettier:diff && yarn test",
2727
"prettier": "prettier --ignore-path .gitignore --write './**/*.{js,jsx,ts,tsx}'",

src/DisjointUnionExample.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export enum EntityTypes {
2+
TypeOne = 'TypeOne',
3+
TypeTwo = 'TypeTwo',
4+
TypeThree = 'TypeThree',
5+
}
6+
export interface EntityOne {
7+
type: EntityTypes.TypeOne;
8+
foo: string;
9+
}
10+
export interface EntityTwo {
11+
type: EntityTypes.TypeTwo;
12+
bar: string;
13+
}
14+
export type Entity =
15+
| EntityOne
16+
| EntityTwo
17+
| {type: EntityTypes.TypeThree; baz: number};
18+
19+
export type Value =
20+
| {number: 0; foo: string}
21+
| {number: 1; bar: string}
22+
| {number: 2; baz: string};

src/DisjointUnionExample.validator.ts

+240
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
/* tslint:disable */
2+
// generated by typescript-json-validator
3+
import Ajv = require('ajv');
4+
import {
5+
EntityTypes,
6+
EntityOne,
7+
EntityTwo,
8+
Entity,
9+
Value,
10+
} from './DisjointUnionExample';
11+
export const ajv = new Ajv({
12+
allErrors: true,
13+
coerceTypes: false,
14+
format: 'fast',
15+
nullable: true,
16+
unicode: true,
17+
uniqueItems: true,
18+
useDefaults: true,
19+
});
20+
21+
ajv.addMetaSchema(require('ajv/lib/refs/json-schema-draft-06.json'));
22+
23+
export {EntityTypes, EntityOne, EntityTwo, Entity, Value};
24+
export const Schema = {
25+
$schema: 'http://json-schema.org/draft-07/schema#',
26+
definitions: {
27+
Entity: {
28+
else: {
29+
else: {
30+
else: {
31+
properties: {
32+
type: {
33+
enum: ['TypeOne', 'TypeTwo', 'TypeThree'],
34+
type: 'string',
35+
},
36+
},
37+
required: ['type'],
38+
},
39+
if: {
40+
properties: {
41+
type: {
42+
enum: ['TypeThree'],
43+
type: 'string',
44+
},
45+
},
46+
required: ['type'],
47+
},
48+
then: {
49+
defaultProperties: [],
50+
properties: {
51+
baz: {
52+
type: 'number',
53+
},
54+
type: {
55+
enum: ['TypeThree'],
56+
type: 'string',
57+
},
58+
},
59+
required: ['baz', 'type'],
60+
type: 'object',
61+
},
62+
},
63+
if: {
64+
properties: {
65+
type: {
66+
enum: ['TypeTwo'],
67+
type: 'string',
68+
},
69+
},
70+
required: ['type'],
71+
},
72+
then: {
73+
$ref: '#/definitions/EntityTwo',
74+
},
75+
},
76+
if: {
77+
properties: {
78+
type: {
79+
enum: ['TypeOne'],
80+
type: 'string',
81+
},
82+
},
83+
required: ['type'],
84+
},
85+
then: {
86+
$ref: '#/definitions/EntityOne',
87+
},
88+
},
89+
EntityOne: {
90+
defaultProperties: [],
91+
properties: {
92+
foo: {
93+
type: 'string',
94+
},
95+
type: {
96+
enum: ['TypeOne'],
97+
type: 'string',
98+
},
99+
},
100+
required: ['foo', 'type'],
101+
type: 'object',
102+
},
103+
EntityTwo: {
104+
defaultProperties: [],
105+
properties: {
106+
bar: {
107+
type: 'string',
108+
},
109+
type: {
110+
enum: ['TypeTwo'],
111+
type: 'string',
112+
},
113+
},
114+
required: ['bar', 'type'],
115+
type: 'object',
116+
},
117+
EntityTypes: {
118+
enum: ['TypeOne', 'TypeThree', 'TypeTwo'],
119+
type: 'string',
120+
},
121+
Value: {
122+
else: {
123+
else: {
124+
else: {
125+
properties: {
126+
number: {
127+
enum: [0, 1, 2],
128+
type: 'number',
129+
},
130+
},
131+
required: ['number'],
132+
},
133+
if: {
134+
properties: {
135+
number: {
136+
enum: [2],
137+
type: 'number',
138+
},
139+
},
140+
required: ['number'],
141+
},
142+
then: {
143+
defaultProperties: [],
144+
properties: {
145+
baz: {
146+
type: 'string',
147+
},
148+
number: {
149+
enum: [2],
150+
type: 'number',
151+
},
152+
},
153+
required: ['baz', 'number'],
154+
type: 'object',
155+
},
156+
},
157+
if: {
158+
properties: {
159+
number: {
160+
enum: [1],
161+
type: 'number',
162+
},
163+
},
164+
required: ['number'],
165+
},
166+
then: {
167+
defaultProperties: [],
168+
properties: {
169+
bar: {
170+
type: 'string',
171+
},
172+
number: {
173+
enum: [1],
174+
type: 'number',
175+
},
176+
},
177+
required: ['bar', 'number'],
178+
type: 'object',
179+
},
180+
},
181+
if: {
182+
properties: {
183+
number: {
184+
enum: [0],
185+
type: 'number',
186+
},
187+
},
188+
required: ['number'],
189+
},
190+
then: {
191+
defaultProperties: [],
192+
properties: {
193+
foo: {
194+
type: 'string',
195+
},
196+
number: {
197+
enum: [0],
198+
type: 'number',
199+
},
200+
},
201+
required: ['foo', 'number'],
202+
type: 'object',
203+
},
204+
},
205+
},
206+
};
207+
ajv.addSchema(Schema, 'Schema');
208+
export function validate(
209+
typeName: 'EntityTypes',
210+
): (value: unknown) => EntityTypes;
211+
export function validate(typeName: 'EntityOne'): (value: unknown) => EntityOne;
212+
export function validate(typeName: 'EntityTwo'): (value: unknown) => EntityTwo;
213+
export function validate(typeName: 'Entity'): (value: unknown) => Entity;
214+
export function validate(typeName: 'Value'): (value: unknown) => Value;
215+
export function validate(typeName: string): (value: unknown) => any {
216+
const validator: any = ajv.getSchema(`Schema#/definitions/${typeName}`);
217+
return (value: unknown): any => {
218+
if (!validator) {
219+
throw new Error(
220+
`No validator defined for Schema#/definitions/${typeName}`,
221+
);
222+
}
223+
224+
const valid = validator(value);
225+
226+
if (!valid) {
227+
throw new Error(
228+
'Invalid ' +
229+
typeName +
230+
': ' +
231+
ajv.errorsText(
232+
validator.errors!.filter((e: any) => e.keyword !== 'if'),
233+
{dataVar: typeName},
234+
),
235+
);
236+
}
237+
238+
return value as any;
239+
};
240+
}

src/Example.validator.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ export default function validate(value: unknown): ExampleType {
4545
return value;
4646
} else {
4747
throw new Error(
48-
ajv.errorsText(rawValidateExampleType.errors, {dataVar: 'ExampleType'}) +
48+
ajv.errorsText(
49+
rawValidateExampleType.errors!.filter((e: any) => e.keyword !== 'if'),
50+
{dataVar: 'ExampleType'},
51+
) +
4952
'\n\n' +
5053
inspect(value),
5154
);

src/__tests__/build-parameters.test.ts

+7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
jest.setTimeout(30000);
12
import rimrafCB from 'rimraf';
23
import {exec as execCB, ExecOptions} from 'child_process';
34
import * as path from 'path';
@@ -24,6 +25,12 @@ const buildProject = async (project: string) => {
2425
await exec(`node ../../../lib/cli ./src/Example.ts ExampleType`, {
2526
cwd: testDir,
2627
});
28+
await exec(
29+
`node ../../../lib/cli ./src/DisjointUnionExample.ts --collection`,
30+
{
31+
cwd: testDir,
32+
},
33+
);
2734

2835
await exec(`npx tsc --project ./tsconfig.json`, {
2936
cwd: testDir,

src/__tests__/disjointUnion.test.ts

+49
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import {validate} from '../DisjointUnionExample.validator';
2+
3+
// let validate: any;
4+
5+
test('Enum Keys', () => {
6+
expect(() =>
7+
validate('Entity')({type: 'TypeOne'}),
8+
).toThrowErrorMatchingInlineSnapshot(
9+
`"Invalid Entity: Entity should have required property 'foo'"`,
10+
);
11+
expect(() =>
12+
validate('Entity')({type: 'TypeTwo'}),
13+
).toThrowErrorMatchingInlineSnapshot(
14+
`"Invalid Entity: Entity should have required property 'bar'"`,
15+
);
16+
expect(() =>
17+
validate('Entity')({type: 'TypeThree'}),
18+
).toThrowErrorMatchingInlineSnapshot(
19+
`"Invalid Entity: Entity should have required property 'baz'"`,
20+
);
21+
expect(() =>
22+
validate('Entity')({type: 'TypeFour'}),
23+
).toThrowErrorMatchingInlineSnapshot(
24+
`"Invalid Entity: Entity.type should be equal to one of the allowed values"`,
25+
);
26+
});
27+
28+
test('Number Keys', () => {
29+
expect(() =>
30+
validate('Value')({number: 0}),
31+
).toThrowErrorMatchingInlineSnapshot(
32+
`"Invalid Value: Value should have required property 'foo'"`,
33+
);
34+
expect(() =>
35+
validate('Value')({number: 1}),
36+
).toThrowErrorMatchingInlineSnapshot(
37+
`"Invalid Value: Value should have required property 'bar'"`,
38+
);
39+
expect(() =>
40+
validate('Value')({number: 2}),
41+
).toThrowErrorMatchingInlineSnapshot(
42+
`"Invalid Value: Value should have required property 'baz'"`,
43+
);
44+
expect(() =>
45+
validate('Value')({type: 'TypeFour'}),
46+
).toThrowErrorMatchingInlineSnapshot(
47+
`"Invalid Value: Value should have required property 'number'"`,
48+
);
49+
});

src/__tests__/output/ComplexExample.validator.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ export function validateKoaRequest(
138138
ctx.throw(
139139
400,
140140
'Invalid request: ' +
141-
ajv.errorsText(validator.errors, {dataVar: prop}) +
141+
ajv.errorsText(
142+
validator.errors!.filter((e: any) => e.keyword !== 'if'),
143+
{dataVar: prop},
144+
) +
142145
'\n\n' +
143146
inspect({
144147
params: ctx.params,
@@ -179,7 +182,10 @@ export function validate(typeName: string): (value: unknown) => any {
179182
'Invalid ' +
180183
typeName +
181184
': ' +
182-
ajv.errorsText(validator.errors, {dataVar: typeName}),
185+
ajv.errorsText(
186+
validator.errors!.filter((e: any) => e.keyword !== 'if'),
187+
{dataVar: typeName},
188+
),
183189
);
184190
}
185191

0 commit comments

Comments
 (0)