├── .gitignore
├── .prettierrc
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── babel.config.js
├── jest.config.js
├── logo.svg
├── package.json
├── rollup.config.js
├── src
├── index.ts
├── useSchema.input.test.ts
├── useSchema.test.ts
├── useSchema.ts
└── utils
│ ├── getNestedProperty.ts
│ └── invariant.ts
├── tsconfig.json
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | .rpt2_*
2 | node_modules
3 | lib
4 | internals
5 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "arrowParens": "always",
5 | "trailingComma": "all"
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true
3 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Artem Zakharchenko
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Reach Schema
10 | Functional schema-driven JavaScript object validation library.
11 |
12 | ## Motivation
13 |
14 | It happens that JavaScript Object validation libraries are often class-based and operate using via a chain of operators. With Reach Schema I would like to take an alternative approach, making validation functional.
15 |
16 | **Main concepts of React Schema:**
17 |
18 | 1. Validation result is a function from schema and data.
19 | 1. Validation result is not coupled with the error messages logic.
20 |
21 | > Reach Schema works great together with functional programming libraries like [lodash](https://lodash.com/) or [ramda](https://ramdajs.com/). They allow to make validation declaration shorter and make your life easier. Consider those.
22 |
23 | ## Validation schema
24 |
25 | Data validity is described using a _validation schema_. It's a plain Object which keys represent the actual data keys hierarhcy, and values equal to _resolver functions_ that return the validation verdict.
26 |
27 | ```ts
28 | interface Schema {
29 | [field: string]: Resolver | NamedResolver | Schema
30 | }
31 |
32 | // A plain resolver function that returns a boolean verdict.
33 | interface Resolver {
34 | (value: ValueType): boolean
35 | }
36 |
37 | // A named resolver function that returns a Record of rules.
38 | interface NamedResolver {
39 | (value: ValueType): {
40 | [ruleName: string]: boolean
41 | }
42 | }
43 | ```
44 |
45 | Applying a validation schema to the actual data returns the validation result.
46 |
47 | ```ts
48 | interface ValidationResult {
49 | errors: Error[]
50 | }
51 | ```
52 |
53 | ## Resolver
54 |
55 | _Resolver_ is a function that determines a value's validity. A simple resolver accepts a value and returns a boolean verdict. Reach Schema supports more complex resolvers, such as _grouped resolver_, which allows to provide multiple independent criteria to a single value.
56 |
57 | ### Basic resolver
58 |
59 | ```js
60 | useSchema(
61 | {
62 | firstName: (value, pointer) => value === 'John',
63 | },
64 | {
65 | firstName: 'Jessica',
66 | },
67 | )
68 | ```
69 |
70 | ### Grouped resolver
71 |
72 | ```js
73 | useSchema(
74 | {
75 | password: (value, pointer) => ({
76 | minLength: value.length > 7,
77 | capitalLetter: /[A-Z]/.test(value),
78 | oneNumber: /[0-9]/.test(value),
79 | }),
80 | },
81 | {
82 | password: 'IshallPass8',
83 | },
84 | )
85 | ```
86 |
87 | ### Nested schema
88 |
89 | Resolver may also return an Object, if validating an Object type value, which would be treated as a nested [Validation schema](#validation-schema).
90 |
91 | ```js
92 | useSchema(
93 | {
94 | billingDetails: {
95 | // Nested schema accepts all kinds of resolvers:
96 | // basic, grouped, and deeply nested schema.
97 | address: (value) => checkAddressExistance(value),
98 | zipCode: (value) => /\d{5}/.test(zipCode),
99 | },
100 | },
101 | {
102 | billingDetails: {
103 | address: 'Sunwell Ave.',
104 | zipCode: 56200,
105 | },
106 | },
107 | )
108 | ```
109 |
110 | ## Errors
111 |
112 | Each validation error has the following structure:
113 |
114 | ```ts
115 | interface Error {
116 | // Pointer to the related property in the actual data.
117 | pointer: string[]
118 |
119 | // A property's validation state.
120 | // - "missing". Expected, but not present in the actual data.
121 | // - "invalid". Present, but doesn't satisfy the validation resolver.
122 | status: 'missing' | 'invalid'
123 |
124 | // A property's value, if present in the actual data.
125 | value?: any
126 |
127 | // Name of the rejected validation rule, if applicable.
128 | rule?: string
129 | }
130 | ```
131 |
132 | ## API
133 |
134 | ### `useSchema: (schema: Schema, data: Object): ValidationError[]`
135 |
136 | #### Basic example
137 |
138 | Each key in a schema corresponds to same property in the actual data. Each schema value is a _resolver_ function that accepts an actual data value and returns a `Boolean` verdict.
139 |
140 | ```js
141 | import { useSchema } from 'reach-schema'
142 |
143 | useSchema(
144 | {
145 | firstName: (value) => value === 'john',
146 | lastName: (value) => value === 'locke',
147 | age: (value) => value > 17,
148 | },
149 | {
150 | firstName: 'john',
151 | age: 16,
152 | },
153 | )
154 | ```
155 |
156 | ```json
157 | [
158 | {
159 | "pointer": ["lastName"],
160 | "status": "missing"
161 | },
162 | {
163 | "pointer": ["age"],
164 | "status": "invalid",
165 | "value": 16
166 | }
167 | ]
168 | ```
169 |
170 | #### Nested properties
171 |
172 | If a schema key equals an Object literal, that nested Object is expected in the data. This allows to validate deeply nested structures.
173 |
174 | ```js
175 | import { useSchema } from 'reach-schema'
176 |
177 | useSchema(
178 | {
179 | billingData: {
180 | country: (value) => ['UK', 'ES'].includes(value),
181 | },
182 | },
183 | {
184 | billingData: {
185 | country: 'US',
186 | },
187 | },
188 | )
189 | ```
190 |
191 | ```json
192 | [
193 | {
194 | "pointer": ["billingData", "country"],
195 | "status": "invalid",
196 | "value": "US"
197 | }
198 | ]
199 | ```
200 |
201 | #### Multiple criteria
202 |
203 | A resolver function may also return a map of rules that apply to the corresponding nested properties. By default, the actual value must satisfy all the rules in order to be valid (**see [Optional validation](#optional-validation)**). Each resolver corresponding to a validation criteria is called _named resolver_.
204 |
205 | ```js
206 | import { useSchema } from 'reach-schema'
207 |
208 | useSchema(
209 | {
210 | password: (value) => ({
211 | minLength: value.length > 5,
212 | capitalLetters: /[A-Z]{2}/.test(value),
213 | oneNumber: /[0-9]/.test(value),
214 | }),
215 | },
216 | {
217 | password: 'DeMo',
218 | },
219 | )
220 | ```
221 |
222 | ```json
223 | [
224 | {
225 | "pointer": ["password"],
226 | "status": "invalid",
227 | "value": "DeMo",
228 | "rule": "minLength"
229 | },
230 | {
231 | "pointer": ["password"],
232 | "status": "invalid",
233 | "value": "DeMo",
234 | "rule": "oneNumber"
235 | }
236 | ]
237 | ```
238 |
239 | ## Error messages
240 |
241 | Reach Schema does not provide any error messages directly. Instead, it treats an error message as an artifact derived from the validation result. To achieve that it provides all the relevant information in the validation result to construct an error message.
242 |
243 | However, there is a common logic that can be integrated into such error messages construction (i.e. resolving due to priority, fallback messages). Declaring such logic each time would be lengthy, time-consuming, and prone to human error.
244 |
245 | ## Recipes
246 |
247 | ### Property existence
248 |
249 | To check that a property exists in the actual data provide a resolver function that always returns `true`. Ideologically it marks the property as "always valid", but since the default validation behavior asserts all the keys present in the Validation Schema, it also implies that the property must be present.
250 |
251 | ```js
252 | import { useSchema } from 'reach-schema'
253 |
254 | useSchema(
255 | {
256 | // The property "email" is required in the data Object,
257 | // but is always valid, no matter the value.
258 | email: () => true,
259 | },
260 | {
261 | email: 'admin@example.com',
262 | },
263 | )
264 | ```
265 |
266 | ### Optional validation
267 |
268 | To apply an optional (_weak_) validation to a property wrap its resolver in the `optional` helper function. This way the property's value will be validated only if present in the actual data. If the property is missing in the actual data it's never validated and considered as valid.
269 |
270 | ```js
271 | import { useSchema, optional } from 'reach-schema'
272 |
273 | useSchema(
274 | {
275 | firstName: optional((value) => value.length > 1),
276 | billingData: optional({
277 | address: (value) => value.includes('st.'),
278 | firstName: optional((value) => value.length > 1),
279 | }),
280 | },
281 | {
282 | billingData: {
283 | address: 'Invalid address',
284 | firstName: 'J',
285 | },
286 | },
287 | )
288 | ```
289 |
290 | ```json
291 | [
292 | {
293 | "pointer": ["billingData", "address"],
294 | "status": "invalid",
295 | "value": "Invalid address"
296 | },
297 | {
298 | "pointer": ["billingData", "firstName"],
299 | "status": "invalid",
300 | "value": "J"
301 | }
302 | ]
303 | ```
304 |
305 | ### Usage with TypeScript
306 |
307 | `useSchema` will infer the type of the given data automatically. However, to type guard the data itself it's useful to describe the data separately and provide to schema:
308 |
309 | ```ts
310 | interface UserDetails {
311 | firstName: string
312 | lastName: string
313 | age: number
314 | }
315 |
316 | useSchema(
317 | {
318 | firstName: (value) => value.length > 2,
319 | lastName: (value) => value.length > 2,
320 | age: (value) => value > 17,
321 | },
322 | {
323 | firstName: 'John',
324 | lastName: 'Maverick',
325 | age: 31,
326 | },
327 | )
328 | ```
329 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: ["@babel/preset-env"]
3 | };
4 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | roots: ['src'],
3 | transform: {
4 | '^.+\\.tsx?$': 'ts-jest',
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Group
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "reach-schema",
3 | "version": "0.2.0",
4 | "esnext": "src/index.ts",
5 | "main": "lib/cjs.js",
6 | "module": "lib/esm.js",
7 | "umd:main": "lib/umd.js",
8 | "typings": "lib/index.d.ts",
9 | "license": "MIT",
10 | "scripts": {
11 | "test": "jest",
12 | "clean": "rimraf ./ib",
13 | "build": "rollup -c rollup.config.js",
14 | "prepublishOnly": "yarn test && yarn build"
15 | },
16 | "devDependencies": {
17 | "@babel/preset-env": "^7.12.10",
18 | "@types/jest": "^26.0.19",
19 | "jest": "^26.6.3",
20 | "rimraf": "^2.6.3",
21 | "rollup": "^2.34.2",
22 | "rollup-plugin-babel": "^4.3.3",
23 | "rollup-plugin-node-resolve": "^5.2.0",
24 | "rollup-plugin-sourcemaps": "^0.6.3",
25 | "rollup-plugin-typescript2": "^0.29.0",
26 | "ts-jest": "^26.4.4",
27 | "typescript": "^4.1.3"
28 | },
29 | "files": [
30 | "README.md",
31 | "lib"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const nodeResolve = require('rollup-plugin-node-resolve')
3 | const sourceMaps = require('rollup-plugin-sourcemaps')
4 | const useBabel = require('rollup-plugin-babel')
5 | const typescript = require('rollup-plugin-typescript2')
6 | const babelConfig = require('./babel.config')
7 | const packageJson = require('./package.json')
8 |
9 | const input = path.resolve(__dirname, packageJson.esnext)
10 |
11 | // Plugins
12 | const resolve = (overrides = {}) => {
13 | return nodeResolve({
14 | extensions: ['.ts'],
15 | ...overrides,
16 | })
17 | }
18 | const babel = (overrides = {}) => {
19 | return useBabel({
20 | ...babelConfig,
21 | extensions: ['.ts'],
22 | ...overrides,
23 | })
24 | }
25 |
26 | // Build targets
27 | const buildCjs = {
28 | input,
29 | output: {
30 | file: path.resolve(__dirname, packageJson.main),
31 | format: 'cjs',
32 | exports: 'named',
33 | sourcemap: true,
34 | },
35 | plugins: [resolve(), typescript(), sourceMaps()],
36 | }
37 |
38 | const buildUmd = {
39 | input,
40 | output: {
41 | file: path.resolve(__dirname, packageJson['umd:main']),
42 | format: 'umd',
43 | name: 'ReachSchema',
44 | exports: 'named',
45 | sourcemap: true,
46 | },
47 | plugins: [resolve(), typescript(), babel(), sourceMaps()],
48 | }
49 |
50 | const buildEsm = {
51 | input,
52 | output: {
53 | file: path.resolve(__dirname, packageJson.module),
54 | format: 'esm',
55 | sourcemap: true,
56 | },
57 | plugins: [resolve(), typescript(), babel(), sourceMaps()],
58 | }
59 |
60 | module.exports = [buildCjs, buildUmd, buildEsm]
61 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { useSchema, optional } from './useSchema'
2 |
--------------------------------------------------------------------------------
/src/useSchema.input.test.ts:
--------------------------------------------------------------------------------
1 | import { useSchema } from './useSchema'
2 |
3 | /**
4 | * @todo Tests fail:
5 | * src/useSchema.ts:7:23 - error TS2315: Type 'ResolverOrNestedSchema' is not generic.
6 | *
7 | * And a bunch of similar "not generic" errors.
8 | */
9 |
10 | describe('useSchema: Input validation', () => {
11 | describe('given invalid schema', () => {
12 | const invalidSchemas = [2, 'schema', [], null, undefined]
13 |
14 | invalidSchemas.forEach((schema) => {
15 | const schemaType = Object.prototype.toString.call(schema)
16 |
17 | describe(`given ${schemaType} as schema`, () => {
18 | const validate = () => useSchema(schema as any, {})
19 |
20 | it('should throw error about invalid schema value', () => {
21 | expect(validate).toThrow(
22 | `Invalid schema: expected schema to be an Object, but got ${schemaType}.`,
23 | )
24 | })
25 | })
26 | })
27 | })
28 |
29 | describe('given invalid data', () => {
30 | const invalidData = [2, 'data', [], null, undefined]
31 |
32 | invalidData.forEach((data) => {
33 | const dataType = Object.prototype.toString.call(data)
34 |
35 | describe(`given ${dataType} as data`, () => {
36 | const validate = () => useSchema({}, data as any)
37 |
38 | it('should throw error about invalid data', () => {
39 | expect(validate).toThrow(
40 | `Invalid data: expected actual data to be an Object, but got ${dataType}`,
41 | )
42 | })
43 | })
44 | })
45 | })
46 |
47 | describe('given schema with invalid resolver', () => {
48 | const validate = () =>
49 | useSchema(
50 | {
51 | // @ts-ignore
52 | lastName: 5,
53 | },
54 | {},
55 | )
56 |
57 | it('should throw an error about invalid resolver', () => {
58 | expect(validate).toThrow(
59 | `Invalid schema at "lastName": expected resolver to be a function, but got number.`,
60 | )
61 | })
62 | })
63 |
64 | describe('given schema with invalid named rule', () => {
65 | const validate = () =>
66 | useSchema(
67 | {
68 | // @ts-ignore
69 | firstName: (value) => ({
70 | minLength: 2,
71 | }),
72 | },
73 | { firstName: 'John' },
74 | )
75 |
76 | it('should throw an error about invalid named resolver', () => {
77 | expect(validate).toThrow(
78 | 'Invalid schema at "firstName.minLength": expected named resolver to be a boolean, but got number.',
79 | )
80 | })
81 | })
82 | })
83 |
--------------------------------------------------------------------------------
/src/useSchema.test.ts:
--------------------------------------------------------------------------------
1 | import { Schema, useSchema, optional } from './useSchema'
2 |
3 | const withSchema = (schema: Schema) => (data: Data) => {
4 | return useSchema(schema, data)
5 | }
6 |
7 | describe('useSchema', () => {
8 | describe('given one-line resolver', () => {
9 | const withData = withSchema<{ firstName?: string; lastName?: string }>({
10 | firstName: (value) => value === 'john',
11 | })
12 |
13 | describe('and actual data matches', () => {
14 | const result = withData({
15 | firstName: 'john',
16 | })
17 |
18 | it('should not return errors', () => {
19 | expect(result).toHaveProperty('errors')
20 | expect(result.errors).toHaveLength(0)
21 | })
22 | })
23 |
24 | describe('and a key is missing in the actual data', () => {
25 | const result = withData({
26 | lastName: 'locke',
27 | })
28 |
29 | it('should return one error', () => {
30 | expect(result).toHaveProperty('errors')
31 | expect(result.errors).toHaveLength(1)
32 | })
33 |
34 | describe('the returned error', () => {
35 | it('should have a pointer to the property', () => {
36 | expect(result.errors[0]).toHaveProperty('pointer', ['firstName'])
37 | })
38 |
39 | it('should not have a value', () => {
40 | expect(result.errors[0]).not.toHaveProperty('value')
41 | })
42 |
43 | it('should have "missing" status', () => {
44 | expect(result.errors[0]).toHaveProperty('status', 'missing')
45 | })
46 |
47 | it('should not have a rule', () => {
48 | expect(result.errors[0]).not.toHaveProperty('rule')
49 | })
50 | })
51 | })
52 |
53 | describe('and actual data rejects', () => {
54 | const result = withData({
55 | firstName: 'martin',
56 | })
57 |
58 | it('should return one error', () => {
59 | expect(result).toHaveProperty('errors')
60 | expect(result.errors).toHaveLength(1)
61 | })
62 |
63 | describe('the returned error', () => {
64 | it('should have a pointer to the property', () => {
65 | expect(result.errors[0]).toHaveProperty('pointer', ['firstName'])
66 | })
67 |
68 | it('should have a value of the property', () => {
69 | expect(result.errors[0]).toHaveProperty('value', 'martin')
70 | })
71 |
72 | it('should have "invalid" status', () => {
73 | expect(result.errors[0]).toHaveProperty('status', 'invalid')
74 | })
75 |
76 | it('should not have a rule', () => {
77 | expect(result.errors[0]).not.toHaveProperty('rule')
78 | })
79 | })
80 | })
81 | })
82 |
83 | describe('given a resolver with 3 named rules', () => {
84 | const withData = withSchema<{ password: string }>({
85 | password: (value) => ({
86 | minLength: value.length > 5,
87 | capitalLetter: /[A-Z]/.test(value),
88 | oneNumber: /[0-9]/.test(value),
89 | }),
90 | })
91 |
92 | describe('and actual data matches all rules', () => {
93 | const result = withData({
94 | password: 'PassWord1',
95 | })
96 |
97 | it('should not return errors', () => {
98 | expect(result).toHaveProperty('errors')
99 | expect(result.errors).toHaveLength(0)
100 | })
101 | })
102 |
103 | describe('and actual data rejects 2 rules', () => {
104 | const result = withData({
105 | password: 'long value',
106 | })
107 |
108 | it('should return error for each rejected rule', () => {
109 | expect(result).toHaveProperty('errors')
110 | expect(result.errors).toHaveLength(2)
111 | })
112 |
113 | describe('each error', () => {
114 | it('should have a pointer to the property', () => {
115 | result.errors.forEach((error) => {
116 | expect(error).toHaveProperty('pointer', ['password'])
117 | })
118 | })
119 |
120 | it('should have "invalid" status', () => {
121 | result.errors.forEach((error) => {
122 | expect(error).toHaveProperty('status', 'invalid')
123 | })
124 | })
125 |
126 | it('should have a value of the property', () => {
127 | result.errors.forEach((error) => {
128 | expect(error).toHaveProperty('value', 'long value')
129 | })
130 | })
131 |
132 | it('should have a rejected rule name', () => {
133 | result.errors.forEach((error) => {
134 | expect(error.rule).toMatch(/capitalLetter|oneNumber/)
135 | })
136 | })
137 | })
138 | })
139 |
140 | describe('and actual data rejects all rules', () => {
141 | const result = withData({
142 | password: 'wrong',
143 | })
144 |
145 | it('should return error for all rejected rules', () => {
146 | expect(result).toHaveProperty('errors')
147 | expect(result.errors).toHaveLength(3)
148 | })
149 |
150 | describe('each error', () => {
151 | it('should have a pointer to the property', () => {
152 | result.errors.forEach((error) => {
153 | expect(error).toHaveProperty('pointer', ['password'])
154 | })
155 | })
156 |
157 | it('should have "invalid" status', () => {
158 | result.errors.forEach((error) => {
159 | expect(error).toHaveProperty('status', 'invalid')
160 | })
161 | })
162 |
163 | it('should have a value of the property', () => {
164 | result.errors.forEach((error) => {
165 | expect(error).toHaveProperty('value', 'wrong')
166 | })
167 | })
168 |
169 | it('should include rejected rule name', () => {
170 | result.errors.forEach((error) => {
171 | expect(error.rule).toMatch(/minLength|capitalLetter|oneNumber/)
172 | })
173 | })
174 | })
175 | })
176 | })
177 |
178 | describe('given schema with nested properties', () => {
179 | const withData = withSchema<{
180 | firstName?: string
181 | billingDetails?: {
182 | city?: string
183 | country?: string
184 | }
185 | }>({
186 | billingDetails: {
187 | country: (value) => ['uk', 'us'].includes(value),
188 | },
189 | })
190 |
191 | describe('and actual data matches', () => {
192 | const result = withData({
193 | billingDetails: {
194 | country: 'us',
195 | },
196 | })
197 |
198 | it('should not return errors', () => {
199 | expect(result).toHaveProperty('errors')
200 | expect(result.errors).toHaveLength(0)
201 | })
202 | })
203 |
204 | describe('and a property is missing in the actual data', () => {
205 | const result = withData({
206 | billingDetails: {
207 | city: 'London',
208 | },
209 | })
210 |
211 | it('should return one error', () => {
212 | expect(result).toHaveProperty('errors')
213 | expect(result.errors).toHaveLength(1)
214 | })
215 |
216 | describe('the returned error', () => {
217 | const [error] = result.errors
218 |
219 | it('should have a pointer to the property', () => {
220 | expect(error).toHaveProperty('pointer', ['billingDetails', 'country'])
221 | })
222 |
223 | it('should have "missing" status', () => {
224 | expect(error).toHaveProperty('status', 'missing')
225 | })
226 |
227 | it('should not have a value', () => {
228 | expect(error).not.toHaveProperty('value')
229 | })
230 |
231 | it('should not have a rule', () => {
232 | expect(error).not.toHaveProperty('rule')
233 | })
234 | })
235 | })
236 |
237 | describe('and a parent property is missing in the actual data', () => {
238 | const result = withData({
239 | firstName: 'john',
240 | })
241 |
242 | it('should return one error', () => {
243 | expect(result).toHaveProperty('errors')
244 | expect(result.errors).toHaveLength(1)
245 | })
246 |
247 | describe('the returned error', () => {
248 | const [error] = result.errors
249 |
250 | it('should have a pointer to the property', () => {
251 | expect(error).toHaveProperty('pointer', ['billingDetails', 'country'])
252 | })
253 |
254 | it('should have "missing" status', () => {
255 | expect(error).toHaveProperty('status', 'missing')
256 | })
257 |
258 | it('should not have a value', () => {
259 | expect(error).not.toHaveProperty('value')
260 | })
261 |
262 | it('should not have a rule', () => {
263 | expect(error).not.toHaveProperty('rule')
264 | })
265 | })
266 | })
267 |
268 | describe('and actual data is invalid', () => {
269 | const result = withData({
270 | billingDetails: {
271 | country: 'it',
272 | },
273 | })
274 |
275 | it('should return one error', () => {
276 | expect(result).toHaveProperty('errors')
277 | expect(result.errors).toHaveLength(1)
278 | })
279 |
280 | describe('the returned error', () => {
281 | const [error] = result.errors
282 |
283 | it('should have a pointer to the property', () => {
284 | expect(error).toHaveProperty('pointer', ['billingDetails', 'country'])
285 | })
286 |
287 | it('should have "invalid" status', () => {
288 | expect(error).toHaveProperty('status', 'invalid')
289 | })
290 |
291 | it('should have a value of the property', () => {
292 | expect(error).toHaveProperty('value', 'it')
293 | })
294 |
295 | it('should not have a rule', () => {
296 | expect(error).not.toHaveProperty('rule')
297 | })
298 | })
299 | })
300 | })
301 |
302 | describe('given schema with an optional property', () => {
303 | const withData = withSchema<{ firstName: string }>({
304 | firstName: optional((value) => value.length > 1),
305 | })
306 |
307 | describe('and an optional property is missing', () => {
308 | const result = withData({} as any)
309 |
310 | it('should not return errors', () => {
311 | expect(result).toHaveProperty('errors')
312 | expect(result.errors).toHaveLength(0)
313 | })
314 | })
315 |
316 | describe('and optional property resolves', () => {
317 | const result = withData({
318 | firstName: 'John',
319 | })
320 |
321 | it('should not return errors', () => {
322 | expect(result).toHaveProperty('errors')
323 | expect(result.errors).toHaveLength(0)
324 | })
325 | })
326 |
327 | describe('and optional property rejects', () => {
328 | const result = withData({
329 | firstName: 'J',
330 | })
331 |
332 | it('should return one error', () => {
333 | expect(result).toHaveProperty('errors')
334 | expect(result.errors).toHaveLength(1)
335 | })
336 |
337 | describe('the returned error', () => {
338 | const [error] = result.errors
339 |
340 | it('should have a pointer to the property', () => {
341 | expect(error).toHaveProperty('pointer', ['firstName'])
342 | })
343 |
344 | it('should have "invalid" status', () => {
345 | expect(error).toHaveProperty('status', 'invalid')
346 | })
347 |
348 | it('should have a value of the property', () => {
349 | expect(error).toHaveProperty('value', 'J')
350 | })
351 |
352 | it('should not have a rule', () => {
353 | expect(error).not.toHaveProperty('rule')
354 | })
355 | })
356 | })
357 | })
358 |
359 | describe('given schema with an optional property that includes required keys', () => {
360 | const withData = withSchema<{
361 | billingDetails: {
362 | firstName?: string
363 | country?: string
364 | }
365 | }>({
366 | billingDetails: optional({
367 | country: (value) => ['uk', 'us'].includes(value),
368 | }),
369 | })
370 |
371 | describe('and optional property is missing', () => {
372 | const result = withData({} as any)
373 |
374 | it('should not return errors', () => {
375 | expect(result).toHaveProperty('errors', [])
376 | })
377 | })
378 |
379 | describe('and optional property resolves', () => {
380 | const result = withData({
381 | billingDetails: {
382 | country: 'uk',
383 | },
384 | })
385 |
386 | it('should not return errors', () => {
387 | expect(result).toHaveProperty('errors', [])
388 | })
389 | })
390 |
391 | describe('and optional property is present, but its required child is missing', () => {
392 | const result = withData({
393 | billingDetails: {
394 | firstName: 'John',
395 | },
396 | })
397 |
398 | it('should return one error', () => {
399 | expect(result).toHaveProperty('errors')
400 | expect(result.errors).toHaveLength(1)
401 | })
402 |
403 | describe('the returned error', () => {
404 | const [error] = result.errors
405 |
406 | it('should have a pointer to the property', () => {
407 | expect(error).toHaveProperty('pointer', ['billingDetails', 'country'])
408 | })
409 |
410 | it('should have "missing" status', () => {
411 | expect(error).toHaveProperty('status', 'missing')
412 | })
413 |
414 | it('should not have a value', () => {
415 | expect(error).not.toHaveProperty('value')
416 | })
417 |
418 | it('should not have a rule', () => {
419 | expect(error).not.toHaveProperty('rule')
420 | })
421 | })
422 | })
423 |
424 | describe('and optional property pointer is present, but invalid', () => {
425 | const result = withData({
426 | billingDetails: {
427 | country: 'es',
428 | },
429 | })
430 |
431 | it('should return one error', () => {
432 | expect(result).toHaveProperty('errors')
433 | expect(result.errors).toHaveLength(1)
434 | })
435 |
436 | describe('the returned error', () => {
437 | const [error] = result.errors
438 |
439 | it('should have a pointer to the property', () => {
440 | expect(error).toHaveProperty('pointer', ['billingDetails', 'country'])
441 | })
442 |
443 | it('should have "invalid" status', () => {
444 | expect(error).toHaveProperty('status', 'invalid')
445 | })
446 |
447 | it('should have a value of the property', () => {
448 | expect(error).toHaveProperty('value', 'es')
449 | })
450 |
451 | it('should not have a rule', () => {
452 | expect(error).not.toHaveProperty('rule')
453 | })
454 | })
455 | })
456 | })
457 | })
458 |
--------------------------------------------------------------------------------
/src/useSchema.ts:
--------------------------------------------------------------------------------
1 | import invariant from './utils/invariant'
2 | import getNestedProperty from './utils/getNestedProperty'
3 |
4 | type UnknownData = Record
5 |
6 | export type Schema = {
7 | [K in keyof DataType]?: ResolverOrNestedSchema
8 | }
9 |
10 | type ResolverPredicate = (
11 | value: ValueType,
12 | key: string,
13 | pointer: Pointer,
14 | data: DataType,
15 | schema: Schema,
16 | ) => boolean
17 |
18 | type ConditionalResolver = [
19 | ResolverPredicate,
20 | Resolver,
21 | ]
22 |
23 | type ResolverOrNestedSchema =
24 | | Schema
25 | | ConditionalResolver
26 | | Resolver
27 |
28 | type Resolver = (
29 | value: ValueType,
30 | pointer: Pointer,
31 | ) => Record | boolean
32 |
33 | type Pointer = string[]
34 |
35 | export enum ErrorStatus {
36 | missing = 'missing',
37 | invalid = 'invalid',
38 | }
39 |
40 | export interface ValidationError {
41 | pointer: Pointer
42 | status: ErrorStatus
43 | value?: any
44 | rule?: string
45 | }
46 |
47 | /**
48 | * Validates data against the given schema.
49 | */
50 | export const useSchema = = any>(
51 | schema: Schema,
52 | data: Data,
53 | ) => {
54 | const schemaType = Object.prototype.toString.call(schema)
55 | invariant(
56 | schemaType.includes('Object'),
57 | `Invalid schema: expected schema to be an Object, but got ${schemaType}.`,
58 | )
59 |
60 | const dataType = Object.prototype.toString.call(data)
61 | invariant(
62 | dataType.includes('Object'),
63 | `Invalid data: expected actual data to be an Object, but got ${dataType}.`,
64 | )
65 |
66 | return {
67 | errors: getErrorsBySchema(schema, data, []),
68 | }
69 | }
70 |
71 | /**
72 | * Recursively produces a list of validation errors
73 | * based on the given schema and data.
74 | */
75 | function getErrorsBySchema(
76 | schema: Schema,
77 | data: Data,
78 | pointer: Pointer,
79 | ) {
80 | return Object.keys(schema).reduce((errors, key) => {
81 | const currentPointer = pointer.concat(key)
82 |
83 | // Handle values that are expected by schema, but not defined in the data
84 | if (data == null) {
85 | return errors.concat(createValidationError(currentPointer, data))
86 | }
87 |
88 | const value = data[key]
89 | const resolverPayload: ResolverOrNestedSchema =
90 | schema[key]
91 |
92 | // When a resolver function returns an Array,
93 | // treat the first argument as a predicate that determines if validation is necessary.
94 | // Threat the second argument as the resolver function.
95 | /**
96 | * @todo Fix type annotations.
97 | */
98 | const [predicate, resolver] = Array.isArray(resolverPayload)
99 | ? resolverPayload
100 | : [() => true, resolverPayload]
101 |
102 | if (!predicate(value, key, currentPointer, data, schema)) {
103 | return errors
104 | }
105 |
106 | if (typeof resolver === 'object') {
107 | // Recursive case.
108 | // An Object resolver value is treated as a nested validation schema.
109 | return errors.concat(
110 | getErrorsBySchema(resolver as Schema, value, currentPointer),
111 | )
112 | }
113 |
114 | invariant(
115 | typeof resolver === 'function',
116 | `Invalid schema at "${currentPointer.join(
117 | '.',
118 | )}": expected resolver to be a function, but got ${typeof resolver}.`,
119 | )
120 |
121 | const resolverVerdict = resolver(value, currentPointer)
122 |
123 | // If resolver returns an Object, treat it as named rules map
124 | if (typeof resolverVerdict === 'object') {
125 | const namedErrors = Object.keys(resolverVerdict)
126 | // Note that named resolvers keep a boolean value,
127 | // not the verdict function. Therefore, no calls.
128 | .filter((ruleName) => {
129 | const ruleVerdict = resolverVerdict[ruleName]
130 |
131 | invariant(
132 | typeof ruleVerdict === 'boolean',
133 | `Invalid schema at "${currentPointer
134 | .concat(ruleName)
135 | .join(
136 | '.',
137 | )}": expected named resolver to be a boolean, but got ${typeof ruleVerdict}.`,
138 | )
139 |
140 | return !ruleVerdict
141 | })
142 | .reduce((acc, rule) => {
143 | return acc.concat(createValidationError(currentPointer, value, rule))
144 | }, [])
145 | return errors.concat(namedErrors)
146 | }
147 |
148 | // Otherwise resolver is a function that returns a boolean verdict
149 | return resolverVerdict
150 | ? errors
151 | : errors.concat(createValidationError(currentPointer, value))
152 | }, [])
153 | }
154 |
155 | function createValidationError(
156 | pointer: Pointer,
157 | value?: any,
158 | rule?: string,
159 | ): ValidationError {
160 | const status = !!value ? ErrorStatus.invalid : ErrorStatus.missing
161 |
162 | const error: ValidationError = {
163 | pointer,
164 | status,
165 | }
166 |
167 | if (value) {
168 | error.value = value
169 | }
170 |
171 | if (rule) {
172 | error.rule = rule
173 | }
174 |
175 | return error
176 | }
177 |
178 | /**
179 | * High-order resolver that applies a given resolver function
180 | * only when the associated property is present in the actual data.
181 | */
182 | export const optional = (
183 | resolver: Resolver,
184 | ): ConditionalResolver => {
185 | return [
186 | (value, key, pointer, data, schema) => {
187 | return getNestedProperty(pointer, data) != null
188 | },
189 | resolver,
190 | ]
191 | }
192 |
--------------------------------------------------------------------------------
/src/utils/getNestedProperty.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Returns a nested property value based on the given property path.
3 | */
4 | export default function getNestedProperty (
5 | path: string[],
6 | obj: Input,
7 | ): Output {
8 | let value = obj as any
9 | let index = 0
10 |
11 | while (index < path.length) {
12 | if (value === null) {
13 | return
14 | }
15 |
16 | value = value[path[index]]
17 | index += 1
18 | }
19 |
20 | return value
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/invariant.ts:
--------------------------------------------------------------------------------
1 | export default function invariant(truthy: boolean, message: string): void {
2 | if (!truthy) {
3 | throw new Error(message)
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": true,
3 | "compilerOptions": {
4 | "esModuleInterop": true,
5 | "lib": ["esnext", "dom"],
6 | "outDir": "types",
7 | "declaration": true
8 | },
9 | "include": ["src/**/*"],
10 | "exclude": ["node_modules", "**/*.test.ts"]
11 | }
12 |
--------------------------------------------------------------------------------