├── .nvmrc ├── .husky ├── .gitignore └── pre-commit ├── docs ├── static │ ├── .nojekyll │ └── img │ │ ├── favicon.png │ │ ├── fast.svg │ │ ├── functional.svg │ │ └── winners.svg ├── tsconfig.json ├── babel.config.js ├── docs │ ├── setup.md │ ├── validators │ │ ├── number.md │ │ ├── boolean.md │ │ ├── string.md │ │ ├── oneOf.md │ │ ├── unknown.md │ │ ├── array.md │ │ ├── date.md │ │ └── object.md │ ├── modifiers │ │ ├── minStringLength.md │ │ ├── nil.md │ │ ├── nullable.md │ │ ├── optional.md │ │ ├── minArrayLength.md │ │ └── refinements.md │ ├── utilities │ │ ├── typeOf.md │ │ └── pipe.md │ ├── validate.md │ └── introduction.md ├── .gitignore ├── src │ ├── pages │ │ ├── styles.module.css │ │ └── index.tsx │ └── css │ │ └── custom.css ├── README.md ├── sidebars.js ├── package.json └── docusaurus.config.js ├── jest-setup.ts ├── .gitignore ├── .npmignore ├── .github ├── semantic.yml ├── FUNDING.yml └── workflows │ ├── stats-pr.yml │ ├── coverage.yml │ ├── stats-workflow.yml │ └── codeql-analysis.yml ├── tsconfig.eslint.json ├── .vscode ├── extensions.json └── settings.json ├── .eslintignore ├── .prettierrc ├── src ├── schema.ts ├── validators │ ├── unknown.ts │ ├── boolean.ts │ ├── __validate.ts │ ├── string.ts │ ├── date.ts │ ├── number.ts │ ├── tuple.ts │ ├── oneOf.ts │ ├── array.ts │ └── object.ts ├── utils │ ├── dateUtils.ts │ ├── either.ts │ └── pipe.ts ├── modifiers │ ├── nil.ts │ ├── nullable.ts │ ├── optional.ts │ ├── minStringLength.ts │ └── minArrayLength.ts ├── errors.ts ├── stringify.ts ├── index.ts ├── types.ts └── refine.ts ├── jest-setup-after-env.ts ├── __tests__ ├── types.test-d.ts ├── bench.js ├── types.spec.ts ├── exports.spec.ts ├── prototypePollution.spec.ts ├── refinements.test-d.ts ├── index.test-d.ts ├── refinements.spec.ts ├── errors.spec.ts ├── property-tests.spec.ts └── unit-tests.spec.ts ├── .versionrc ├── jest.config.js ├── .eslintrc ├── tsconfig.json ├── LICENSE ├── rollup.config.js ├── .all-contributorsrc ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 12 2 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /docs/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jest-setup.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage/ 4 | .clinic/ 5 | .vscode/snipsnap.code-snippets 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /src 3 | 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | # Always validate the PR title, and ignore the commits 2 | titleOnly: true 3 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src", "__tests__"] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typeofweb-org/schema/HEAD/docs/static/img/favicon.png -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/docusaurus/tsconfig.json", 3 | "include": ["src/"] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | jest-*.ts 2 | docs 3 | jest.config.js 4 | rollup.config.js 5 | dist 6 | coverage 7 | __tests__/bench.js 8 | -------------------------------------------------------------------------------- /docs/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /src/schema.ts: -------------------------------------------------------------------------------- 1 | import type { SomeSchema } from './types'; 2 | 3 | export const isSchema = (val: any): val is SomeSchema => { 4 | return typeof val === 'object' && val !== null && '__validate' in val && 'toString' in val; 5 | }; 6 | -------------------------------------------------------------------------------- /docs/docs/setup.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: setup 3 | title: Setup 4 | --- 5 | 6 | ## yarn 7 | 8 | Install the package with yarn: 9 | 10 | `yarn add @typeofweb/schema` 11 | 12 | ## npm 13 | 14 | Install the package with npm: 15 | 16 | `npm install @typeofweb/schema` 17 | -------------------------------------------------------------------------------- /src/validators/unknown.ts: -------------------------------------------------------------------------------- 1 | import { refine } from '../refine'; 2 | import { typeToPrint } from '../stringify'; 3 | 4 | export const unknown = refine( 5 | (value, t) => { 6 | return t.nextValid(value); 7 | }, 8 | () => typeToPrint('unknown'), 9 | ); 10 | -------------------------------------------------------------------------------- /src/utils/dateUtils.ts: -------------------------------------------------------------------------------- 1 | export const isDate = (d: unknown): d is Date => 2 | Object.prototype.toString.call(d) === '[object Date]'; 3 | 4 | const simplifiedISODateStringRegex = /^[+-]?\d{4}/; 5 | export const isISODateString = (value: string) => simplifiedISODateStringRegex.test(value); 6 | -------------------------------------------------------------------------------- /docs/docs/validators/number.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: number 3 | title: number 4 | --- 5 | 6 | Creates a schema that matches numbers: 7 | 8 | ```ts 9 | const numberSchema = number(); 10 | const numberValidator = validate(numberSchema); 11 | 12 | // Returns 3.14 13 | const pi = numberValidator(3.14); 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/docs/validators/boolean.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: boolean 3 | title: boolean 4 | --- 5 | 6 | Creates a schema that matches booleans. 7 | 8 | ```ts 9 | const booleanSchema = boolean(); 10 | const booleanValidator = validate(booleanSchema); 11 | 12 | // Returns true 13 | const loading = booleanValidator(true); 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/docs/validators/string.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: string 3 | title: string 4 | --- 5 | 6 | Creates a schema that matches strings. 7 | 8 | ```ts 9 | const stringSchema = string(); 10 | const stringValidator = validate(stringSchema); 11 | 12 | // Returns 'Michael' 13 | const michael = stringValidator('Michael'); 14 | ``` 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "typescript.tsdk": "node_modules/typescript/lib", 5 | "eslint.packageManager": "yarn", 6 | "eslint.run": "onSave", 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modifiers/nil.ts: -------------------------------------------------------------------------------- 1 | import { refine } from '../refine'; 2 | 3 | export const nil = refine( 4 | (value, t) => 5 | value === null || value === undefined 6 | ? t.right(value) 7 | : t.nextNotValid({ 8 | expected: 'nil', 9 | got: value, 10 | }), 11 | () => `undefined | null`, 12 | ); 13 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env.local 14 | .env.development.local 15 | .env.test.local 16 | .env.production.local 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | -------------------------------------------------------------------------------- /jest-setup-after-env.ts: -------------------------------------------------------------------------------- 1 | beforeAll(() => { 2 | process.on('unhandledRejection', (err) => fail(err)); 3 | process.on('uncaughtException', (err) => fail(err)); 4 | }); 5 | afterAll(() => { 6 | process.removeListener('unhandledRejection', (err) => fail(err)); 7 | process.removeListener('uncaughtException', (err) => fail(err)); 8 | }); 9 | export {}; 10 | -------------------------------------------------------------------------------- /src/modifiers/nullable.ts: -------------------------------------------------------------------------------- 1 | import { refine } from '../refine'; 2 | import { typeToPrint } from '../stringify'; 3 | 4 | export const nullable = refine( 5 | (value, t) => 6 | value === null 7 | ? t.right(null) 8 | : t.nextNotValid({ 9 | expected: 'nullable', 10 | got: value, 11 | }), 12 | () => typeToPrint('null'), 13 | ); 14 | -------------------------------------------------------------------------------- /src/modifiers/optional.ts: -------------------------------------------------------------------------------- 1 | import { refine } from '../refine'; 2 | import { typeToPrint } from '../stringify'; 3 | 4 | export const optional = refine( 5 | (value, t) => 6 | value === undefined 7 | ? t.right(undefined) 8 | : t.nextNotValid({ 9 | expected: 'optional', 10 | got: value, 11 | }), 12 | () => typeToPrint('undefined'), 13 | ); 14 | -------------------------------------------------------------------------------- /src/modifiers/minStringLength.ts: -------------------------------------------------------------------------------- 1 | import { refine } from '../refine'; 2 | 3 | export const minStringLength = (minLength: L) => 4 | refine((value: string, t) => 5 | value.length >= minLength 6 | ? t.nextValid(value) 7 | : t.left({ 8 | expected: 'minStringLength', 9 | got: value, 10 | args: [minLength], 11 | }), 12 | ); 13 | -------------------------------------------------------------------------------- /src/validators/boolean.ts: -------------------------------------------------------------------------------- 1 | import { refine } from '../refine'; 2 | import { typeToPrint } from '../stringify'; 3 | 4 | export const boolean = refine( 5 | (value, t) => { 6 | if (typeof value !== 'boolean') { 7 | return t.left({ 8 | expected: 'boolean', 9 | got: value, 10 | }); 11 | } 12 | return t.nextValid(value); 13 | }, 14 | () => typeToPrint('boolean'), 15 | ); 16 | -------------------------------------------------------------------------------- /docs/docs/validators/oneOf.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: oneOf 3 | title: oneOf 4 | --- 5 | 6 | Creates a schema that matches only specified values. 7 | 8 | ```ts 9 | const fishSchema = oneOf(['trout', 'catfish'])(); 10 | const fishValidator = validate(fishSchema); 11 | 12 | // Returns 'trout' 13 | const trout = fishValidator('trout'); 14 | 15 | // Throws ValidationError 16 | const salmon = fishValidator('salmon'); 17 | ``` 18 | -------------------------------------------------------------------------------- /docs/docs/modifiers/minStringLength.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: minStringLength 3 | title: minStringLength 4 | --- 5 | 6 | The `minStringLength` modifier is used to contraint length of `string` or `array` values. 7 | 8 | ```ts 9 | const atLeastTwoCharsValidator = validate(minStringLength(2)(string())); 10 | 11 | // Returns 'ok' 12 | const ok = atLeastTwoCharsValidator('ok'); 13 | 14 | // Throws ValidationError 15 | const notOk = atLeastTwoCharsValidator('?'); 16 | ``` 17 | -------------------------------------------------------------------------------- /__tests__/types.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd'; 2 | 3 | import { object, number, pipe, string, optional } from '../src'; 4 | 5 | import type { TypeOf } from '../src/types'; 6 | 7 | /** 8 | * TypeOf 9 | */ 10 | const exampleObjectSchema = object({ 11 | a: number(), 12 | b: pipe(string, optional), 13 | })(); 14 | 15 | declare const objectType: TypeOf; 16 | 17 | expectType<{ 18 | readonly a: number; 19 | readonly b?: string; 20 | }>(objectType); 21 | -------------------------------------------------------------------------------- /src/utils/either.ts: -------------------------------------------------------------------------------- 1 | import type { Either, Next, ErrorData } from '../types'; 2 | 3 | export const left = (value: L): Either => ({ _t: 'left', value }); 4 | export const right = (value: R): Either => ({ _t: 'right', value }); 5 | export const nextValid = (value: Output): Next => ({ _t: 'nextValid', value }); 6 | export const nextNotValid = >(value: L): Next => ({ 7 | _t: 'nextNotValid', 8 | value, 9 | }); 10 | -------------------------------------------------------------------------------- /src/validators/__validate.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../errors'; 2 | 3 | import type { SomeSchema, TypeOf } from '../types'; 4 | 5 | export const validate = 6 | >(schema: S) => 7 | (value: unknown) => { 8 | const result = schema.__validate(value); 9 | 10 | if (result._t === 'right' || result._t === 'nextValid') { 11 | return result.value as TypeOf; 12 | } else { 13 | throw new ValidationError(schema, value, result); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /.versionrc: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "type": "feat", 5 | "section": "Features" 6 | }, 7 | { 8 | "type": "fix", 9 | "section": "Bug Fixes" 10 | }, 11 | { 12 | "type": "refactor", 13 | "section": "Refactoring" 14 | }, 15 | { 16 | "type": "chore", 17 | "section": "Chores" 18 | }, 19 | { 20 | "type": "docs", 21 | "section": "Docs" 22 | }, 23 | { 24 | "type": "test", 25 | "section": "Tests" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /docs/docs/validators/unknown.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: unknown 3 | title: unknown 4 | --- 5 | 6 | Creates a schema that matches anything and everything including undefined, null or no value at all. 7 | 8 | ```ts 9 | const whateverSchema = unknown(); 10 | const whateverValidator = validate(whateverSchema); 11 | 12 | // It's fine 13 | whateverValidator(); 14 | whateverValidator(123123); 15 | whateverValidator(null); 16 | whateverValidator({ foo: 'bar' }); 17 | whateverValidator([null, 3, 'hello']); 18 | // The output type is unknown 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/docs/modifiers/nil.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: nil 3 | title: nil 4 | --- 5 | 6 | If you use the `nil` modifier, given schema will be extended to also match `null` and `undefined` values, and its output type will be `null | undefined | T`. 7 | 8 | ```ts 9 | const nilRoleSchema = nil(oneOf(['User', 'Admin'])); 10 | const nilRoleValidator = validate(nilRoleSchema); 11 | 12 | // Returns 'User', the output type is undefined | null | 'User' | 'Admin' 13 | const role = nilRoleValidator('User'); 14 | 15 | // Both return undefined 16 | nilRoleValidator(); 17 | nilRoleValidator(undefined); 18 | // Returns null 19 | nilRoleValidator(null); 20 | ``` 21 | -------------------------------------------------------------------------------- /src/modifiers/minArrayLength.ts: -------------------------------------------------------------------------------- 1 | import { refine } from '../refine'; 2 | 3 | import type { TupleOf } from '@typeofweb/utils'; 4 | 5 | export const minArrayLength = (minLength: L) => 6 | refine((value: readonly unknown[], t) => { 7 | return value.length >= minLength 8 | ? t.nextValid( 9 | value as readonly [ 10 | ...TupleOf, 11 | ...(readonly typeof value[number][]) 12 | ], 13 | ) 14 | : t.left({ 15 | expected: 'minArrayLength', 16 | got: value, 17 | args: [minLength], 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /docs/src/pages/styles.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | padding: 4rem 0; 10 | text-align: center; 11 | position: relative; 12 | overflow: hidden; 13 | } 14 | 15 | .buttons { 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | } 20 | 21 | .features { 22 | display: flex; 23 | align-items: center; 24 | padding: 2rem 0; 25 | width: 100%; 26 | } 27 | 28 | .featureImage { 29 | height: 200px; 30 | width: 200px; 31 | } 32 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | roots: [''], 3 | moduleFileExtensions: ['js', 'ts', 'json'], 4 | testPathIgnorePatterns: ['[/\\\\](node_modules)[/\\\\]'], 5 | transformIgnorePatterns: ['[/\\\\]node_modules[/\\\\].+\\.(ts|tsx)$'], 6 | transform: { 7 | // '^.+\\.tsx?$': 'ts-jest', 8 | }, 9 | testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], 10 | setupFiles: ['./jest-setup.ts'], 11 | setupFilesAfterEnv: ['./jest-setup-after-env.ts'], 12 | extensionsToTreatAsEsm: ['.ts'], 13 | preset: 'ts-jest/presets/default-esm', 14 | globals: { 15 | 'ts-jest': { 16 | useESM: true, 17 | }, 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /docs/docs/validators/array.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: array 3 | title: array 4 | --- 5 | 6 | Creates a schema that matches arrays containing values specified by the schemas passed as parameters: 7 | 8 | ```ts 9 | const musicGenresSchema = array(string())(); 10 | const musicGenresValidator = validate(musicGenresSchema); 11 | 12 | // Returns ['classical', 'lofi', 'pop'] 13 | const musicGenres = musicGenresValidator(['classical', 'lofi', 'pop']); 14 | ``` 15 | 16 | ```ts 17 | const primitiveValidator = validate(array(string(), number(), boolean())()); 18 | // Returns [false, 'string', 123, 42, ':)'] 19 | primitiveValidator([false, 'string', 123, 42, ':)']); 20 | ``` 21 | -------------------------------------------------------------------------------- /docs/docs/modifiers/nullable.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: nullable 3 | title: nullable 4 | --- 5 | 6 | If you use the `nullable` modifier, given schema will be extended to also match `null` values, and its output type will be `null | T`. 7 | 8 | ```ts 9 | const nullableRoleSchema = nullable(oneOf(['User', 'Admin'])); 10 | const nullableRoleValidator = validate(nullableRoleSchema); 11 | 12 | // Returns 'User', the output type is null | 'User' | 'Admin' 13 | const role = nullableRoleValidator('User'); 14 | 15 | // Returns null 16 | nullableRoleValidator(null); 17 | 18 | // Throws ValidationError 19 | nullableRoleValidator(); 20 | nullableRoleValidator(undefined); 21 | ``` 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [typeofweb] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: typeofweb 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /src/validators/string.ts: -------------------------------------------------------------------------------- 1 | import { refine } from '../refine'; 2 | import { typeToPrint } from '../stringify'; 3 | import { isDate } from '../utils/dateUtils'; 4 | 5 | export const string = refine( 6 | (value, t) => { 7 | const parsedValue = parseString(value); 8 | if (typeof parsedValue !== 'string') { 9 | return t.left({ 10 | expected: 'string', 11 | got: value, 12 | }); 13 | } 14 | return t.nextValid(parsedValue); 15 | }, 16 | () => typeToPrint('string'), 17 | ); 18 | 19 | function parseString(value: unknown) { 20 | if (isDate(value)) { 21 | return value.toISOString(); 22 | } 23 | return value; 24 | } 25 | -------------------------------------------------------------------------------- /docs/docs/modifiers/optional.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: optional 3 | title: optional 4 | --- 5 | 6 | If you use the `optional` modifier, given schema will be extended to also match `undefined` values, and its output type will be `undefined | T`. 7 | 8 | ```ts 9 | const optionalRoleSchema = optional(oneOf(['User', 'Admin'])); 10 | const optionalRoleValidator = validate(optionalRoleSchema); 11 | 12 | // Returns 'User', the output type is undefined | 'User' | 'Admin' 13 | const role = optionalRoleValidator('User'); 14 | 15 | // Both return undefined 16 | optionalRoleValidator(); 17 | optionalRoleValidator(undefined); 18 | 19 | // Throws validation error 20 | optionalRoleValidator(null); 21 | ``` 22 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parserOptions": { 4 | "project": "tsconfig.eslint.json" 5 | }, 6 | "plugins": ["@typeofweb/eslint-plugin"], 7 | "extends": ["plugin:@typeofweb/recommended"], 8 | "rules": { 9 | "@typescript-eslint/consistent-type-assertions": "off", 10 | "eslint-comments/require-description": "off", 11 | "@typescript-eslint/no-non-null-assertion": "off", 12 | "@typescript-eslint/no-non-null-asserted-optional-chain": "off" 13 | }, 14 | "overrides": [ 15 | { 16 | "files": ["src/validators/**.ts", "src/modifiers/**.ts", "src/errors.ts"], 17 | "rules": { 18 | "functional/no-this-expression": 0 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /__tests__/bench.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | exports.__esModule = true; 3 | const { object, minStringLength, string, number, validate } = require('../dist/index.common.js'); 4 | function run(i) { 5 | const schema = object({ 6 | name: minStringLength(4)(string()), 7 | email: string(), 8 | firstName: minStringLength(0)(string()), 9 | phone: string(), 10 | // age: number(), 11 | })(); 12 | const validator = validate(schema); 13 | const obj = { 14 | name: 'John Doe', 15 | email: 'john.doe@company.space', 16 | firstName: 'John', 17 | phone: '123-4567', 18 | // age: i, 19 | }; 20 | return validator(obj); 21 | } 22 | for (let i = 0; i < 4000000; ++i) { 23 | run(i); 24 | } 25 | -------------------------------------------------------------------------------- /src/validators/date.ts: -------------------------------------------------------------------------------- 1 | import { refine } from '../refine'; 2 | import { typeToPrint } from '../stringify'; 3 | import { isDate, isISODateString } from '../utils/dateUtils'; 4 | 5 | export const date = refine( 6 | (value, t) => { 7 | const parsedValue = parseDate(value); 8 | if (!isDate(parsedValue) || Number.isNaN(Number(parsedValue))) { 9 | return t.left({ 10 | expected: 'date', 11 | got: value, 12 | }); 13 | } 14 | return t.nextValid(parsedValue); 15 | }, 16 | () => typeToPrint('Date'), 17 | ); 18 | 19 | function parseDate(value: unknown) { 20 | if (typeof value === 'string' && isISODateString(value)) { 21 | return new Date(value); 22 | } 23 | return value; 24 | } 25 | -------------------------------------------------------------------------------- /docs/docs/validators/date.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: date 3 | title: date 4 | --- 5 | 6 | Creates a schema that matches JavaScript `Date` objects. 7 | 8 | ```ts 9 | const dateSchema = date(); 10 | const dateValidator = validate(dateSchema); 11 | 12 | // Returns Date Tue Jan 12 2021 22:00:20 GMT+0100 (Central European Standard Time) 13 | const annieBirthday = dateValidator( 14 | new Date('Tue Jan 12 2021 22:00:20 GMT+0100 (Central European Standard Time)'), 15 | ); 16 | ``` 17 | 18 | ## Other date formats 19 | 20 | By default, other date formats or timestamps are not supported. However, you're encouraged to create a custom refinement for this purpose. You can find a working example in [Custom modifiers (refinements)](modifiers/refinements.md). 21 | -------------------------------------------------------------------------------- /src/validators/number.ts: -------------------------------------------------------------------------------- 1 | import { refine } from '../refine'; 2 | import { typeToPrint } from '../stringify'; 3 | 4 | export const number = refine((value, t) => { 5 | const parsedValue = parseNumber(value); 6 | if (typeof parsedValue !== 'number' || Number.isNaN(parsedValue)) { 7 | return t.left({ 8 | expected: 'number', 9 | got: value, 10 | }); 11 | } 12 | return t.nextValid(parsedValue); 13 | }, numberToString); 14 | 15 | function parseNumber(value: unknown) { 16 | if (typeof value === 'string') { 17 | if (value.trim() === '') { 18 | return value; 19 | } 20 | return Number(value); 21 | } 22 | return value; 23 | } 24 | 25 | function numberToString() { 26 | return typeToPrint('number'); 27 | } 28 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { schemaToString } from './stringify'; 2 | 3 | import type { Either, SomeSchema, Next, ErrorData } from './types'; 4 | 5 | export class ValidationError extends Error { 6 | public readonly details: ErrorData; 7 | 8 | constructor(schema: SomeSchema, value: any, context: Either | Next) { 9 | const expected = schemaToString(schema); 10 | const got = typeof value === 'function' ? String(value) : JSON.stringify(value); 11 | 12 | super(`Invalid type! Expected ${expected} but got ${got}!`); 13 | 14 | this.details = context.value; 15 | 16 | this.name = 'ValidationError'; 17 | Error.captureStackTrace(this); 18 | 19 | Object.setPrototypeOf(this, ValidationError.prototype); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /docs/docs/utilities/typeOf.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: typeOf 3 | title: TypeOf 4 | --- 5 | 6 | Types can be inferred based on validators: 7 | 8 | ```ts 9 | import { nonEmpty, minStringLength, nil, λ, object, string } from '@typeofweb/schema'; 10 | import type { TypeOf } from '@typeofweb/schema'; 11 | 12 | const blogSchema = object({ 13 | title: λ(string, minStringLength(10)), 14 | description: λ(string, nil), 15 | href: λ(string, nil, nonEmpty), 16 | rssUrl: λ(string, nil, nonEmpty), 17 | })(); 18 | 19 | type Blog = TypeOf; 20 | // type Blog = { 21 | // readonly title: string; 22 | // readonly description?: string | null | undefined; 23 | // readonly href?: string | null | undefined; 24 | // readonly rssUrl?: string | null | undefined; 25 | // } 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/docs/utilities/pipe.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: pipe 3 | title: pipe (λ) 4 | --- 5 | 6 | Schemas may become hard to read as nesting grows, which may be solved by function composition in the form of the provided `pipe` utility function. 7 | 8 | ```ts 9 | import { nonEmpty, nullable, pipe, string } from '@typeofweb/schema'; 10 | 11 | pipe(string, nullable, nonEmpty); 12 | // is equivalent to 13 | nonEmpty(nullable(string())); 14 | ``` 15 | 16 | `λ` is an alias for `pipe`: 17 | 18 | ```ts 19 | import { nonEmpty, minStringLength, nil, λ, object, string } from '@typeofweb/schema'; 20 | 21 | const blogSchema = object({ 22 | title: λ(string, minStringLength(10)), 23 | description: λ(string, nil), 24 | href: λ(string, nil, nonEmpty), 25 | rssUrl: λ(string, nil, nonEmpty), 26 | })(); 27 | ``` 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node12/tsconfig.json", 3 | "compilerOptions": { 4 | "module": "ES2020", 5 | "moduleResolution": "node", 6 | "isolatedModules": true, 7 | "strict": true, 8 | "alwaysStrict": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": false, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "noUncheckedIndexedAccess": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "resolveJsonModule": true, 19 | "allowUnreachableCode": true, 20 | "stripInternal": true, 21 | "declaration": true, 22 | "declarationMap": true, 23 | "sourceMap": true 24 | }, 25 | "include": ["src", "__tests__"] 26 | } 27 | -------------------------------------------------------------------------------- /src/stringify.ts: -------------------------------------------------------------------------------- 1 | import type { SomeSchema } from './types'; 2 | 3 | export const schemaToString = (schema: SomeSchema): string => { 4 | return schema.toString(); 5 | }; 6 | 7 | export const typeToPrint = (str: string) => str; 8 | export const objectToPrint = (str: string) => '{' + str + '}'; 9 | export const quote = (str: string) => (/\s/.test(str) ? `"${str}"` : str); 10 | 11 | export const unionToPrint = (arr: readonly string[]): string => { 12 | const str = arr.join(' | '); 13 | 14 | if (arr.length > 1) { 15 | return `(${str})`; 16 | } 17 | return str; 18 | }; 19 | 20 | declare global { 21 | interface Array { 22 | includes(searchElement: unknown, fromIndex?: number): searchElement is T; 23 | } 24 | 25 | interface ReadonlyArray { 26 | includes(searchElement: unknown, fromIndex?: number): searchElement is T; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern static website generator. 4 | 5 | ## Setup 6 | 7 | ```console 8 | yarn install 9 | ``` 10 | 11 | ## Local Development 12 | 13 | ```console 14 | yarn start 15 | ``` 16 | 17 | This command starts a local development server and open up a browser window. Most changes are reflected live without having to restart the server. 18 | 19 | ## Build 20 | 21 | ```console 22 | yarn build 23 | ``` 24 | 25 | This command generates static content into the `build` directory and can be served using any static contents hosting service. 26 | 27 | ## Deployment 28 | 29 | ```console 30 | GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /docs/sidebars.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | sidebar: [ 3 | { 4 | 'Getting started': ['introduction', 'setup'], 5 | 'API Reference': [ 6 | 'validate', 7 | { 8 | Validators: [ 9 | 'validators/oneOf', 10 | 'validators/string', 11 | 'validators/number', 12 | 'validators/boolean', 13 | 'validators/date', 14 | 'validators/object', 15 | 'validators/array', 16 | 'validators/unknown', 17 | ], 18 | Modifiers: [ 19 | 'modifiers/nullable', 20 | 'modifiers/optional', 21 | 'modifiers/nil', 22 | 'modifiers/minArrayLength', 23 | 'modifiers/minStringLength', 24 | 'modifiers/refinements', 25 | ], 26 | Utilities: ['utilities/pipe', 'utilities/typeOf'], 27 | }, 28 | ], 29 | }, 30 | ], 31 | }; 32 | -------------------------------------------------------------------------------- /__tests__/types.spec.ts: -------------------------------------------------------------------------------- 1 | import Path, { join } from 'path'; 2 | import Url from 'url'; 3 | 4 | import { identity } from 'ramda'; 5 | import TsdModule from 'tsd'; 6 | 7 | // @ts-expect-error @todo 8 | const tsd = (TsdModule as { readonly default: typeof TsdModule }).default; 9 | 10 | describe('@typeofweb/schema', () => { 11 | it('tsd', async () => { 12 | const diagnostics = await tsd({ 13 | cwd: join(Path.dirname(Url.fileURLToPath(import.meta.url)), '..'), 14 | typingsFile: './dist/index.d.ts', 15 | testFiles: ['./__tests__/*.test-d.ts'], 16 | }); 17 | 18 | if (diagnostics.length > 0) { 19 | const errorMessage = diagnostics.map((test) => { 20 | return ( 21 | [test.fileName, test.line, test.column].filter(identity).join(':') + 22 | ` - ${test.severity} - ${test.message}` 23 | ); 24 | }); 25 | throw new Error('\n' + errorMessage.join('\n') + '\n'); 26 | } 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /docs/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary:hsl(146.45, 39.24%, 53.53%); 11 | --ifm-color-primary-dark: hsl(146.45, 39.24%, 48%); 12 | --ifm-color-primary-darker:hsl(146.45, 39.24%, 43%); 13 | --ifm-color-primary-darkest: hsl(146.45, 39.24%, 35%); 14 | --ifm-color-primary-light:hsl(146.45, 39.24%, 58%); 15 | --ifm-color-primary-lighter: hsl(146.45, 39.24%, 63%); 16 | --ifm-color-primary-lightest: hsl(146.45, 39.24%, 68%); 17 | --ifm-code-font-size: 95%; 18 | } 19 | 20 | .docusaurus-highlight-code-line { 21 | background-color: rgb(72, 77, 91); 22 | display: block; 23 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 24 | padding: 0 var(--ifm-pre-padding); 25 | } 26 | 27 | .button.button--secondary.button--outline:not(.button--active):not(:hover) { 28 | color: var(--ifm-font-color-base-alternative); 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Type of Web - Michał Miszczyszyn 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 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { isSchema } from './schema'; 2 | export { ValidationError } from './errors'; 3 | export { λ, pipe } from './utils/pipe'; 4 | export type { 5 | Schema, 6 | SomeSchema, 7 | TypeOf, 8 | ErrorData, 9 | ErrorDataEntry, 10 | Either, 11 | SchemaRecord, 12 | TypeOfRecord, 13 | } from './types'; 14 | 15 | export { array } from './validators/array'; 16 | export { boolean } from './validators/boolean'; 17 | export { date } from './validators/date'; 18 | export { number } from './validators/number'; 19 | export { object } from './validators/object'; 20 | export { oneOf } from './validators/oneOf'; 21 | export { string } from './validators/string'; 22 | export { tuple } from './validators/tuple'; 23 | export { unknown } from './validators/unknown'; 24 | export { validate } from './validators/__validate'; 25 | 26 | export { nil } from './modifiers/nil'; 27 | export { nullable } from './modifiers/nullable'; 28 | export { optional } from './modifiers/optional'; 29 | export { minArrayLength } from './modifiers/minArrayLength'; 30 | export { minStringLength } from './modifiers/minStringLength'; 31 | 32 | export { refine } from './refine'; 33 | 34 | export { left, right } from './utils/either'; 35 | -------------------------------------------------------------------------------- /src/utils/pipe.ts: -------------------------------------------------------------------------------- 1 | import type { SomeSchema } from '../types'; 2 | 3 | type Modifier = (arg: S1) => S2; 4 | type SchemaArg = (() => S) | S; 5 | // prettier-ignore 6 | export function pipe(schema: SchemaArg): S1; 7 | // prettier-ignore 8 | export function pipe(schema: SchemaArg, mod1: Modifier): S2; 9 | // prettier-ignore 10 | export function pipe(schema: SchemaArg, mod1: Modifier, mod2: Modifier): S3; 11 | // prettier-ignore 12 | export function pipe(schema: SchemaArg, mod1: Modifier, mod2: Modifier, mod3: Modifier): S4; 13 | // prettier-ignore 14 | export function pipe(schema: SchemaArg, mod1: Modifier, mod2: Modifier, mod3: Modifier, mod4: Modifier): S5; 15 | // prettier-ignore 16 | export function pipe(schema: SchemaArg, mod1: Modifier, mod2: Modifier, mod3: Modifier, mod4: Modifier, mod5: Modifier): S6; 17 | export function pipe>( 18 | schema: SchemaArg, 19 | ...rest: readonly ((...args: readonly any[]) => any)[] 20 | ) { 21 | return rest.reduce((acc, mod) => mod(acc), typeof schema === 'function' ? schema() : schema); 22 | } 23 | export const λ = pipe; 24 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "schema", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "serve": "docusaurus serve", 12 | "clear": "docusaurus clear" 13 | }, 14 | "dependencies": { 15 | "@docusaurus/core": "2.0.0-beta.13", 16 | "@docusaurus/plugin-google-gtag": "2.0.0-beta.13", 17 | "@docusaurus/plugin-sitemap": "2.0.0-beta.13", 18 | "@docusaurus/preset-classic": "2.0.0-beta.13", 19 | "@mdx-js/react": "1.6.22", 20 | "clsx": "1.1.1", 21 | "react": "17.0.2", 22 | "react-dom": "17.0.2" 23 | }, 24 | "browserslist": { 25 | "production": [ 26 | ">0.5%", 27 | "not dead", 28 | "not op_mini all" 29 | ], 30 | "development": [ 31 | "last 1 chrome version", 32 | "last 1 firefox version", 33 | "last 1 safari version" 34 | ] 35 | }, 36 | "devDependencies": { 37 | "@docusaurus/module-type-aliases": "2.0.0-beta.13", 38 | "@tsconfig/docusaurus": "1.0.4", 39 | "@types/react": "17.0.37", 40 | "@types/react-helmet": "6.1.4", 41 | "@types/react-router-dom": "5.3.2", 42 | "typescript": "4.5.4" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/stats-pr.yml: -------------------------------------------------------------------------------- 1 | name: Generate Pull Request Bundle Stats 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | stats: 7 | name: PR Stats 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout PR 11 | uses: actions/checkout@v2 12 | with: 13 | path: pr-branch 14 | ref: ${{github.event.pull_request.head.ref}} 15 | repository: ${{github.event.pull_request.head.repo.full_name}} 16 | 17 | - name: Checkout base 18 | uses: actions/checkout@v2 19 | with: 20 | path: base-branch 21 | ref: ${{github.base_ref}} 22 | 23 | - name: Compare sizes 24 | uses: typeofweb/typeofweb-bundlephobia-pr-stats-action@pkg 25 | with: 26 | pr_directory_name: pr-branch 27 | base_directory_name: base-branch 28 | env: 29 | COMPRESS_BUNDLES: true 30 | 31 | # START https://securitylab.github.com/research/github-actions-preventing-pwn-requests 32 | - name: Save PR number 33 | run: | 34 | mkdir -p ./pr 35 | echo ${{ github.event.number }} > ./pr/NR 36 | - uses: actions/upload-artifact@v2 37 | with: 38 | name: pr 39 | path: pr/ 40 | # END https://securitylab.github.com/research/github-actions-preventing-pwn-requests 41 | -------------------------------------------------------------------------------- /__tests__/exports.spec.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/dynamic-import-chunkname */ 2 | import Fs from 'fs'; 3 | import Path, { resolve, extname } from 'path'; 4 | import Url from 'url'; 5 | 6 | describe('check exports', () => { 7 | describe('validators', () => { 8 | const validatorsDir = Fs.readdirSync( 9 | resolve(Path.dirname(Url.fileURLToPath(import.meta.url)), '..', 'src', 'validators'), 10 | ); 11 | const validators = validatorsDir 12 | .filter((filename) => !filename.startsWith('__')) 13 | .map((name) => (extname(name).length ? name.slice(0, -extname(name).length) : name)); 14 | 15 | it.each(validators)(`%s should be exported from index.ts`, async (v) => 16 | expect(Object.keys(await import('../src/index'))).toContain(v), 17 | ); 18 | }); 19 | 20 | describe('modifiers', () => { 21 | const modifiersDir = Fs.readdirSync( 22 | resolve(Path.dirname(Url.fileURLToPath(import.meta.url)), '..', 'src', 'modifiers'), 23 | ); 24 | const modifiers = modifiersDir 25 | .filter((filename) => !filename.startsWith('__')) 26 | .map((name) => (extname(name).length ? name.slice(0, -extname(name).length) : name)); 27 | 28 | it.each(modifiers)(`%s should be exported from index.ts`, async (v) => 29 | expect(Object.keys(await import('../src/index'))).toContain(v), 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Test and Build 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | tests: 11 | if: "! contains(toJSON(github.event.commits.*.message), '[skip-ci]')" 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 1 18 | 19 | - name: Read .nvmrc 20 | run: echo "##[set-output name=NVMRC;]$(cat .nvmrc)" 21 | id: nvm 22 | 23 | - name: Use Node.js 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: '${{ steps.nvm.outputs.NVMRC }}' 27 | 28 | - name: Get yarn cache directory path 29 | id: yarn-cache-dir-path 30 | run: echo "::set-output name=dir::$(yarn cache dir)" 31 | 32 | - uses: actions/cache@v2 33 | id: yarn-cache 34 | with: 35 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 36 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 37 | restore-keys: | 38 | ${{ runner.os }}-yarn- 39 | ${{ runner.os }}- 40 | 41 | - name: Install dependencies 42 | run: yarn install --frozen-lockfile 43 | 44 | - name: Run tests 45 | run: | 46 | yarn build 47 | yarn test 48 | 49 | - name: Collect coverage 50 | run: curl -s https://codecov.io/bash | bash 51 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Pretty } from '@typeofweb/utils'; 2 | 3 | export type TypeOf> = Pretty; 4 | 5 | export interface Schema { 6 | readonly __type: Type; 7 | /** 8 | * @internal 9 | */ 10 | readonly __validate: (val: unknown) => Either | Next; 11 | toString(): string; 12 | } 13 | 14 | type Left = { readonly _t: 'left'; readonly value: L }; 15 | type Right = { readonly _t: 'right'; readonly value: R }; 16 | 17 | type NextNotValid = { readonly _t: 'nextNotValid'; readonly value: L }; 18 | type NextValid = { readonly _t: 'nextValid'; readonly value: R }; 19 | export type Next = NextNotValid | NextValid; 20 | export type Either = Left | Right; 21 | 22 | export type SomeSchema = Schema; 23 | 24 | export interface ErrorDataEntry { 25 | readonly path: keyof any; 26 | readonly error: ErrorData; 27 | } 28 | 29 | export interface ErrorData { 30 | readonly expected: string; 31 | readonly got: T; 32 | readonly args?: ReadonlyArray; 33 | readonly errors?: ReadonlyArray; 34 | } 35 | 36 | export type SchemaRecord = { 37 | readonly [K in Keys]: SomeSchema; 38 | }; 39 | 40 | export type TypeOfRecord> = Pretty< 41 | { 42 | readonly [K in keyof T]: TypeOf; 43 | } 44 | >; 45 | -------------------------------------------------------------------------------- /docs/docs/validators/object.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: object 3 | title: object 4 | --- 5 | 6 | Creates a schema that matches objects of provided shape. 7 | 8 | ```ts 9 | const carSchema = object({ 10 | manufacturer: string(), 11 | model: string(), 12 | mass: number(), 13 | enginePower: number(), 14 | fuelCapacity: number(), 15 | })(); 16 | const carValidator = validate(carSchema); 17 | 18 | /* Returns { 19 | manufacturer: 'Type of Web Enterprise', 20 | model: 'x1024', 21 | mass: 1600, 22 | enginePower: 135, 23 | fuelCapacity: 59, 24 | } */ 25 | const matthewCar = carValidator({ 26 | manufacturer: 'Type of Web Enterprise', 27 | model: 'x1024', 28 | mass: 1600, 29 | enginePower: 135, 30 | fuelCapacity: 59, 31 | }); 32 | ``` 33 | 34 | ## `allowUnknownKeys` 35 | 36 | The `allowUnknownKeys` option is used to not throw validation error on unspecifed fields in object (`false` by default): 37 | 38 | ```ts 39 | const computerSchema = object( 40 | { 41 | cpuModel: string(), 42 | gpuModel: string(), 43 | RAM: number(), 44 | }, 45 | { allowUnknownKeys: false }, 46 | )(); 47 | 48 | const anotherComputerSchema = object( 49 | { 50 | cpuModel: string(), 51 | gpuModel: string(), 52 | RAM: number(), 53 | }, 54 | { allowUnknownKeys: true }, 55 | )(); 56 | 57 | const computer = { 58 | cpuModel: 'Intel', 59 | gpuModel: 'Nvidia', 60 | RAM: 8, 61 | motherboard: 'MSI', 62 | }; 63 | 64 | // Throws ValidationError 65 | validate(computerSchema)(computer); 66 | 67 | // It's fine 68 | validate(anotherComputerSchema)(computer); 69 | ``` 70 | -------------------------------------------------------------------------------- /docs/docs/modifiers/minArrayLength.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: minArrayLength 3 | title: minArrayLength 4 | --- 5 | 6 | The `minArrayLength` modifier is used to contraint length of`array` values. It also changes the return validation type – tuples with given number of elements are returned instead of arrays: 7 | 8 | ```ts 9 | const len10Validator = λ(array(number()), minArrayLength(2), validate)([]); 10 | const result = validate(len10Validator)([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); 11 | // type of result is 12 | // readonly [number, number, ...number[]] 13 | ``` 14 | 15 | As a result, it makes using such arrays/tuples more typesafe. Notice how the type of the `thirdElement` is different: 16 | 17 | ```ts 18 | // number 19 | const firstElement = result[0]; 20 | // number 21 | const tenthElement = result[1]; 22 | // number | undefined 23 | const thirdElement = result[2]; 24 | ``` 25 | 26 | ## Type instantiation is excessively deep and possibly infinite 27 | 28 | On rare occasions you may come across the following TypeScript error: _Type instantiation is excessively deep and possibly infinite.ts(2589)_. It's a limitation of the TypeScript compiler when used on very deeply nested conditional types such as the tuples generated by the `minArrayLength` modifier. 29 | 30 | Should this issue ever occur to you, please override the inferred generic parameter with `number`: 31 | 32 | ```ts 33 | // error in compile-time 34 | const whoopsieDaisyValidator = λ(array(number()), minArrayLength(100000), validate)([]); 35 | 36 | // fallback to less-typesafe array: readonly number[] 37 | const better = λ(array(number()), minArrayLength(100000), validate)([]); 38 | ``` 39 | -------------------------------------------------------------------------------- /src/validators/tuple.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable functional/no-loop-statement */ 2 | import { refine } from '../refine'; 3 | import { isSchema } from '../schema'; 4 | import { schemaToString } from '../stringify'; 5 | 6 | import type { SomeSchema, TypeOf, ErrorDataEntry } from '../types'; 7 | 8 | export const tuple = []>( 9 | validatorsOrLiterals: readonly [...U], 10 | ) => { 11 | type TypeOfResult = { 12 | readonly [Index in keyof U]: U[Index] extends SomeSchema ? TypeOf : U[Index]; 13 | }; 14 | 15 | return refine( 16 | function (values, t) { 17 | if (!Array.isArray(values) || values.length !== validatorsOrLiterals.length) { 18 | return t.left({ 19 | expected: 'tuple', 20 | got: values, 21 | }); 22 | } 23 | 24 | let isError = false; 25 | const result = new Array(values.length); 26 | // eslint-disable-next-line functional/prefer-readonly-type 27 | const errors: Array = []; 28 | for (let i = 0; i < values.length; ++i) { 29 | const schema = validatorsOrLiterals[i]; 30 | const value = values[i] as unknown; 31 | 32 | const r = schema.__validate(value); 33 | result[i] = r.value as unknown; 34 | if (r._t === 'left') { 35 | isError = true; 36 | errors.push({ 37 | path: i, 38 | error: r.value, 39 | }); 40 | } 41 | } 42 | 43 | if (isError) { 44 | return t.left({ expected: 'tuple', got: values, errors }); 45 | } 46 | return t.nextValid(result as unknown as TypeOfResult); 47 | }, 48 | () => 49 | '[' + 50 | validatorsOrLiterals 51 | .map((s) => (isSchema(s) ? schemaToString(s) : JSON.stringify(s))) 52 | .join(', ') + 53 | ']', 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/validators/oneOf.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable functional/no-loop-statement */ 2 | import { refine } from '../refine'; 3 | import { isSchema } from '../schema'; 4 | import { schemaToString } from '../stringify'; 5 | 6 | import type { SomeSchema, TypeOf } from '../types'; 7 | import type { Primitives } from '@typeofweb/utils'; 8 | 9 | // `U extends (Primitives)[]` and `[...U]` is a trick to force TypeScript to narrow the type correctly 10 | // thanks to this, there's no need for "as const": oneOf(['a', 'b']) works as oneOf(['a', 'b'] as const) 11 | export const oneOf = )[]>( 12 | validatorsOrLiterals: readonly [...U], 13 | ) => { 14 | type TypeOfResult = { 15 | readonly [Index in keyof U]: U[Index] extends SomeSchema ? TypeOf : U[Index]; 16 | }[number]; 17 | 18 | return refine( 19 | function (value, t) { 20 | for (let i = 0; i < validatorsOrLiterals.length; ++i) { 21 | const valueOrSchema = validatorsOrLiterals[i]; 22 | if (isSchema(valueOrSchema)) { 23 | const r = valueOrSchema.__validate(value); 24 | if (r._t === 'right') { 25 | return t.right(r.value as TypeOfResult); 26 | } else if (r._t === 'nextValid') { 27 | return t.nextValid(r.value as TypeOfResult); 28 | } 29 | continue; 30 | } else { 31 | if (value === valueOrSchema) { 32 | return t.right(valueOrSchema as TypeOfResult); 33 | } 34 | } 35 | } 36 | return t.left({ expected: 'oneOf', got: value as TypeOfResult }); 37 | }, 38 | () => { 39 | const str = validatorsOrLiterals 40 | .map((s) => (isSchema(s) ? schemaToString(s) : JSON.stringify(s))) 41 | .join(' | '); 42 | 43 | return validatorsOrLiterals.length > 1 ? `(${str})` : str; 44 | }, 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/validators/array.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable functional/no-loop-statement */ 2 | import { refine } from '../refine'; 3 | import { schemaToString, typeToPrint } from '../stringify'; 4 | 5 | import type { SomeSchema, TypeOf, Either, Next, ErrorDataEntry } from '../types'; 6 | 7 | export const array = []>(...validators: readonly [...U]) => { 8 | type TypeOfResult = readonly TypeOf[]; 9 | 10 | return refine( 11 | function (values, t) { 12 | if (!Array.isArray(values)) { 13 | return t.left({ 14 | expected: 'array', 15 | got: values, 16 | }); 17 | } 18 | 19 | let isError = false; 20 | const result = new Array(values.length); 21 | // eslint-disable-next-line functional/prefer-readonly-type 22 | const errors: Array = []; 23 | valuesLoop: for (let i = 0; i < values.length; ++i) { 24 | const value = values[i]! as unknown; 25 | let r: Either | Next | undefined = undefined; 26 | let validator: U[number] | undefined = undefined; 27 | for (let k = 0; k < validators.length; ++k) { 28 | validator = validators[k]!; 29 | r = validator.__validate(value); 30 | if (r._t === 'right' || r._t === 'nextValid') { 31 | result[i] = r.value; 32 | continue valuesLoop; 33 | } 34 | } 35 | if (r && validator && (r._t === 'nextNotValid' || r._t === 'left')) { 36 | errors.push({ 37 | path: i, 38 | error: r.value, 39 | }); 40 | isError = true; 41 | } 42 | continue; 43 | } 44 | 45 | if (isError) { 46 | return t.left({ 47 | expected: 'array', 48 | got: values, 49 | errors, 50 | }); 51 | } 52 | return t.nextValid(result as TypeOfResult); 53 | }, 54 | () => { 55 | const str = validators.map((s) => schemaToString(s)).join(' | '); 56 | return validators.length > 1 ? typeToPrint(`(${str})[]`) : typeToPrint(`${str}[]`); 57 | }, 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /.github/workflows/stats-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Comment on the pull request 2 | 3 | on: 4 | workflow_run: 5 | workflows: ['Generate Pull Request Bundle Stats'] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | upload: 11 | runs-on: ubuntu-latest 12 | if: > 13 | ${{ github.event.workflow_run.event == 'pull_request' && 14 | github.event.workflow_run.conclusion == 'success' }} 15 | 16 | # START https://securitylab.github.com/research/github-actions-preventing-pwn-requests 17 | steps: 18 | - name: 'Download artifact' 19 | uses: actions/github-script@v3.1.0 20 | with: 21 | script: | 22 | var artifacts = await github.actions.listWorkflowRunArtifacts({ 23 | owner: context.repo.owner, 24 | repo: context.repo.repo, 25 | run_id: ${{github.event.workflow_run.id }}, 26 | }); 27 | var matchArtifact = artifacts.data.artifacts.filter((artifact) => { 28 | return artifact.name == "pr" 29 | })[0]; 30 | var download = await github.actions.downloadArtifact({ 31 | owner: context.repo.owner, 32 | repo: context.repo.repo, 33 | artifact_id: matchArtifact.id, 34 | archive_format: 'zip', 35 | }); 36 | var fs = require('fs'); 37 | fs.writeFileSync('${{github.workspace}}/pr.zip', Buffer.from(download.data)); 38 | - run: unzip pr.zip 39 | - uses: actions/github-script@v3 40 | id: get-pr-number 41 | with: 42 | result-encoding: string 43 | script: | 44 | var fs = require('fs'); 45 | return Number(fs.readFileSync('./NR')); 46 | # END https://securitylab.github.com/research/github-actions-preventing-pwn-requests 47 | - name: Compare sizes 48 | uses: typeofweb/typeofweb-bundlephobia-pr-stats-action@pkg 49 | with: 50 | pr_number: ${{steps.get-pr-number.outputs.result}} 51 | workflow_run_id: ${{github.event.workflow_run.id}} 52 | env: 53 | GITHUB_TOKEN: ${{secrets.TYPEOFWEB_BOT_GITHUB_TOKEN}} 54 | COMPRESS_BUNDLES: true 55 | -------------------------------------------------------------------------------- /__tests__/prototypePollution.spec.ts: -------------------------------------------------------------------------------- 1 | import { object, optional, string, validate } from '../src'; 2 | 3 | describe('prototype pollution', () => { 4 | it('do not allow __proto__ polution', () => { 5 | const obj = {} as { readonly polluted?: string }; 6 | 7 | expect(obj.polluted).toBe(undefined); 8 | const schema = object({ 9 | __proto__: object({ 10 | polluted: string(), 11 | })(), 12 | })(); 13 | expect(() => validate(schema)({ __proto__: { polluted: true } })).not.toThrowError(); 14 | expect(obj.polluted).toBe(undefined); 15 | // @ts-ignore 16 | expect(Object.polluted).toBe(undefined); 17 | }); 18 | 19 | it('do not allow prototype polution', () => { 20 | const obj = {} as { readonly polluted?: string }; 21 | 22 | expect(obj.polluted).toBe(undefined); 23 | const schema = optional( 24 | object({ 25 | prototype: optional( 26 | object({ 27 | polluted: optional(string()), 28 | })(), 29 | ), 30 | })(), 31 | ); 32 | expect(() => validate(schema)(Object)).toThrowError(); 33 | expect(obj.polluted).toBe(undefined); 34 | // @ts-ignore 35 | expect(Object.polluted).toBe(undefined); 36 | }); 37 | 38 | it('do not allow constructor polution', () => { 39 | const obj = {} as { readonly polluted?: string }; 40 | 41 | expect(obj.polluted).toBe(undefined); 42 | const t = {}; 43 | const schema = object({ 44 | constructor: optional(object({ polluted: optional(string()) })()), 45 | })(); 46 | expect(() => validate(schema)(t)).not.toThrowError(); 47 | expect(typeof t.constructor).toBe('function'); 48 | expect(obj.polluted).toBe(undefined); 49 | // @ts-ignore 50 | expect(Object.polluted).toBe(undefined); 51 | }); 52 | 53 | it('do not allow constructor.prototype polution', () => { 54 | const obj = {} as { readonly polluted?: string }; 55 | 56 | expect(obj.polluted).toBe(undefined); 57 | const schema = object({ 58 | constructor: object({ 59 | prototype: object({ 60 | polluted: string(), 61 | })(), 62 | })(), 63 | })(); 64 | expect(() => 65 | validate(schema)({ constructor: { prototype: { polluted: 'yes' } } }), 66 | ).not.toThrowError(); 67 | expect(obj.polluted).toBe(undefined); 68 | // @ts-ignore 69 | expect(Object.polluted).toBe(undefined); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /docs/docs/validate.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: validate 3 | title: validate 4 | --- 5 | 6 | `validate` is a function that returns a `validator function`. `validator function` is used to validate data and to provide the correct type of the data. 7 | 8 | ## Parsing behaviour of `validate` 9 | 10 | `@typeofweb/schema` comes with a default set of sane coercion rules, meaning some of the most common scenarioes are covered out of the box! 11 | 12 | ### Example 13 | 14 | ```ts 15 | import Qs from 'qs'; 16 | 17 | const queryString = `dateFrom=2020-10-15&dateTo=2020-10-15&resultsPerPage=10`; 18 | 19 | const parsedQuery = Qs.parse(queryString); 20 | 21 | const queryValidator = validate( 22 | object({ 23 | dateFrom: date(), 24 | dateTo: date(), 25 | resultsPerPage: number(), 26 | })(), 27 | ); 28 | 29 | const queryObject = queryValidator(parsedQuery); 30 | // { 31 | // dateFrom: new Date('2020-10-15T00:00:00.000Z'), // date instance 32 | // dateTo: new Date('2020-10-15T00:00:00.000Z'), // date instance 33 | // resultsPerPage: 10 // number 34 | // } 35 | ``` 36 | 37 | ### Numeric strings are converted to numbers 38 | 39 | Sometimes, numbers are passed as strings i.e. in query strings. We found out this kind of scenario is very common and thus we automatically convert numeric strings to numbers. 40 | 41 | ```ts 42 | const numberSchema = number(); 43 | const numberValidator = validate(numberSchema); 44 | const luckyNumber = '2'; 45 | 46 | // Returns 2 & type of dayOfTheWeek is `number` 47 | const dayOfTheWeek = numberValidator(luckyNumber); 48 | ``` 49 | 50 | ### Dates get stringified 51 | 52 | ```ts 53 | const stringSchema = string(); 54 | const stringValidator = validate(stringSchema); 55 | const aprilFoolsDay = new Date('April 1, 2021 00:00:00'); 56 | 57 | // Returns "2021-03-31T22:00:00.000Z" & type of ISOString is `string` 58 | const ISOString = stringValidator(aprilFoolsDay); 59 | ``` 60 | 61 | ### Date strings are converted to `Date` instances 62 | 63 | JSON format doesn't support Date instances and usually strings in ISO 8601 format are used instead. We thought it makes sense to automatically convert them to proper `Date` instances. 64 | 65 | ```ts 66 | const dateSchema = date(); 67 | const dateValidator = validate(dateSchema); 68 | const ISOString = '2021-03-31T22:00:00.000Z'; 69 | 70 | // Returns Date Thu Apr 01 2021 00:00:00 GMT+0200 (Central European Summer Time) & type of aprilFoolsDay is `Date` 71 | const aprilFoolsDay = dateValidator(ISOString); 72 | ``` 73 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '42 21 * * 1' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'javascript' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /__tests__/refinements.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd'; 2 | 3 | import { 4 | string, 5 | validate, 6 | pipe, 7 | array, 8 | number, 9 | date, 10 | optional, 11 | nullable, 12 | minArrayLength, 13 | minStringLength, 14 | } from '../src'; 15 | import { refine } from '../src/refine'; 16 | 17 | const nullR = pipe(string, nullable, validate)(''); 18 | expectType(nullR); 19 | 20 | const optionalR = pipe(string, optional, validate)(''); 21 | expectType(optionalR); 22 | 23 | const even = refine((value: number, t) => 24 | value % 2 === 0 25 | ? t.nextValid(value) 26 | : t.left({ 27 | expected: 'even', 28 | got: value, 29 | }), 30 | ); 31 | const evenR = pipe(number, even, validate)(''); 32 | expectType(evenR); 33 | 34 | const noDuplicateItems = refine((arr: ReadonlyArray, t) => { 35 | const allUnique = arr.every((item, index) => index === arr.indexOf(item)); 36 | return allUnique 37 | ? t.nextValid(arr) 38 | : t.left({ 39 | expected: 'noDuplicateItems', 40 | got: arr, 41 | }); 42 | }); 43 | const noDuplicateItemsR = pipe(array(string()), noDuplicateItems, validate)(''); 44 | expect(noDuplicateItemsR); 45 | 46 | const noDuplicateItemsAnyR = pipe(array(number()), noDuplicateItems, validate)(''); 47 | expect(noDuplicateItemsAnyR); 48 | 49 | const allowTimestamps = refine((value, t) => 50 | typeof value === 'number' ? t.nextValid(new Date(value)) : t.nextValid(value), 51 | ); 52 | const allowDateTimestamps = pipe(date, allowTimestamps); 53 | const allowDateTimestampsR = pipe(allowDateTimestamps, validate)(''); 54 | expectType(allowDateTimestampsR); 55 | 56 | const presentOrFuture = refine((value: Date, t) => 57 | value.getTime() >= Date.now() 58 | ? t.nextValid(value) 59 | : t.left({ 60 | expected: 'presentOrFuture', 61 | got: value, 62 | }), 63 | ); 64 | const allowDateTimestampsR2 = pipe(presentOrFuture, date, allowTimestamps, validate)(''); 65 | expectType(allowDateTimestampsR2); 66 | 67 | const ref1 = pipe(string, nullable, optional, validate)(''); 68 | expectType(ref1); 69 | 70 | const ref2 = pipe(minStringLength(2), nullable, validate)(''); 71 | expectType(ref2); 72 | 73 | const ref3 = pipe(array(string()), minArrayLength(2), nullable, validate)(''); 74 | expectType(ref3); 75 | 76 | const ref4 = pipe(number, even, nullable, validate)(1); 77 | expectType(ref4); 78 | 79 | // @ts-expect-error 80 | pipe(nullable, even, validate)(1); 81 | -------------------------------------------------------------------------------- /docs/docs/introduction.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: introduction 3 | title: Introduction 4 | slug: / 5 | --- 6 | 7 | `@typeofweb/schema` is a lightweight and extensible library for data validation with full TypeScript support! 8 | 9 | [![codecov](https://codecov.io/gh/typeofweb/schema/branch/main/graph/badge.svg?token=6DNCIHEEUO)](https://codecov.io/gh/typeofweb/schema) 10 | [![npm](https://img.shields.io/npm/v/@typeofweb/schema.svg)](https://www.npmjs.com/package/@typeofweb/schema) 11 | [![npm bundle size (minified + gzip)](https://badgen.net/bundlephobia/minzip/@typeofweb/schema)](https://bundlephobia.com/result?p=@typeofweb/schema) 12 | [![no external dependencies](https://badgen.net/bundlephobia/dependency-count/@typeofweb/schema)](https://bundlephobia.com/result?p=@typeofweb/schema) 13 | [![tree-shakeable](https://badgen.net/bundlephobia/tree-shaking/@typeofweb/schema)](https://bundlephobia.com/result?p=@typeofweb/schema) 14 | 15 | ## Validation 16 | 17 | ```ts 18 | import { number, object, optional, string, validate } from '@typeofweb/schema'; 19 | 20 | const personSchema = object({ 21 | name: string(), 22 | age: number(), 23 | email: optional(string()), 24 | })(); 25 | 26 | const mark = { 27 | name: 'Mark', 28 | age: 29, 29 | }; 30 | 31 | const personValidator = validate(personSchema); 32 | 33 | const validatedPerson = personValidator(mark); 34 | // returns 35 | // { 36 | // readonly name: string; 37 | // readonly age: number; 38 | // readonly email?: string | undefined; 39 | // } 40 | ``` 41 | 42 | ## Intuitive coercion 43 | 44 | ```ts 45 | import { number, object, date, validate } from '@typeofweb/schema'; 46 | 47 | const userQuery = object({ 48 | dob: date(), 49 | query: number(), 50 | })(); 51 | 52 | const payload = { 53 | dob: '2001-04-16T00:00:00.000Z', 54 | query: '123', 55 | }; 56 | 57 | const userQueryValidator = validate(userQuery); 58 | 59 | const result = userQueryValidator(payload); 60 | // returns 61 | // { 62 | // readonly dob: Date; 63 | // readonly query: number; 64 | // } 65 | ``` 66 | 67 | ## Descriptive errors 68 | 69 | ```ts 70 | import { string, object, array, validate } from '@typeofweb/schema'; 71 | 72 | const validator = validate(array(object({ a: string() })())()); 73 | 74 | const result = validator([123]); 75 | // throws ValidationError: Invalid type! Expected { a: string }[] but got [123]! 76 | ``` 77 | 78 | ## Types generated from validators 79 | 80 | ```ts 81 | import { number, object, optional, string, TypeOf } from '@typeofweb/schema'; 82 | 83 | const personSchema = object({ 84 | name: string(), 85 | age: number(), 86 | email: optional(string()), 87 | })(); 88 | 89 | type Person = TypeOf; 90 | // type Person = { 91 | // readonly name: string; 92 | // readonly age: number; 93 | // readonly email?: string | undefined; 94 | // } 95 | ``` 96 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import prettier from 'rollup-plugin-prettier'; 6 | import { terser } from 'rollup-plugin-terser'; 7 | import filesize from 'rollup-plugin-filesize'; 8 | import license from 'rollup-plugin-license'; 9 | 10 | import pkg from './package.json'; 11 | 12 | const shouldCompress = process.env.COMPRESS_BUNDLES ? true : false; 13 | 14 | const rollupConfig = [ 15 | { 16 | input: 'src/index.ts', 17 | output: [ 18 | { 19 | name: '@typeofweb/schema', 20 | format: 'es', 21 | dir: './', 22 | entryFileNames: pkg.exports.import.replace(/^\.\//, ''), 23 | sourcemap: true, 24 | plugins: [ 25 | shouldCompress 26 | ? terser({ 27 | compress: true, 28 | mangle: true, 29 | ecma: 2019, 30 | }) 31 | : prettier({ 32 | parser: 'typescript', 33 | }), 34 | ], 35 | }, 36 | { 37 | name: '@typeofweb/schema', 38 | format: 'cjs', 39 | dir: './', 40 | entryFileNames: pkg.exports.require.replace(/^\.\//, ''), 41 | sourcemap: true, 42 | plugins: [ 43 | shouldCompress 44 | ? terser({ 45 | compress: true, 46 | mangle: true, 47 | ecma: 2019, 48 | }) 49 | : prettier({ 50 | parser: 'typescript', 51 | }), 52 | ], 53 | }, 54 | { 55 | name: '@typeofweb/schema', 56 | entryFileNames: pkg.exports.browser.replace(/^\.\//, ''), 57 | sourcemap: true, 58 | format: 'umd', 59 | dir: './', 60 | plugins: [ 61 | terser({ 62 | compress: true, 63 | mangle: true, 64 | ecma: 2019, 65 | }), 66 | ], 67 | }, 68 | ], 69 | plugins: [ 70 | resolve(), 71 | commonjs({ 72 | include: 'node_modules/**', 73 | }), 74 | typescript({ 75 | tsconfig: 'tsconfig.json', 76 | declaration: true, 77 | declarationDir: 'dist/', 78 | rootDir: 'src/', 79 | }), 80 | filesize({}), 81 | license({ 82 | banner: ` 83 | <%= pkg.name %>@<%= pkg.version %> 84 | Copyright (c) <%= moment().format('YYYY') %> Type of Web - Michał Miszczyszyn 85 | 86 | This source code is licensed under the MIT license found in the 87 | LICENSE file in the root directory of this source tree. 88 | `.trim(), 89 | }), 90 | ], 91 | }, 92 | ]; 93 | // eslint-disable-next-line import/no-default-export 94 | export default rollupConfig; 95 | -------------------------------------------------------------------------------- /src/refine.ts: -------------------------------------------------------------------------------- 1 | import { unionToPrint } from './stringify'; 2 | import { left, right, nextValid, nextNotValid } from './utils/either'; 3 | 4 | import type { Either, Next, SomeSchema } from './types'; 5 | import type { If, Pretty } from '@typeofweb/utils'; 6 | 7 | type Refinement = ( 8 | this: SomeSchema, 9 | value: Input, 10 | t: RefinementToolkit, 11 | ) => Either | Next; 12 | 13 | const refinementToolkit = { 14 | right: right, 15 | left: left, 16 | nextValid, 17 | nextNotValid, 18 | } as const; 19 | type RefinementToolkit = typeof refinementToolkit; 20 | 21 | export const refine = 22 | ( 23 | refinement: Refinement, 24 | toString?: () => string, 25 | ) => 26 | >(schema?: S) => { 27 | type HasExitEarlyResult = unknown extends ExitEarlyResult 28 | ? false 29 | : ExitEarlyResult extends never 30 | ? false 31 | : readonly unknown[] extends ExitEarlyResult 32 | ? false 33 | : true; 34 | 35 | type HasOutput = unknown extends Output 36 | ? false 37 | : Output extends never 38 | ? false 39 | : {} extends Output 40 | ? true 41 | : // : readonly unknown[] extends Output 42 | // ? false 43 | true; 44 | 45 | type Result = 46 | | If 47 | | If< 48 | true, 49 | HasOutput, 50 | // if schema is an array schema 51 | S['__type'] extends readonly (infer TypeOfSchemaElement)[] 52 | ? // and Output is a *tuple* schema 53 | Output extends readonly [...infer _] 54 | ? // replace each tuple element in Output with the type of element from schema 55 | { readonly [Index in keyof Output]: TypeOfSchemaElement } 56 | : Output 57 | : Output 58 | > 59 | | If 60 | | If; 61 | 62 | return { 63 | ...schema, 64 | toString() { 65 | return unionToPrint([schema?.toString()!, toString?.()!].filter(Boolean)); 66 | }, 67 | __validate(val) { 68 | // eslint-disable-next-line functional/no-this-expression 69 | const innerResult = refinement.call(this, val as Input, refinementToolkit); 70 | 71 | if (innerResult._t === 'left' || innerResult._t === 'right') { 72 | return innerResult; 73 | } 74 | if (!schema) { 75 | return innerResult; 76 | } 77 | if (innerResult._t === 'nextNotValid') { 78 | return schema.__validate(innerResult.value.got); 79 | } 80 | return schema.__validate(innerResult.value); 81 | }, 82 | } as SomeSchema>; 83 | }; 84 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "schema", 3 | "projectOwner": "typeofweb", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "gitmoji", 12 | "contributors": [ 13 | { 14 | "login": "mmiszy", 15 | "name": "Michał Miszczyszyn", 16 | "avatar_url": "https://avatars0.githubusercontent.com/u/1338731?v=4", 17 | "profile": "https://typeofweb.com/", 18 | "contributions": [ 19 | "code", 20 | "maintenance", 21 | "projectManagement", 22 | "review" 23 | ] 24 | }, 25 | { 26 | "login": "wisnie", 27 | "name": "Bartłomiej Wiśniewski", 28 | "avatar_url": "https://avatars3.githubusercontent.com/u/47081011?v=4", 29 | "profile": "https://github.com/wisnie", 30 | "contributions": [ 31 | "code", 32 | "review", 33 | "bug", 34 | "doc" 35 | ] 36 | }, 37 | { 38 | "login": "AdamSiekierski", 39 | "name": "Adam Siekierski", 40 | "avatar_url": "https://avatars0.githubusercontent.com/u/24841038?v=4", 41 | "profile": "https://github.com/AdamSiekierski", 42 | "contributions": [ 43 | "review" 44 | ] 45 | }, 46 | { 47 | "login": "asottile", 48 | "name": "Anthony Sottile", 49 | "avatar_url": "https://avatars3.githubusercontent.com/u/1810591?v=4", 50 | "profile": "https://github.com/asottile", 51 | "contributions": [ 52 | "security" 53 | ] 54 | }, 55 | { 56 | "login": "malydok", 57 | "name": "Marek", 58 | "avatar_url": "https://avatars.githubusercontent.com/u/1423385?v=4", 59 | "profile": "https://devalchemist.com", 60 | "contributions": [ 61 | "doc" 62 | ] 63 | }, 64 | { 65 | "login": "o-alexandrov", 66 | "name": "Olzhas Alexandrov", 67 | "avatar_url": "https://avatars.githubusercontent.com/u/9992724?v=4", 68 | "profile": "https://www.upwork.com/freelancers/~018e2d48fa8a42e825", 69 | "contributions": [ 70 | "bug" 71 | ] 72 | }, 73 | { 74 | "login": "Aliath", 75 | "name": "Bartek Słysz", 76 | "avatar_url": "https://avatars.githubusercontent.com/u/28493823?v=4", 77 | "profile": "https://github.com/Aliath", 78 | "contributions": [ 79 | "bug" 80 | ] 81 | }, 82 | { 83 | "login": "stepaniukm", 84 | "name": "Mateusz Stepaniuk", 85 | "avatar_url": "https://avatars.githubusercontent.com/u/28492390?v=4", 86 | "profile": "https://github.com/stepaniukm", 87 | "contributions": [ 88 | "ideas" 89 | ] 90 | }, 91 | { 92 | "login": "darkowic", 93 | "name": "Dariusz Rzepka", 94 | "avatar_url": "https://avatars.githubusercontent.com/u/11510581?v=4", 95 | "profile": "https://github.com/darkowic", 96 | "contributions": [ 97 | "ideas" 98 | ] 99 | } 100 | ], 101 | "contributorsPerLine": 3, 102 | "skipCi": true 103 | } 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@typeofweb/schema", 3 | "version": "0.8.0-10", 4 | "type": "module", 5 | "exports": { 6 | "require": "./dist/index.cjs", 7 | "import": "./dist/index.mjs", 8 | "browser": "./dist/index.umd.js", 9 | "default": "./dist/index.mjs" 10 | }, 11 | "main": "./dist/index.cjs", 12 | "module": "./dist/index.mjs", 13 | "browser": "dist/index.umd.js", 14 | "unpkg": "dist/index.umd.js", 15 | "types": "dist/index.d.ts", 16 | "sideEffects": false, 17 | "repository": "git://github.com/typeofweb/schema", 18 | "bugs": { 19 | "url": "https://github.com/typeofweb/schema/issues" 20 | }, 21 | "homepage": "https://github.com/typeofweb/schema#readme", 22 | "author": "Michał Miszczyszyn - Type of Web (https://typeofweb.com/)", 23 | "license": "MIT", 24 | "engines": { 25 | "node": "^12.20.0 || ^14.13.1 || >=16.0.0" 26 | }, 27 | "publishConfig": { 28 | "access": "public" 29 | }, 30 | "files": [ 31 | "package.json", 32 | "dist", 33 | "LICENSE" 34 | ], 35 | "keywords": [ 36 | "typescript", 37 | "validation", 38 | "validation-library", 39 | "schema", 40 | "jsonschema", 41 | "ts", 42 | "validations" 43 | ], 44 | "devDependencies": { 45 | "@rollup/plugin-commonjs": "21.0.1", 46 | "@rollup/plugin-node-resolve": "13.1.3", 47 | "@rollup/plugin-typescript": "8.3.0", 48 | "@tsconfig/node12": "1.0.9", 49 | "@tsconfig/node14": "1.0.1", 50 | "@typeofweb/eslint-plugin": "0.2.3-0", 51 | "@types/jest": "27.4.0", 52 | "@types/node": "12", 53 | "@types/qs": "6.9.7", 54 | "@types/ramda": "0.27.64", 55 | "all-contributors-cli": "6.20.0", 56 | "eslint": "8.9.0", 57 | "fast-check": "2.22.0", 58 | "husky": "7.0.4", 59 | "jest": "27.5.1", 60 | "lint-staged": "12.3.4", 61 | "prettier": "2.5.1", 62 | "qs": "6.10.3", 63 | "ramda": "0.28.0", 64 | "rimraf": "3.0.2", 65 | "rollup": "2.67.2", 66 | "rollup-plugin-filesize": "9.1.2", 67 | "rollup-plugin-license": "2.6.1", 68 | "rollup-plugin-prettier": "2.2.2", 69 | "rollup-plugin-terser": "7.0.2", 70 | "ts-jest": "27.1.3", 71 | "tsd": "https://github.com/typeofweb/tsd#pkg", 72 | "tslib": "2.3.1", 73 | "typescript": "4.5.5", 74 | "weak-napi": "2.0.2" 75 | }, 76 | "scripts": { 77 | "prejest": "yarn build", 78 | "jest": "NODE_OPTIONS=--experimental-vm-modules jest", 79 | "test": "NODE_OPTIONS=--experimental-vm-modules yarn jest --detectOpenHandles --forceExit --passWithNoTests --coverage", 80 | "test:dev": "NODE_OPTIONS=--experimental-vm-modules yarn jest --watch", 81 | "build": "rimraf dist && rollup --config", 82 | "build:watch": "rollup --config --watch", 83 | "prepublishOnly": "yarn build", 84 | "prepare": "husky install" 85 | }, 86 | "lint-staged": { 87 | "**/*.ts": [ 88 | "yarn test", 89 | "yarn eslint --fix", 90 | "yarn prettier --write" 91 | ], 92 | "**/*.{md,js,json}": [ 93 | "yarn prettier --write" 94 | ] 95 | }, 96 | "dependencies": { 97 | "@typeofweb/utils": "0.3.0" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /docs/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import clsx from 'clsx'; 3 | import Layout from '@theme/Layout'; 4 | import Link from '@docusaurus/Link'; 5 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 6 | import useBaseUrl from '@docusaurus/useBaseUrl'; 7 | import styles from './styles.module.css'; 8 | 9 | const features = [ 10 | { 11 | title: 'Focused on speed', 12 | imageUrl: 'img/fast.svg', 13 | description: ( 14 | <> 15 | @typeofweb/schema is significantly faster compared to most other validators. We want to 16 | provide fast and reliable packages for people to use. 17 | 18 | ), 19 | }, 20 | { 21 | title: 'Lightweight', 22 | imageUrl: 'img/winners.svg', 23 | description: ( 24 | <> 25 | @typeofweb/schema is one of the most lightweight packages available. It's side-effect free 26 | and supports tree shaking. Bundle only what you need! 27 | 28 | ), 29 | }, 30 | { 31 | title: 'Based on functional programming', 32 | imageUrl: 'img/functional.svg', 33 | description: ( 34 | <> 35 | We believe that functional programming is one of the most efficient paradigms. 36 | @typeofweb/schema exposes sweet functional API without confusing theoretical concepts to 37 | learn. 38 | 39 | ), 40 | }, 41 | ]; 42 | 43 | function Feature({ imageUrl, title, description }) { 44 | const imgUrl = useBaseUrl(imageUrl); 45 | return ( 46 |
47 | {imgUrl && ( 48 |
49 | {title} 50 |
51 | )} 52 |

{title}

53 |

{description}

54 |
55 | ); 56 | } 57 | 58 | function Home() { 59 | const context = useDocusaurusContext(); 60 | const { siteConfig = {} } = context; 61 | return ( 62 | 63 |