├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── automate-PRs.yml │ ├── close-stale-issues-and-PRs.yml │ ├── gha-publish-to-npm.yml │ └── test.yml ├── .gitignore ├── .husky ├── .gitignore ├── pre-commit └── pre-push ├── .npmignore ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── ci └── ci-test-commands.sh ├── config ├── commitlint.config.js ├── global.d.ts ├── helpers.js ├── jest.config.js ├── setup-tests.js ├── tsconfig.json └── types.d.ts ├── jest.config.js ├── package.json ├── scripts ├── build.js ├── copy.js ├── file-size.js └── tsconfig.json ├── src ├── fail.ts ├── index.ts ├── internal │ └── utils.ts ├── rule.ts ├── rules │ ├── get-prop.ts │ ├── greater-than.ts │ ├── has-prop.ts │ ├── index.ts │ ├── is-array.ts │ ├── is-equal-to.ts │ ├── is-non-empty-array.ts │ ├── is-non-empty-object.ts │ ├── is-non-empty-string.ts │ ├── is-number.ts │ ├── is-object.ts │ ├── is-string.ts │ ├── is-valid-email.ts │ ├── less-than.ts │ ├── matches-regex.ts │ ├── max-length.ts │ └── min-length.ts ├── success.ts ├── types │ └── index.ts ├── utils │ ├── compose.ts │ ├── index.ts │ └── pipe.ts └── validation.ts ├── test ├── Builtins.test.ts └── Validation.test.ts ├── tsconfig.dev.json ├── tsconfig.json ├── tsup.config.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # all files 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | max_line_length = 80 13 | 14 | [*.{js,ts}] 15 | quote_type = single 16 | curly_bracket_next_line = false 17 | spaces_around_brackets = inside 18 | indent_brace_style = BSD KNF 19 | 20 | # HTML 21 | [*.html] 22 | quote_type = double 23 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | scripts 4 | config -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line unicorn/prefer-module 2 | module.exports = { 3 | parser: `@typescript-eslint/parser`, 4 | parserOptions: { 5 | ecmaVersion: 2018, 6 | sourceType: `module`, 7 | project: [`./tsconfig.json`, `./tsconfig.dev.json`], 8 | extraFileExtensions: [`.md`], 9 | }, 10 | plugins: [`@typescript-eslint`], 11 | env: { 12 | browser: true, 13 | node: true, 14 | es6: true, 15 | jest: true, 16 | }, 17 | extends: [`adjunct`], 18 | rules: { 19 | 'comma-spacing': `off`, 20 | 'no-prototype-builtins': 0, 21 | '@typescript-eslint/no-unsafe-assignment': 0, 22 | '@typescript-eslint/no-unsafe-member-access': 0, 23 | 'unicorn/no-array-reduce': 0, 24 | 'sonarjs/no-nested-template-literals': 0, 25 | '@typescript-eslint/comma-spacing': [`error`], 26 | '@typescript-eslint/quotes': [`error`, `backtick`], 27 | '@typescript-eslint/no-explicit-any': 0, 28 | '@typescript-eslint/explicit-function-return-type': 0, 29 | '@typescript-eslint/prefer-regexp-exec': 0, 30 | '@typescript-eslint/no-non-null-assertion': 0, 31 | '@typescript-eslint/triple-slash-reference': 0, 32 | '@typescript-eslint/no-var-requires': 0, 33 | '@typescript-eslint/semi': [`error`, `never`], 34 | '@typescript-eslint/member-delimiter-style': [ 35 | 2, 36 | { 37 | multiline: { 38 | delimiter: `none`, 39 | }, 40 | singleline: { 41 | delimiter: `semi`, 42 | requireLast: false, 43 | }, 44 | }, 45 | ], 46 | 'object-curly-spacing': [`error`, `always`], 47 | indent: [`error`, 2], 48 | 'padding-line-between-statements': [ 49 | `error`, 50 | { 51 | blankLine: `always`, 52 | prev: `*`, 53 | next: `return`, 54 | }, 55 | { 56 | blankLine: `never`, 57 | prev: `return`, 58 | next: `*`, 59 | }, 60 | { 61 | blankLine: `always`, 62 | prev: [`const`, `let`, `var`], 63 | next: `*`, 64 | }, 65 | { 66 | blankLine: `any`, 67 | prev: [`const`, `let`, `var`], 68 | next: [`const`, `let`, `var`], 69 | }, 70 | { 71 | blankLine: `always`, 72 | prev: [`block-like`], 73 | next: `*`, 74 | }, 75 | { 76 | blankLine: `always`, 77 | prev: [`*`], 78 | next: `block-like`, 79 | }, 80 | { 81 | blankLine: `always`, 82 | prev: [`import`], 83 | next: `*`, 84 | }, 85 | { 86 | blankLine: `any`, 87 | prev: [`import`], 88 | next: `import`, 89 | }, 90 | ], 91 | 'no-multiple-empty-lines': [ 92 | `error`, 93 | { 94 | max: 1, 95 | maxEOF: 1, 96 | maxBOF: 0, 97 | }, 98 | ], 99 | }, 100 | } 101 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First of all, thanks for your interest in contributing to the codeparticle-formal! 🎉 4 | 5 | PRs are the preferred way to spike ideas and address issues, if you have time. If you plan on contributing frequently, please feel free to ask to become a maintainer; the more the merrier. 🤙 6 | 7 | ## Technical overview 8 | 9 | This library uses following libraries for development: 10 | 11 | - [typescript](http://www.typescriptlang.org/) for typed JavaScript and transpilation 12 | - [jest](https://jestjs.io/) for unit testing 13 | - run `yarn test:watch` during development 14 | - [rollup](https://rollupjs.org/guide/en) for creating UMD bundles 15 | - [yarn](https://yarnpkg.com/lang/en/) for package management 16 | - [npm scripts](https://docs.npmjs.com/misc/scripts) for executing tasks 17 | 18 | ### 🧪 Tests 19 | 20 | Test are written and run via Jest 💪 21 | 22 | ```sh 23 | # Run whole test suite once 24 | yarn test 25 | # Run test in watch mode 26 | yarn test:watch 27 | # Ged code coverage 28 | yarn test:coverage 29 | ``` 30 | 31 | ### 💅 Style guides 32 | 33 | Style guides are enforced by robots _(I meant prettier and tslint of course 🤖 )_, so they'll let you know if you screwed something, but most of the time, they'll autofix things for you. Magic right ? 34 | 35 | Lint and format codebase via npm-script: 36 | 37 | ```sh 38 | #Format and fix lint errors 39 | yarn ts:style:fix 40 | ``` 41 | 42 | #### Commit conventions (via commitizen) 43 | 44 | - this is preferred way how to create conventional-changelog valid commits 45 | - if you prefer your custom tool we provide a commit hook linter which will error out, it you provide invalid commit message 46 | - if you are in rush and just wanna skip commit message validation just prefix your message with `WIP: something done` ( if you do this please squash your work when you're done with proper commit message so standard-version can create Changelog and bump version of your library appropriately ) 47 | 48 | ```sh 49 | # invoke [commitizen CLI](https://github.com/commitizen/cz-cli) 50 | yarn commit 51 | ``` 52 | 53 | ### 📖 Documentation 54 | 55 | ```sh 56 | yarn docs 57 | ``` 58 | 59 | ## Getting started 60 | 61 | ### Creating a Pull Request 62 | 63 | If you've never submitted a Pull request before please visit http://makeapullrequest.com/ to learn everything you need to know. 64 | 65 | #### Setup 66 | 67 | 1. Fork the repo. 68 | 1. `git clone` your fork. 69 | 1. Make a `git checkout -b branch-name` branch for your change. 70 | 1. Run `yarn install --ignore-scripts` (make sure you have node and yarn installed first) 71 | Updates 72 | 73 | 1. Make sure to add unit tests 74 | 1. If there is a `*.spec.ts` file, update it to include a test for your change, if needed. If this file doesn't exist, please create it. 75 | 1. Run `yarn test` or `yarn test:watch` to make sure all tests are working, regardless if a test was added. 76 | 77 | --- 78 | 79 | ## 🚀 Publishing 80 | 81 | > releases are handled by awesome [standard-version](https://github.com/conventional-changelog/standard-version) 82 | 83 | > #### NOTE: 84 | > 85 | > you have to create npm account and register token on your machine 86 | > 👉 `npm adduser` 87 | > 88 | > If you are using scope (you definitely should 👌) don't forget to [`--scope`](https://docs.npmjs.com/cli/adduser#scope) 89 | 90 | Execute `yarn release` which will handle following tasks: 91 | 92 | - bump package version and git tag 93 | - update/(create if it doesn't exist) CHANGELOG.md 94 | - push to github master branch + push tags 95 | - publish build packages to npm 96 | 97 | > releases are handled by awesome [standard-version](https://github.com/conventional-changelog/standard-version) 98 | 99 | ### Initial Release (no package.json version bump): 100 | 101 | ```sh 102 | yarn release --first-release 103 | ``` 104 | 105 | ### Pre-release 106 | 107 | - To get from `1.1.2` to `1.1.2-0`: 108 | 109 | `yarn release --prerelease` 110 | 111 | - **Alpha**: To get from `1.1.2` to `1.1.2-alpha.0`: 112 | 113 | `yarn release --prerelease alpha` 114 | 115 | - **Beta**: To get from `1.1.2` to `1.1.2-beta.0`: 116 | 117 | `yarn release --prerelease beta` 118 | 119 | ### Dry run 120 | 121 | #### version bump + changelog 122 | 123 | ```sh 124 | # See what next release version would be with updated changelog 125 | yarn standard-version --dry-run 126 | ``` 127 | 128 | #### npm publish 129 | 130 | ```sh 131 | # check what files are gonna be published to npm 132 | yarn release:preflight 133 | ``` 134 | 135 | ## License 136 | 137 | By contributing your code to the codeparticle-formal GitHub Repository, you agree to license your contribution under the MIT license. 138 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ## Bug report 6 | 7 | - @codeparticle/codeparticle-formal version: _x.x.x_ () 8 | - Affected browsers (and versions): _IE 10_ 9 | 10 | ### Current behaviour 11 | 12 | 13 | 14 | ```ts 15 | // put code here 16 | ``` 17 | 18 | 19 | 20 | [issue demo](https://codesandbox.io/) 21 | 22 | ### Expected behaviour 23 | 24 | _Please explain how you'd expect it to behave._ 25 | 26 | 27 | 28 | 29 | 30 | ## Feature request 31 | 32 | ### Use case(s) 33 | 34 | _Explain the rationale for this feature._ 35 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | _If there is a linked issue, mention it here._ 2 | 3 | - [ ] Bug 4 | - [ ] Feature 5 | 6 | ## Requirements 7 | 8 | - [ ] Read the [contribution guidelines](./.github/CONTRIBUTING.md). 9 | - [ ] Wrote tests. 10 | - [ ] Updated docs and upgrade instructions, if necessary. 11 | 12 | ## Rationale 13 | 14 | _Why is this PR necessary?_ 15 | 16 | ## Implementation 17 | 18 | _Why have you implemented it this way? Did you try any other methods?_ 19 | 20 | ## Open questions 21 | 22 | _Are there any open questions about this implementation that need answers?_ 23 | 24 | ## Other 25 | 26 | _Is there anything else we should know? Delete this section if you don't need it._ 27 | 28 | ## Tasks 29 | 30 | _List any tasks you need to do here, if any. Delete this section if you don't need it._ 31 | 32 | - [ ] _Example task._ 33 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: weekly 7 | time: '13:00' 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: y18n 11 | versions: 12 | - 4.0.1 13 | - dependency-name: husky 14 | versions: 15 | - 4.3.8 16 | - 5.0.9 17 | - 5.1.0 18 | - 5.1.3 19 | - 5.2.0 20 | - dependency-name: '@commitlint/cli' 21 | versions: 22 | - 11.0.0 23 | - 12.0.1 24 | - dependency-name: '@commitlint/config-conventional' 25 | versions: 26 | - 11.0.0 27 | - 12.0.1 28 | - dependency-name: '@types/prettier' 29 | versions: 30 | - 2.1.6 31 | - 2.2.0 32 | - 2.2.1 33 | - 2.2.2 34 | - dependency-name: tslib 35 | versions: 36 | - 2.1.0 37 | -------------------------------------------------------------------------------- /.github/workflows/automate-PRs.yml: -------------------------------------------------------------------------------- 1 | name: automate-PRs 2 | # The script auto merges PRs 3 | 4 | on: 5 | pull_request: 6 | branches: 7 | - master 8 | types: 9 | - opened 10 | - synchronize 11 | 12 | jobs: 13 | automate-PRs: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node-version: [14.x] 18 | os: [ubuntu-latest, windows-latest, macos-latest] 19 | steps: 20 | - uses: actions/checkout@v2 21 | - uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | 25 | # Specific for the project 26 | - run: chmod 755 ./ci/ci-test-commands.sh 27 | - run: ./ci/ci-test-commands.sh 28 | 29 | automate-PRs-merge: 30 | needs: automate-PRs 31 | runs-on: ubuntu-latest 32 | permissions: 33 | pull-requests: write 34 | contents: write 35 | steps: 36 | - uses: fastify/github-action-merge-dependabot@v3 37 | with: 38 | github-token: ${{ secrets.GITHUB_TOKEN }} 39 | target: minor 40 | -------------------------------------------------------------------------------- /.github/workflows/close-stale-issues-and-PRs.yml: -------------------------------------------------------------------------------- 1 | name: close-stale-issues-and-PRs 2 | # The script closes stale issues and PRs 3 | 4 | on: 5 | schedule: 6 | - cron: '0 1 * * *' # Every day at 1:00 7 | 8 | jobs: 9 | close-stale-issues-and-PRs: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/stale@v4 13 | with: 14 | stale-issue-message: 'Inactive issue' 15 | stale-pr-message: 'Inactive PR' 16 | days-before-stale: 2 17 | days-before-close: 1 18 | only-labels: 'dependencies' 19 | delete-branch: true 20 | -------------------------------------------------------------------------------- /.github/workflows/gha-publish-to-npm.yml: -------------------------------------------------------------------------------- 1 | name: gha-publish-to-npm 2 | # The script publishes to NPM 3 | 4 | on: 5 | schedule: 6 | - cron: '0 1 15 * *' # On 15th, every month at 1:00 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - run: git config --global user.name 'gha-publish-to-npm' 14 | - run: git config --global user.email 'gha-publish-to-npm@users.noreply.github.com' 15 | - run: npm version patch 16 | 17 | # Specific for the project 18 | - run: chmod 755 ./ci/ci-test-commands.sh 19 | - run: ./ci/ci-test-commands.sh 20 | - run: git push 21 | - uses: JS-DevTools/npm-publish@v1 22 | with: 23 | token: ${{ secrets.NPM_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | # The script runs tests Specific for the project 3 | 4 | on: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | test: 11 | name: test 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [14.x] 16 | os: [ubuntu-latest, windows-latest, macos-latest] 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-node@v2 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | 23 | # Specific for the project 24 | - run: chmod 755 ./ci/ci-test-commands.sh 25 | - run: ./ci/ci-test-commands.sh 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .idea 3 | .DS_Store 4 | .cache 5 | node_modules 6 | 7 | coverage 8 | lib 9 | esm5 10 | lib-esm 11 | esm2015 12 | lib-fesm 13 | fesm 14 | umd 15 | bundles 16 | typings 17 | docs 18 | dist 19 | .history 20 | 21 | ## this is generated by `npm pack` 22 | *.tgz 23 | package -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn lint-staged 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn style && yarn test -- --bail --onlyChanged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | *.log 3 | 4 | # tools configs 5 | **/tsconfig.json 6 | tsconfig.*.json 7 | **/webpack.config.js 8 | **/jest.config.js 9 | **/prettier.config.js 10 | 11 | # build scripts 12 | config/ 13 | scripts/ 14 | 15 | # Test files 16 | **/*.spec.js 17 | **/*.test.js 18 | **/*.test.d.ts 19 | **/*.spec.d.ts 20 | __tests__ 21 | coverage 22 | 23 | # Sources 24 | node_modules 25 | src 26 | docs 27 | examples 28 | 29 | ## this is generated by `npm pack` 30 | *.tgz 31 | package 32 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "arrowParens": "always", 4 | "semi": false, 5 | "bracketSpacing": true, 6 | "trailingComma": "es5", 7 | "printWidth": 80 8 | } 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'lts/*' 4 | cache: yarn 5 | notifications: 6 | email: false 7 | env: 8 | - CI=true 9 | before_install: 10 | - npm config set scripts-prepend-node-path true 11 | - npm i -g yarn 12 | install: 13 | - yarn 14 | script: 15 | - yarn build 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [2.0.1](https://github.com/codeparticle/formal/compare/v2.0.0...v2.0.1) (2022-04-13) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * **v2.0.1:** fix exports in package.json ([56e1fcb](https://github.com/codeparticle/formal/commit/56e1fcb36e778caf9e0fec470866c4d6f91591f4)) 11 | 12 | ## [2.0.0](https://github.com/codeparticle/formal/compare/v1.0.5...v2.0.0) (2022-04-13) 13 | 14 | 15 | ### Features 16 | 17 | * **update-build:** update build to use tsup, rm rollup, update eslint ([8f24093](https://github.com/codeparticle/formal/commit/8f24093c62285c0f7c384d6a51826627f09b050d)) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * **github:** fix builds due to missing jest types ([70c5eac](https://github.com/codeparticle/formal/commit/70c5eacdfe3af4b138437565507cc9a070035c3b)) 23 | * **github:** revert back to yarn from pnpm ([c3a173f](https://github.com/codeparticle/formal/commit/c3a173f46da604727946b0486b2a262a7d75221c)) 24 | 25 | ### [1.0.5](https://github.com/codeparticle/formal/compare/v1.0.4...v1.0.5) (2022-01-20) 26 | 27 | ### [1.0.4](https://github.com/codeparticle/formal/compare/v1.0.3...v1.0.4) (2021-12-10) 28 | 29 | ### Bug Fixes 30 | 31 | - **deps:** remove git add from lint-staged script ([d03e998](https://github.com/codeparticle/formal/commit/d03e998194f07799d5b5a5495b17f9f7b12dbb1b)) 32 | - **rules:** fix object rules throwing when objects are null ([2b16deb](https://github.com/codeparticle/formal/commit/2b16deb9ab3be5cae6c00c4b19d65d91f4519c6d)) 33 | 34 | ### [1.0.3](https://github.com/codeparticle/formal/compare/v1.0.2...v1.0.3) (2021-06-30) 35 | 36 | ### Bug Fixes 37 | 38 | - **scripts:** fix npm publish script ([3c598c9](https://github.com/codeparticle/formal/commit/3c598c933c0813b6f1f1e77d6bb8a026e9821506)) 39 | 40 | ### [1.0.2](https://github.com/codeparticle/formal/compare/v1.0.1...v1.0.2) (2021-06-23) 41 | 42 | ### [1.0.1](https://github.com/codeparticle/formal/compare/v1.0.0...v1.0.1) (2021-05-24) 43 | 44 | ### [0.4.7](https://github.com/codeparticle/formal/compare/v0.4.2...v0.4.7) (2021-05-24) 45 | 46 | ### Features 47 | 48 | - **deps:** update deps ([8382dc9](https://github.com/codeparticle/formal/commit/8382dc99570e3405b3cd0482d0de8f853098e523)) 49 | 50 | ### Bug Fixes 51 | 52 | - **errors:** fix errors accumulation ([724b424](https://github.com/codeparticle/formal/commit/724b4241ba9111080273e30e3638fb91c7363e3d)) 53 | - **fail:** do not map transformations over values in Fail ([433f29d](https://github.com/codeparticle/formal/commit/433f29d4035f901bf229a8e452c893b86a0d28a0)) 54 | - **rules:** update Rules with new interface, fix withMessage overwriting functions ([6ef04c6](https://github.com/codeparticle/formal/commit/6ef04c6d1772e486e7918a6cbb0dce1dd9c66ea4)) 55 | 56 | ### [0.4.2](https://github.com/codeparticle/formal/compare/v0.4.1...v0.4.2) (2020-05-10) 57 | 58 | ## [0.4.0](https://github.com/codeparticle/formal/compare/v0.3.0...v0.4.0) (2020-02-25) 59 | 60 | ### Bug Fixes 61 | 62 | - **linter:** add markdown file ext to parser opts ([359a768](https://github.com/codeparticle/formal/commit/359a768)) 63 | - **linter:** don't try to lint non-js ([f420c46](https://github.com/codeparticle/formal/commit/f420c46)) 64 | - **tests:** rm dupe utils file, fix tests ([963a11c](https://github.com/codeparticle/formal/commit/963a11c)) 65 | - **tests:** temp disable type checking for tests ([2625d95](https://github.com/codeparticle/formal/commit/2625d95)) 66 | - **tests:** temp disable type checks in tests as they are incorrect ([fe5ef34](https://github.com/codeparticle/formal/commit/fe5ef34)) 67 | 68 | ### Features 69 | 70 | - **build:** move tests and remove them from builds ([7972fa1](https://github.com/codeparticle/formal/commit/7972fa1)) 71 | - **docs:** update docs ([d3c192b](https://github.com/codeparticle/formal/commit/d3c192b)) 72 | - **rules:** add is-equal-to rule ([d66b82c](https://github.com/codeparticle/formal/commit/d66b82c)) 73 | - **rules:** make getProp and hasProp variadic. ([20c0255](https://github.com/codeparticle/formal/commit/20c0255)) 74 | 75 | 76 | 77 | # [0.3.0](https://github.com/codeparticle/formal/compare/v0.2.1...v0.3.0) (2019-07-23) 78 | 79 | ### Bug Fixes 80 | 81 | - **rules:** minor bugfixes, message normalization across rules. ([5b332ef](https://github.com/codeparticle/formal/commit/5b332ef)) 82 | 83 | ### Features 84 | 85 | - **docs:** update the docs to include an example of validateObject ([7056a05](https://github.com/codeparticle/formal/commit/7056a05)) 86 | - **validateObject:** added validateObject util. ([e9b413f](https://github.com/codeparticle/formal/commit/e9b413f)) 87 | 88 | 89 | 90 | ## [0.2.1](https://github.com/codeparticle/formal/compare/v0.2.0...v0.2.1) (2019-06-10) 91 | 92 | 93 | 94 | # [0.2.0](https://github.com/codeparticle/formal/compare/v0.1.0...v0.2.0) (2019-06-10) 95 | 96 | ### Bug Fixes 97 | 98 | - **builtins:** clarity and edge-case fixes ([f3e6a2c](https://github.com/codeparticle/formal/commit/f3e6a2c)) 99 | 100 | ### Features 101 | 102 | - **builtins:** added isNonEmptyString, matchesRegex, and isValidEmail. ([bb0447a](https://github.com/codeparticle/formal/commit/bb0447a)) 103 | 104 | 105 | 106 | # [0.1.0](https://github.com/codeparticle/formal/compare/v0.0.6...v0.1.0) (2019-06-08) 107 | 108 | ### Features 109 | 110 | - **rules:** updated createRule to accept a transform prop to change the value of the output validation ([e09ce49](https://github.com/codeparticle/formal/commit/e09ce49)) 111 | - **tests:** updated tests to include new Rule class. ([67dfac2](https://github.com/codeparticle/formal/commit/67dfac2)) 112 | - **withMessage:** added withMessage function to overwrite messages in built-in checks. ([7e49a92](https://github.com/codeparticle/formal/commit/7e49a92)) 113 | - updated README ([b799586](https://github.com/codeparticle/formal/commit/b799586)) 114 | 115 | 116 | 117 | ## [0.0.7](https://github.com/codeparticle/formal/compare/v0.0.6...v0.0.7) (2019-06-08) 118 | 119 | 120 | 121 | ## [0.0.6](https://github.com/codeparticle/formal/compare/v0.0.5...v0.0.6) (2019-06-04) 122 | 123 | 124 | 125 | ## [0.0.5](https://github.com/codeparticle/formal/compare/v0.0.4...v0.0.5) (2019-06-04) 126 | 127 | 128 | 129 | ## [0.0.4](https://github.com/codeparticle/formal/compare/v0.0.3...v0.0.4) (2019-06-04) 130 | 131 | 132 | 133 | ## [0.0.3](https://github.com/codeparticle/formal/compare/v0.0.2...v0.0.3) (2019-06-03) 134 | 135 | 136 | 137 | ## [0.0.2](https://github.com/codeparticle/formal/compare/v0.0.1...v0.0.2) (2019-06-03) 138 | 139 | 140 | 141 | ## 0.0.1 (2019-06-03) 142 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Nick Krause 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 | # @codeparticle/formal 2 | 3 | > A simple data and form validation library with a wide range of possibilities. 4 | 5 | ### 🔧 Installation 6 | 7 | ```sh 8 | yarn add @codeparticle/formal 9 | ``` 10 | 11 | ## Usage 12 | 13 | Formal makes it simple to validate arbitrary data with whatever rules you'd like. It also guarantees that when things fail, you know why, and have a plan B in place. 14 | 15 | Here's a playful example: 16 | 17 | ```ts 18 | import { Validator } from '@codeparticle/formal' 19 | import { isString, minLength } from '@codeparticle/lib/rules' 20 | 21 | const techPitchValidator = Validator.of(isString, minLength(32), maxLength(256)) 22 | 23 | const validPitch = "It's like your favorite social network, but for dogs" 24 | const invalidPitch = "It's an AI to rule us all" 25 | 26 | const validResult = techPitchValidator 27 | .validate(validPitch) 28 | .map((str) => str.toUpperCase()) 29 | // returns Success("IT'S LIKE YOUR FAVORITE SOCIAL NETWORK, BUT FOR DOGS") - maps can change successfully checked values 30 | 31 | const invalidResult = techPitchValidator 32 | .validate(invalidPitch) 33 | .map((str) => str.toUpperCase()) 34 | // returns Fail('Must be at least 32 characters') - maps have no effect on failures 35 | 36 | validResult.then({ 37 | onSuccess: () => alert("We love it kid. Here's 5 million."), 38 | onFail: (errs) => console.log(errs), // can also simply be console.log 39 | }) 40 | 41 | invalidResult.then({ 42 | onSuccess: () => alert("We love it kid. Here's 5 million."), 43 | onFail: (errs) => errs.map(console.log), // 'Must be at least 32 characters' 44 | }) 45 | ``` 46 | 47 | ### validateObject 48 | 49 | For validating forms or large API responses, formal exposes a `validateObject` utility. Here's an example 50 | of a form with a required name and email field. 51 | 52 | ```ts 53 | import { rules, validateObject } from '@codeparticle/formal' 54 | 55 | const { isNonEmptyString, isValidEmail } = rules 56 | 57 | const validateForm = validateObject({ 58 | name: [isNonEmptyString], 59 | email: [isNonEmptyString, isValidEmail], 60 | }) 61 | 62 | const formValues = { 63 | name: 'hello', 64 | email: '', 65 | } 66 | 67 | validateForm(formValues) 68 | ``` 69 | 70 | calling this will return a schema like this: 71 | 72 | ```ts 73 | { 74 | hasErrors: true, 75 | errors: { 76 | email: ['Value must not be empty.', 'Must be a valid email'] 77 | }, 78 | values: { 79 | name: 'hello', 80 | email: '' 81 | } 82 | } 83 | ``` 84 | 85 | formal is flexible to your style, and exposes a `pipeValidators` function for writing validations in a more functional way. It condenses multiple checks into a function that encloses a value into a `Success` or `Fail` container. 86 | 87 | Once run, these validation containers are supplied with an `isSuccess` property for use in filters,with the ability to reach for the internally held `value`. While not recommended for control flow, it's useful in cases where you're running validation over a long list of items, as well as in writing test cases. 88 | 89 | ```ts 90 | import { pipeValidators, rules } from '@codeparticle/formal' 91 | const { isString, minLength } = rules 92 | 93 | // ... 94 | const isLongString = pipeValidators([isString, minLength(50)]) 95 | 96 | values 97 | .filter((val) => isLongString(val).isSuccess) 98 | .map((container) => container.value) 99 | .map((str) => console.log(str)) 100 | 101 | // this technique can make testing a breeze. 102 | 103 | // here, we want all of our objects to have a common property; maybe a required prop in a react component. 104 | 105 | // while a bit contrived here, this method makes tests over complex, nested objects 106 | // or other data simple to do. 107 | 108 | const testObjects = [ 109 | { required: 'present' }, 110 | { required: 'present' }, 111 | { required: 'wrong' }, 112 | ] 113 | 114 | const check = pipeValidators([isObject, hasProp('required')]) 115 | 116 | for (const test of testObjects) { 117 | expect(check(test).isSuccess).toBe(true) // passes 118 | 119 | expect(check(test).value).toBe('present') // fails 120 | } 121 | ``` 122 | 123 | ### Built-in Validators 124 | 125 | Formal has a small set of useful checks built in to validate simple data. 126 | 127 | ```ts 128 | import { 129 | // Basic validations that take no arguments when used in Validator.of // 130 | isString, 131 | isNumber, 132 | isObject, 133 | isArray, 134 | isNonEmptyString, 135 | isNonEmptyObject, 136 | isNonEmptyArray, 137 | isValidEmail, // Only validates format, not ownership. 138 | 139 | /** 140 | * checks if something is equal to another value. 141 | * if you're using this in validateObject to check that two fields match, 142 | * use it as [values => isEqualTo(values['otherFormField'])] 143 | */ 144 | isEqualTo, 145 | 146 | // Validations that take arguments before being supplied to Validator or pipeValidators() // 147 | 148 | 149 | // Check that a value matches a given regex. matchesRegex(/[A-Z]) || matchesRegex(RegExp('[A-Z]')) 150 | matchesRegex, 151 | // Check that a string is x-characters long. Takes a value for the minimum. `minLength(10)` 152 | minLength, 153 | // Check that a string is no more than x-characters long. Takes a value for the maximum. `maxLength(50)` 154 | maxLength, 155 | // Check that a number is less than a certain amount. Takes a value for the minimum. `lessThan(50)` 156 | lessThan, 157 | // Check that a number is less than a certain amount. Takes a value for the maximum. `greaterThan(50)` 158 | greaterThan, 159 | // check that an object has a certain property. Takes a drilldown path supplied as strings. `hasProp('fieldName', 'subfield')` 160 | hasProp, 161 | // check that an object has a property at the given drilldown path, then return a Success object with its value. `getProp('fieldName', 'subfield')` 162 | getProp 163 | } from '@codeparticle/formal'; 164 | ... 165 | ``` 166 | 167 | ### Customizing built-in or existing checks 168 | 169 | Sometimes, the messages included with built-in or existing checks need to be modified after the fact. Formal supports this via the `withMessage` function. 170 | 171 | `withMessage` creates a new copy of the rule, so don't worry about accidentally overwriting something important when using it. Like `createRule`, you can supply a string, or a function that returns a string. 172 | 173 | ```ts 174 | import { withMessage, rules } from '@codeparticle/formal' 175 | 176 | const withAdminFormErrorMessage = withMessage( 177 | `Admins must enter an administrator ID.` 178 | ) 179 | const withUserFormErrorMessage = withMessage( 180 | `Users must enter their first and last name to sign up` 181 | ) 182 | 183 | const withInternationalizedErrorMessage = withMessage( 184 | intl.formatMessage('form.error.message') 185 | ) 186 | 187 | const withNewMessageFn = withMessage( 188 | (badValue) => `${badValue} is invalid for this field.` 189 | ) 190 | 191 | const adminFormFieldCheck = withAdminFormErrorMessage(rules.isNonEmptyString) 192 | 193 | const userFormFieldCheck = withUserFormErrorMessage(rules.isNonEmptyString) 194 | 195 | const internationalizedFieldCheck = withInternationalizedErrorMessage( 196 | rules.isString 197 | ) 198 | 199 | const customMessageFunctionFieldCheck = withNewMessageFn(rules.isNonEmptyString) 200 | ``` 201 | 202 | ## Creating your own validators 203 | 204 | Formal gives you the ability to create your own rules to supply to `Validator`. There are two ways to do so. 205 | 206 | Using `createRule` is a quick way to check things that _don't change the value that comes out_. 207 | 208 | `createRule` takes two (required) options - a condition function, and a message. The message can be a string, or it can be a function that returns a string, in case you'd like to tailor your messages. 209 | 210 | ```ts 211 | import { createRule } from '@codeparticle/formal' 212 | export const containsMatchingString = (match) => 213 | createRule({ 214 | condition: (str) => str.includes(match), 215 | message: (failedStr) => `Value ${failedStr} does not include today's date`, 216 | }) 217 | ``` 218 | 219 | createRule also allows more customized checks through an optional parameter called `transform` 220 | that allows for a transformation of the value _before_ it's handed off to the next validation check. 221 | 222 | ```ts 223 | import { createRule } from '../rule' 224 | import { hasProp } from './has-prop' 225 | 226 | // below is the actual source code of the built-in getProp function. 227 | export const getProp = (property) => 228 | createRule({ 229 | condition: (obj) => hasProp(property).check(obj).isSuccess, 230 | message: `Property '${property}' does not exist`, 231 | // transform the object to the value of the successfully found property 232 | // before handing off to the next check / function. 233 | transform: (obj) => obj[property], 234 | }) 235 | ``` 236 | 237 | ## Usage with Typescript 238 | 239 | This package exports TS type definitions in the `/types` folder. 240 | 241 | ```ts 242 | import { 243 | // interfaces for the Success and Fail classes 244 | Success, 245 | Fail, 246 | // used to specify the argument structure to `createValidator` 247 | CustomValidationOptions, 248 | // options supplied to Validator.then 249 | ValidationActions, 250 | // alias for `Success | Fail` in cases where both are needed. 251 | ValidationM, 252 | // aliases for a single or array of validation rules provided to Validator.of 253 | ValidationRule, 254 | // alias for a function that returns a Success or Failure. 255 | ValidationCheck, 256 | ValidationRuleSet, 257 | // interface for the Validator class 258 | Validator, 259 | } from '@codeparticle/formal/types' 260 | ``` 261 | 262 | [![Build Status](https://travis-ci.org/codeparticle/codeparticle-formal.svg?branch=master)](https://travis-ci.org/codeparticle/codeparticle-formal) 263 | [![NPM version](https://img.shields.io/npm/v/@codeparticle/codeparticle-formal.svg)](https://www.npmjs.com/package/@codeparticle/codeparticle-formal) 264 | ![Downloads](https://img.shields.io/npm/dm/@codeparticle/codeparticle-formal.svg) 265 | [![Standard Version](https://img.shields.io/badge/release-standard%20version-brightgreen.svg)](https://github.com/conventional-changelog/standard-version) 266 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 267 | [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) 268 | 269 | --- 270 | 271 | ### 🥂 License 272 | 273 | [MIT](./LICENSE.md) as always 274 | -------------------------------------------------------------------------------- /ci/ci-test-commands.sh: -------------------------------------------------------------------------------- 1 | # The script commands Specific for the project 2 | yarn install 3 | yarn run lint 4 | yarn run build 5 | yarn run test 6 | yarn run test:ci 7 | yarn run verify -------------------------------------------------------------------------------- /config/commitlint.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@commitlint/core').Config} 3 | */ 4 | const config = { extends: ['@commitlint/config-conventional'] } 5 | 6 | module.exports = config 7 | -------------------------------------------------------------------------------- /config/global.d.ts: -------------------------------------------------------------------------------- 1 | // ============================ 2 | // extend existing types 3 | // ============================ 4 | declare namespace NodeJS { 5 | interface ProcessEnv { 6 | CI: 'true' | 'false' 7 | } 8 | } 9 | 10 | // ============================ 11 | // Rollup plugins without types 12 | // ============================ 13 | type RollupPluginImpl = 14 | import('rollup').PluginImpl 15 | 16 | declare module 'rollup-plugin-json' { 17 | export interface Options { 18 | /** 19 | * All JSON files will be parsed by default, but you can also specifically include/exclude files 20 | */ 21 | include?: string | string[] 22 | exclude?: string | string[] 23 | /** 24 | * for tree-shaking, properties will be declared as variables, using either `var` or `const` 25 | * @default false 26 | */ 27 | preferConst?: boolean 28 | /** 29 | * specify indentation for the generated default export — defaults to '\t' 30 | * @default '\t' 31 | */ 32 | indent?: string 33 | } 34 | const plugin: RollupPluginImpl 35 | export default plugin 36 | } 37 | declare module 'rollup-plugin-sourcemaps' { 38 | const plugin: RollupPluginImpl 39 | export default plugin 40 | } 41 | declare module 'rollup-plugin-node-resolve' { 42 | const plugin: RollupPluginImpl 43 | export default plugin 44 | } 45 | declare module 'rollup-plugin-commonjs' { 46 | const plugin: RollupPluginImpl 47 | export default plugin 48 | } 49 | declare module 'rollup-plugin-replace' { 50 | const plugin: RollupPluginImpl 51 | export default plugin 52 | } 53 | declare module 'rollup-plugin-uglify' { 54 | const uglify: RollupPluginImpl 55 | export { uglify } 56 | } 57 | declare module 'rollup-plugin-terser' { 58 | const terser: RollupPluginImpl 59 | export { terser } 60 | } 61 | 62 | // =====================∫ 63 | // missing library types 64 | // ===================== 65 | 66 | // ts-jest types require 'babel__core' 67 | declare module 'babel__core' { 68 | interface TransformOptions {} 69 | } 70 | 71 | declare module '@commitlint/core' { 72 | interface Config { 73 | extends: string[] 74 | } 75 | } 76 | declare module 'sort-object-keys' { 77 | const sortPackageJson: ( 78 | object: T, 79 | sortWith?: (...args: any[]) => any 80 | ) => T 81 | export = sortPackageJson 82 | } 83 | 84 | declare module 'replace-in-file' { 85 | interface Options { 86 | files: string | string[] 87 | from: Array 88 | to: string | string[] 89 | ignore: string | string[] 90 | dry: boolean 91 | encoding: string 92 | disableGlobs: boolean 93 | allowEmptyPaths: boolean 94 | } 95 | 96 | interface API { 97 | (options: Partial): string[] 98 | sync(options: Partial): string[] 99 | } 100 | 101 | const api: API 102 | export = api 103 | } 104 | 105 | declare module 'gzip-size' { 106 | type Options = import('zlib').ZlibOptions 107 | type Input = string | Buffer 108 | 109 | function gzipSize(input: Input, options?: Options): Promise 110 | namespace gzipSize { 111 | function sync(input: Input, options?: Options): number 112 | function stream(options?: Options): import('stream').PassThrough 113 | function file(path: string, options?: Options): Promise 114 | function fileSync(path: string, options?: Options): number 115 | } 116 | 117 | export = gzipSize 118 | } 119 | 120 | declare module 'brotli-size' { 121 | type Input = string | Buffer 122 | 123 | namespace brotliSize { 124 | function sync(input: Input): number 125 | function stream(): import('stream').PassThrough 126 | } 127 | 128 | function brotliSize(input: Input): Promise 129 | 130 | export = brotliSize 131 | } 132 | 133 | declare module 'pretty-bytes' { 134 | type Options = { 135 | /** 136 | * @default false 137 | */ 138 | signed: boolean 139 | /** 140 | * @default false 141 | */ 142 | locale: string | boolean 143 | } 144 | 145 | function prettyBytes(input: number, options?: Partial): string 146 | 147 | export = prettyBytes 148 | } 149 | -------------------------------------------------------------------------------- /config/helpers.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | camelCaseToDash, 3 | dashToCamelCase, 4 | toUpperCase, 5 | pascalCase, 6 | normalizePackageName, 7 | getOutputFileName, 8 | } 9 | 10 | /** 11 | * 12 | * @param {string} myStr 13 | */ 14 | function camelCaseToDash(myStr) { 15 | return myStr.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() 16 | } 17 | 18 | /** 19 | * 20 | * @param {string} myStr 21 | */ 22 | function dashToCamelCase(myStr) { 23 | return myStr.replace(/-([a-z])/g, (g) => g[1].toUpperCase()) 24 | } 25 | 26 | /** 27 | * 28 | * @param {string} myStr 29 | */ 30 | function toUpperCase(myStr) { 31 | return `${myStr.charAt(0).toUpperCase()}${myStr.substr(1)}` 32 | } 33 | 34 | /** 35 | * 36 | * @param {string} myStr 37 | */ 38 | function pascalCase(myStr) { 39 | return toUpperCase(dashToCamelCase(myStr)) 40 | } 41 | 42 | /** 43 | * 44 | * @param {string} rawPackageName 45 | */ 46 | function normalizePackageName(rawPackageName) { 47 | const scopeEnd = rawPackageName.indexOf('/') + 1 48 | 49 | return rawPackageName.substring(scopeEnd) 50 | } 51 | 52 | /** 53 | * 54 | * @param {string} fileName 55 | * @param {boolean?} isProd 56 | */ 57 | function getOutputFileName(fileName, isProd = false) { 58 | return isProd ? fileName.replace(/\.js$/, '.min.js') : fileName 59 | } 60 | -------------------------------------------------------------------------------- /config/jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | preset: 'ts-jest', 3 | rootDir: '..', 4 | testMatch: [ 5 | '/src/**/__tests__/**/*.ts?(x)', 6 | '/src/**/?(*.)+(spec|test).ts?(x)', 7 | ], 8 | testPathIgnorePatterns: ['dist'], 9 | coverageThreshold: { 10 | global: { 11 | branches: 80, 12 | functions: 80, 13 | lines: 80, 14 | statements: 80, 15 | }, 16 | }, 17 | setupFiles: ['/config/setup-tests.js'], 18 | watchPlugins: [ 19 | 'jest-watch-typeahead/filename', 20 | 'jest-watch-typeahead/testname', 21 | ], 22 | } 23 | 24 | module.exports = config 25 | -------------------------------------------------------------------------------- /config/setup-tests.js: -------------------------------------------------------------------------------- 1 | // add here any code that you wanna execute before tests like 2 | // - polyfills 3 | // - some custom code 4 | // for more docs check see https://jestjs.io/docs/en/configuration.html#setupfiles-array 5 | -------------------------------------------------------------------------------- /config/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2017", 5 | "module": "commonjs", 6 | "allowJs": true, 7 | "checkJs": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "noEmit": true, 11 | "importHelpers": false 12 | }, 13 | "include": [".", "../jest.config.js"] 14 | } 15 | -------------------------------------------------------------------------------- /config/types.d.ts: -------------------------------------------------------------------------------- 1 | // ===== JEST ==== 2 | export type TsJestConfig = import('ts-jest/dist/types').TsJestConfig 3 | export type JestConfig = Partial 4 | 5 | // ==== PRETTIER ==== 6 | export type PrettierConfig = import('prettier').Options 7 | 8 | // ==== ROLLUP ==== 9 | export type RollupConfig = import('rollup').InputOptions & { 10 | output: 11 | | import('rollup').OutputOptions 12 | | Array 13 | } 14 | export type RollupPlugin = import('rollup').Plugin 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | preset: `ts-jest`, 3 | rootDir: `.`, 4 | testMatch: [`/test/**/*.ts?(x)`], 5 | testPathIgnorePatterns: [`dist`], 6 | coverageThreshold: { 7 | global: { 8 | branches: 80, 9 | functions: 80, 10 | lines: 80, 11 | statements: 80, 12 | }, 13 | }, 14 | setupFiles: [`/config/setup-tests.js`], 15 | watchPlugins: [ 16 | `jest-watch-typeahead/filename`, 17 | `jest-watch-typeahead/testname`, 18 | ], 19 | } 20 | 21 | // eslint-disable-next-line unicorn/prefer-module 22 | module.exports = config 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@codeparticle/formal", 3 | "version": "2.0.40", 4 | "description": "A <2kb library for validating data of any kind", 5 | "keywords": [ 6 | "validation", 7 | "formal", 8 | "form", 9 | "prop-types", 10 | "codeparticle" 11 | ], 12 | "main": "./index.js", 13 | "module": "./index.mjs", 14 | "es2015": "./index.mjs", 15 | "typings": "./index.d.ts", 16 | "sideEffects": false, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/codeparticle/formal" 20 | }, 21 | "author": "Nick Krause ", 22 | "license": "MIT", 23 | "engines": { 24 | "node": ">=12.0.0" 25 | }, 26 | "scripts": { 27 | "cleanup": "shx rm -rf", 28 | "prebuild": "yarn cleanup && yarn verify", 29 | "build": "tsup", 30 | "postbuild": "node scripts/copy && yarn size", 31 | "docs": "typedoc -p . --theme minimal --target 'es6' --excludeNotExported --excludePrivate --ignoreCompilerErrors --exclude \"**/src/**/__tests__/*.*\" --out docs src/", 32 | "test": "jest -c ./jest.config.js", 33 | "test:watch": "yarn test -- --watch", 34 | "test:coverage": "yarn test -- --coverage", 35 | "test:ci": "yarn test -- --ci", 36 | "validate-js": "tsc -p ./config && tsc -p ./scripts", 37 | "verify": "yarn validate-js && yarn style && yarn test:ci", 38 | "commit": "git-cz", 39 | "lint": "eslint 'src/**/*.ts' 'test/**/*.ts'", 40 | "style": "yarn lint -- --fix", 41 | "prerelease": "yarn build", 42 | "release": "standard-version", 43 | "postrelease": "node scripts/copy && node scripts/build && yarn release:github && npm run release:npm", 44 | "release:github": "git push --no-verify --follow-tags origin master", 45 | "release:npm": "npm publish ./dist --access public", 46 | "release:preflight": "npm pack ./dist --dry-run", 47 | "size": "node scripts/file-size ./dist/index.js" 48 | }, 49 | "config": { 50 | "commitizen": { 51 | "path": "cz-conventional-changelog" 52 | } 53 | }, 54 | "lint-staged": { 55 | "**/*.{ts,tsx,js,jsx}": [ 56 | "yarn style" 57 | ] 58 | }, 59 | "dependencies": { 60 | "case": "^1.6.3" 61 | }, 62 | "devDependencies": { 63 | "@commitlint/cli": "^17.0.0", 64 | "@commitlint/config-conventional": "^17.0.0", 65 | "@types/jest": "27.5.0", 66 | "@types/node": "^18.0.0", 67 | "@types/prettier": "^2.6.0", 68 | "@types/webpack-config-utils": "2.3.4", 69 | "@typescript-eslint/eslint-plugin": "^5.19.0", 70 | "@typescript-eslint/parser": "5.62.0", 71 | "brotli-size": "^4.0.0", 72 | "commitizen": "^4.2.4", 73 | "cz-conventional-changelog": "^3.3.0", 74 | "eslint": "^8.13.0", 75 | "eslint-config-adjunct": "^4.11.1", 76 | "eslint-config-prettier": "^8.5.0", 77 | "eslint-plugin-array-func": "^3.1.7", 78 | "eslint-plugin-eslint-comments": "^3.2.0", 79 | "eslint-plugin-html": "^7.1.0", 80 | "eslint-plugin-jest": "^27.0.1", 81 | "eslint-plugin-jest-async": "^1.0.3", 82 | "eslint-plugin-json": "^3.1.0", 83 | "eslint-plugin-markdown": "^3.0.0", 84 | "eslint-plugin-no-constructor-bind": "^2.0.4", 85 | "eslint-plugin-no-secrets": "^0.9.1", 86 | "eslint-plugin-no-unsanitized": "^4.0.1", 87 | "eslint-plugin-no-use-extend-native": "^0.5.0", 88 | "eslint-plugin-optimize-regex": "^1.2.1", 89 | "eslint-plugin-promise": "^6.0.0", 90 | "eslint-plugin-scanjs-rules": "^0.2.1", 91 | "eslint-plugin-security": "^1.4.0", 92 | "eslint-plugin-simple-import-sort": "^8.0.0", 93 | "eslint-plugin-sonarjs": "^0.23.0", 94 | "eslint-plugin-switch-case": "^1.1.2", 95 | "eslint-plugin-unicorn": "^45.0.1", 96 | "gzip-size": "^6.0.0", 97 | "husky": "^8.0.2", 98 | "jest": "^27.5.1", 99 | "jest-watch-typeahead": "^2.0.0", 100 | "kleur": "^4.1.4", 101 | "lint-staged": "^13.0.0", 102 | "prettier": "^2.6.2", 103 | "pretty-bytes": "^5.6.0", 104 | "shx": "^0.3.4", 105 | "standard-version": "^9.3.2", 106 | "ts-jest": "^27.1.4", 107 | "tslib": "^2.3.1", 108 | "tsup": "^6.1.0", 109 | "typedoc": "^0.24.1", 110 | "typescript": "^4.6.3", 111 | "webpack-config-utils": "^2.3.1" 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /scripts/build.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file only purpose is to execute any build related tasks 3 | */ 4 | 5 | const { resolve, normalize } = require(`path`) 6 | const { readFileSync, writeFileSync } = require(`fs`) 7 | const pkg = require(`../package.json`) 8 | 9 | const ROOT = resolve(__dirname, `..`) 10 | const DIST = resolve(ROOT, `dist`) 11 | const TYPES_ROOT_FILE = resolve(DIST, normalize(pkg.typings)) 12 | 13 | main() 14 | 15 | function main() { 16 | writeDtsHeader() 17 | } 18 | 19 | function writeDtsHeader() { 20 | const dtsHeader = getDtsHeader( 21 | pkg.name, 22 | pkg.version, 23 | pkg.author, 24 | pkg.repository.url, 25 | pkg.devDependencies.typescript 26 | ) 27 | 28 | prependFileSync(TYPES_ROOT_FILE, dtsHeader) 29 | } 30 | 31 | /** 32 | * 33 | * @param {string} pkgName 34 | * @param {string} version 35 | * @param {string} author 36 | * @param {string} repoUrl 37 | * @param {string} tsVersion 38 | */ 39 | function getDtsHeader(pkgName, version, author, repoUrl, tsVersion) { 40 | const extractUserName = repoUrl.match(/\.com\/([\w-]+)\/\w+/i) 41 | const githubUserUrl = extractUserName ? extractUserName[1] : `Unknown` 42 | 43 | return ` 44 | // Type definitions for ${pkgName} ${version} 45 | // Project: ${repoUrl} 46 | // Definitions by: ${author} 47 | // Definitions: ${repoUrl} 48 | // TypeScript Version: ${tsVersion} 49 | `.replace(/^\s+/gm, ``) 50 | } 51 | 52 | /** 53 | * 54 | * @param {string} path 55 | * @param {string | Blob} data 56 | */ 57 | function prependFileSync(path, data) { 58 | const existingFileContent = readFileSync(path, { 59 | encoding: `utf8`, 60 | }) 61 | const newFileContent = [data, existingFileContent].join(`\n`) 62 | 63 | writeFileSync(path, newFileContent, { 64 | flag: `w+`, 65 | encoding: `utf8`, 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /scripts/copy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file only purpose is to copy files before npm publish and strip churn/security sensitive metadata from package.json 3 | * 4 | * **NOTE:** 5 | * 👉 This file should not use any 3rd party dependency 6 | */ 7 | const { writeFileSync, copyFileSync, statSync } = require(`fs`) 8 | const { resolve, basename } = require(`path`) 9 | const packageJson = require(`../package.json`) 10 | 11 | main() 12 | 13 | function main() { 14 | const projectRoot = resolve(__dirname, `..`) 15 | const distPath = resolve(projectRoot, `dist`) 16 | const distPackageJson = createDistPackageJson(packageJson) 17 | 18 | const cpFiles = [`README.md`, `CHANGELOG.md`, `LICENSE.md`, `.npmignore`].map( 19 | (file) => resolve(projectRoot, file) 20 | ) 21 | 22 | cp(cpFiles, distPath) 23 | 24 | writeFileSync(resolve(distPath, `package.json`), distPackageJson) 25 | } 26 | 27 | /** 28 | * 29 | * @param {string[]|string} source 30 | * @param {string} target 31 | */ 32 | function cp(source, target) { 33 | const isDir = statSync(target).isDirectory() 34 | 35 | if (isDir) { 36 | if (!Array.isArray(source)) { 37 | throw new Error( 38 | `if is directory you need to provide source as an array` 39 | ) 40 | } 41 | 42 | source.forEach((file) => 43 | copyFileSync(file, resolve(target, basename(file))) 44 | ) 45 | 46 | return 47 | } 48 | 49 | copyFileSync(/** @type {string} */ (source), target) 50 | } 51 | 52 | /** 53 | * @param {typeof packageJson} packageConfig 54 | * @return {string} 55 | */ 56 | function createDistPackageJson(packageConfig) { 57 | const { 58 | devDependencies, 59 | scripts, 60 | engines, 61 | config, 62 | 'lint-staged': lintStaged, 63 | ...distPackageJson 64 | } = packageConfig 65 | 66 | return JSON.stringify(distPackageJson, null, 2) 67 | } 68 | -------------------------------------------------------------------------------- /scripts/file-size.js: -------------------------------------------------------------------------------- 1 | const { basename, normalize } = require(`path`) 2 | const { readFile: readFileCb } = require(`fs`) 3 | const { promisify } = require(`util`) 4 | const readFile = promisify(readFileCb) 5 | 6 | const kolor = require(`kleur`) 7 | const prettyBytes = require(`pretty-bytes`) 8 | const brotliSize = require(`brotli-size`) 9 | const gzipSize = require(`gzip-size`) 10 | const { log } = console 11 | const pkg = require(`../package.json`) 12 | 13 | main() 14 | 15 | async function main() { 16 | const args = process.argv.splice(2) 17 | const filePaths = [...args.map(normalize)] 18 | const fileMetadata = await Promise.all( 19 | filePaths.map(async (filePath) => { 20 | return { 21 | path: filePath, 22 | blob: await readFile(filePath, { 23 | encoding: `utf8`, 24 | }), 25 | } 26 | }) 27 | ) 28 | 29 | const output = await Promise.all( 30 | fileMetadata.map((metadata) => getSizeInfo(metadata.blob, metadata.path)) 31 | ) 32 | 33 | log(getFormatedOutput(pkg.name, output)) 34 | } 35 | 36 | /** 37 | * 38 | * @param {string} pkgName 39 | * @param {string[]} filesOutput 40 | */ 41 | function getFormatedOutput(pkgName, filesOutput) { 42 | const MAGIC_INDENTATION = 3 43 | const WHITE_SPACE = ` `.repeat(MAGIC_INDENTATION) 44 | 45 | return ( 46 | kolor.blue(`${pkgName} bundle sizes: 📦`) + 47 | `\n${WHITE_SPACE}` + 48 | readFile.name + 49 | filesOutput.join(`\n${WHITE_SPACE}`) 50 | ) 51 | } 52 | 53 | /** 54 | * 55 | * @param {number} size 56 | * @param {string} filename 57 | * @param {'br' | 'gz'} type 58 | * @param {boolean} raw 59 | */ 60 | function formatSize(size, filename, type, raw) { 61 | const pretty = raw ? `${size} B` : prettyBytes(size) 62 | const color = size < 5000 ? `green` : size > 40000 ? `red` : `yellow` 63 | const MAGIC_INDENTATION = type === `br` ? 13 : 10 64 | 65 | return `${` `.repeat(MAGIC_INDENTATION - pretty.length)}${kolor[color]( 66 | pretty 67 | )}: ${kolor.white(basename(filename))}.${type}` 68 | } 69 | 70 | /** 71 | * 72 | * @param {string} code 73 | * @param {string} filename 74 | * @param {boolean} [raw=false] 75 | */ 76 | async function getSizeInfo(code, filename, raw = false) { 77 | const isRaw = raw || code.length < 5000 78 | const gzip = formatSize(await gzipSize(code), filename, `gz`, isRaw) 79 | const brotli = formatSize(brotliSize.sync(code), filename, `br`, isRaw) 80 | 81 | return gzip + `\n` + brotli 82 | } 83 | -------------------------------------------------------------------------------- /scripts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../config/tsconfig.json", 3 | "compilerOptions": {}, 4 | "include": [".", "../config/global.d.ts"] 5 | } 6 | -------------------------------------------------------------------------------- /src/fail.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file class to describe validation failures. 3 | * @name Failure.js 4 | * @author Nick Krause 5 | * @license MIT 6 | */ 7 | import { checkIsValidationM } from './internal/utils' 8 | import { ValidationActions, ValidationM } from './types' 9 | 10 | class Fail implements Fail { 11 | static of(value: any, errors: string[] = []) { 12 | return new Fail(value, errors) 13 | } 14 | 15 | value: any = null 16 | errors: string[] = [] 17 | isSuccess = false 18 | 19 | /** 20 | * This constructor allows us to coalesce errors from multiple failed checks into 21 | * one, used when we fold out of this context. 22 | * 23 | */ 24 | constructor(value: any, errors: string[] = []) { 25 | this.value = value 26 | this.errors = errors 27 | } 28 | 29 | /** 30 | * The map function for Fail preserves the value without mapping anything over it, and accumulates errors on the side. 31 | */ 32 | map() { 33 | return new Fail(this.value, this.errors) 34 | } 35 | 36 | /** 37 | * The .chain() for Fail takes another Success or Fail, then combines the results. 38 | */ 39 | chain(validationM: (v: any, e?: string[]) => ValidationM): ValidationM { 40 | try { 41 | const result = validationM(this.value, this.errors) 42 | 43 | checkIsValidationM(result) 44 | 45 | return new Fail(result.value, [...this.errors, ...(result?.errors ?? [])]) 46 | } catch (error) { 47 | console.error(error.message) 48 | console.error(error.stack) 49 | } 50 | } 51 | 52 | /** 53 | * After checks are done, this method is used to extract the value 54 | * with a function that operates on it. 55 | */ 56 | fold({ onFail }: ValidationActions) { 57 | return onFail(this.errors) 58 | } 59 | } 60 | 61 | export { Fail } 62 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Fail } from './fail' 2 | export { pipeValidators, validateObject } from './internal/utils' 3 | export { createRule, withMessage } from './rule' 4 | export * from './rules' 5 | export * as rules from './rules' 6 | export { Success } from './success' 7 | export * from './types' 8 | export * from './utils' 9 | export { Validator } from './validation' -------------------------------------------------------------------------------- /src/internal/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Util functions for validators 3 | * @name utils.js 4 | * @author Nick Krause 5 | * @license MIT 6 | */ 7 | import { ValidationCheck, ValidationM, ValidationRuleset } from '../types' 8 | 9 | class ValidationError extends Error {} 10 | 11 | /** 12 | * id function - returns whatever its input is. 13 | * @param {Any} x - Any param supplied to this function. 14 | * @returns {Any} The param supplied to this function. 15 | */ 16 | const id = (x: any) => x 17 | 18 | const pipeValidators: ( 19 | fns: ValidationRuleset, 20 | values?: any 21 | ) => ValidationCheck = (fns, values) => (value: any) => { 22 | const [first, ...rest] = fns 23 | 24 | const firstCheck = ( 25 | typeof first === `function` ? first(values) : first 26 | ).check(value) 27 | 28 | // starting with the first function that returns a monad, 29 | // we chain through the rest of the functions 30 | // in order to combine them all into a single check. 31 | return rest.reduce( 32 | (prevM, nextM) => 33 | // eslint-disable-next-line @typescript-eslint/unbound-method 34 | prevM.chain( 35 | typeof nextM === `function` ? nextM(values)[`check`] : nextM.check, 36 | ), 37 | firstCheck, 38 | ) 39 | } 40 | 41 | /** 42 | * returnConstant is a convenience method that can be used as an argument to functions that require a function, 43 | * but ultimately do nothing but return a primitive value. 44 | * @param {Any} x - Primitive value to be returned. 45 | */ 46 | function returnConstant(x: T): () => T { 47 | return () => x 48 | } 49 | 50 | /** 51 | * a customized error message to catch incorrect types in Validator.using 52 | * @param {Function} fn - The incorrect function supplied to using(). 53 | * @returns {String} - A tailored error message that attempts to pinpoint the error. 54 | */ 55 | const validationErrorMessage = (fn: (v: any) => any): string => { 56 | return ` 57 | Chaining validation only works if every function has a .chain() method that takes a Success or Fail object. 58 | Check the type of ${fn.constructor.name} - was it written using createRule? 59 | ` 60 | } 61 | 62 | /** 63 | * Function that checks whether the supplied validator is, itself, valid. 64 | * @param {Function} validator - Parameter description. 65 | * @throws {Exception Type} Exception description. 66 | */ 67 | const checkIsValidationM = (validator: ValidationM): void => { 68 | if ( 69 | !( 70 | validator.hasOwnProperty(`value`) && validator.hasOwnProperty(`isSuccess`) 71 | ) 72 | ) { 73 | throw new ValidationError(validationErrorMessage(validator as any)) 74 | } 75 | } 76 | 77 | /** 78 | * Function that takes an object with field names and rules, then applies those rules to the fields 79 | * of another object with the same field names. Great for validation of entire objects at once, like 80 | * forms or API responses. 81 | */ 82 | 83 | const validateObject = 84 | >(fieldRules: Rules) => 85 | >( 86 | values: Vals, 87 | ): { 88 | values: Vals 89 | hasErrors: boolean 90 | errors: Partial> 91 | } => { 92 | const errors = Object.keys(fieldRules).reduce((errs, fieldName) => { 93 | if (!(fieldName in values)) { 94 | throw new Error( 95 | `Field ${fieldName} is not in the object being validated`, 96 | ) 97 | } 98 | 99 | const applyFieldChecks: ValidationCheck = pipeValidators( 100 | fieldRules[fieldName], 101 | values, 102 | ) 103 | const checkResults: ValidationM = applyFieldChecks( 104 | values[fieldName], 105 | values, 106 | ) 107 | 108 | if (!checkResults.isSuccess) { 109 | errs[fieldName] = checkResults.errors 110 | } 111 | 112 | return errs 113 | }, {}) 114 | 115 | return { 116 | values, 117 | hasErrors: Object.keys(errors).length > 0, 118 | errors, 119 | } 120 | } 121 | 122 | export { 123 | checkIsValidationM, 124 | id, 125 | pipeValidators, 126 | returnConstant, 127 | validateObject, 128 | ValidationError, 129 | } 130 | -------------------------------------------------------------------------------- /src/rule.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Rule class for use with createRule and withMessage 3 | * @author Nick Krause 4 | */ 5 | import { Fail } from './fail' 6 | import { Success } from './success' 7 | import { 8 | CustomValidatorOptions, 9 | ValidationCheck, 10 | ValidationRule, 11 | } from './types' 12 | 13 | class Rule implements ValidationRule { 14 | message: string | ((v: any) => string) 15 | opts: CustomValidatorOptions 16 | 17 | constructor(opts: CustomValidatorOptions) { 18 | this.opts = opts 19 | } 20 | 21 | check: ValidationCheck = (value) => { 22 | if (this.opts.condition(value)) { 23 | // Rule and createRule accept an optional function (delayed) parameter 24 | // called transform that allows us to change the value 25 | // before it is passed onto the next check. 26 | if (this.opts.transform) { 27 | return Success.of(this.opts.transform(value)) 28 | } 29 | 30 | return Success.of(value) 31 | } else { 32 | return typeof this.opts.message === `function` 33 | ? Fail.of(value, [this.opts.message(value)].flat()) 34 | : Fail.of(value, [this.opts.message].flat()) 35 | } 36 | } 37 | } 38 | 39 | /** 40 | * Exposed interface to create a rule 41 | */ 42 | const createRule: (opts: CustomValidatorOptions) => ValidationRule = (opts) => { 43 | return new Rule(opts) 44 | } 45 | 46 | /** 47 | * Method to overwrite the message of a rule. 48 | * Useful if you'd like to use a built-in, but want to change the ultimate message 49 | * that comes out of a failed check. 50 | * @param message 51 | */ 52 | const withMessage = 53 | (message: string | ((v?: any) => string)) => (rule: ValidationRule) => 54 | new Rule({ ...rule.opts, message }) 55 | 56 | export { createRule, Rule, withMessage } 57 | -------------------------------------------------------------------------------- /src/rules/get-prop.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Check to see if an object has a property, then return the value of that property. 3 | * @name getProp.js 4 | * @author Nick Krause 5 | * @license MIT 6 | */ 7 | 8 | import { createRule } from '../rule' 9 | 10 | export const getProp = (...properties: string[]) => { 11 | let prop = `` 12 | let currentObjectPath = {} 13 | 14 | return createRule({ 15 | condition: (obj) => { 16 | currentObjectPath = obj 17 | 18 | for (const property of properties) { 19 | if (currentObjectPath.hasOwnProperty(property)) { 20 | prop += `.${property}` 21 | currentObjectPath = currentObjectPath[property] 22 | } else { 23 | return false 24 | } 25 | } 26 | 27 | return true 28 | }, 29 | message: () => { 30 | const path = properties.join(`.`) 31 | 32 | return `Object does not include property ${path.slice( 33 | prop.length - 1, 34 | )} at path .${path}` 35 | }, 36 | 37 | transform: (obj) => properties.reduce((acc, key) => acc[key], obj), 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /src/rules/greater-than.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Check to ensure that a number is greater than a supplied amount. 3 | * @name greater-than.js 4 | * @author Nick Krause 5 | * @license MIT 6 | */ 7 | 8 | import { createRule } from '../rule' 9 | 10 | export const greaterThan = (min) => 11 | createRule({ 12 | condition: (num) => num > min, 13 | message: (num) => `${num} must be greater than ${min}`, 14 | }) 15 | -------------------------------------------------------------------------------- /src/rules/has-prop.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Checks an object to make sure that it has a certain property. 3 | * @name hasProp.js 4 | * @author Nick Krause 5 | * @license MIT 6 | */ 7 | import { createRule } from '../rule' 8 | 9 | export const hasProp = (...properties: string[]) => { 10 | let prop = `` 11 | let currentObjectPath = {} 12 | 13 | return createRule({ 14 | condition: (obj) => { 15 | currentObjectPath = obj 16 | 17 | for (const property of properties) { 18 | if (currentObjectPath.hasOwnProperty(property)) { 19 | prop = property 20 | currentObjectPath = currentObjectPath[property] 21 | } else { 22 | return false 23 | } 24 | } 25 | 26 | return true 27 | }, 28 | message: (obj) => { 29 | // list out the keys that we have 30 | // to help us spot where things may have gone wrong prior 31 | const keys = Object.keys(obj).toString().replace(`,`, `,\n`) 32 | 33 | return `Object containing properties ${keys} does not include ${prop}${ 34 | properties.length > 1 ? ` at path ${properties.join(`.`)}` : `` 35 | }` 36 | }, 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/rules/index.ts: -------------------------------------------------------------------------------- 1 | export { getProp } from './get-prop' 2 | export { greaterThan } from './greater-than' 3 | export { hasProp } from './has-prop' 4 | export { isArray } from './is-array' 5 | export { isEqualTo } from './is-equal-to' 6 | export { isNonEmptyArray } from './is-non-empty-array' 7 | export { isNonEmptyObject } from './is-non-empty-object' 8 | export { isNonEmptyString } from './is-non-empty-string' 9 | export { isNumber } from './is-number' 10 | export { isObject } from './is-object' 11 | export { isString } from './is-string' 12 | export { isValidEmail } from './is-valid-email' 13 | export { lessThan } from './less-than' 14 | export { matchesRegex } from './matches-regex' 15 | export { maxLength } from './max-length' 16 | export { minLength } from './min-length' 17 | -------------------------------------------------------------------------------- /src/rules/is-array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Check to see if a value is an array 3 | * @name isArray.js 4 | * @author Nick Krause 5 | * @license MIT 6 | */ 7 | import { createRule } from '../rule' 8 | 9 | export const isArray = createRule({ 10 | condition: (maybeArr) => Array.isArray(maybeArr), 11 | message: (notArray) => 12 | `Value must be an array, but has type ${typeof notArray}`, 13 | }) 14 | -------------------------------------------------------------------------------- /src/rules/is-equal-to.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Validator to ensure that this value is equivalent to another that may or may not be available 3 | * at time of definition. 4 | */ 5 | 6 | import { createRule } from '../rule' 7 | 8 | const isEqualTo = (value) => 9 | createRule({ 10 | condition: (val) => 11 | typeof value === `function` ? val === value() : val === value, 12 | message: () => `Values must be equal`, 13 | }) 14 | 15 | export { isEqualTo } 16 | -------------------------------------------------------------------------------- /src/rules/is-non-empty-array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Check if an array is actually an array, and also not empty. 3 | * @author Nick Krause 4 | * @license MIT 5 | */ 6 | import { createRule } from '../rule' 7 | 8 | export const isNonEmptyArray = createRule({ 9 | condition: (arr) => Array.isArray(arr) && arr.length > 0, 10 | message: (val) => 11 | Array.isArray(val) 12 | ? `Array must not be empty` 13 | : `No array values found in ${val}`, 14 | }) 15 | -------------------------------------------------------------------------------- /src/rules/is-non-empty-object.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file check that an object has keys 3 | * @author Nick Krause 4 | * @license MIT 5 | */ 6 | import { createRule } from '../rule' 7 | 8 | export const isNonEmptyObject = createRule({ 9 | // Reflect.ownKeys is used because 10 | // this check should not fail if we only have 11 | // properties that are non-enumerable 12 | // like 'Symbol' or properties defined by Object.defineProperty where 13 | // 'enumerable' is set to false. 14 | condition: (obj) => 15 | !!obj && typeof obj === `object` && Reflect.ownKeys(obj).length > 0, 16 | message: (obj) => 17 | typeof obj === `object` 18 | ? `Object must not be empty` 19 | : `Value ${obj} is not an object`, 20 | }) 21 | -------------------------------------------------------------------------------- /src/rules/is-non-empty-string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file ensures that a value is a non-empty string. 3 | * @author Nick Krause 4 | */ 5 | 6 | import { createRule } from '../rule' 7 | 8 | export const isNonEmptyString = createRule({ 9 | condition: (maybeStr) => 10 | typeof maybeStr === `string` && maybeStr.length > 0, 11 | message: `Value must be a non-empty string`, 12 | }) 13 | -------------------------------------------------------------------------------- /src/rules/is-number.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Check that something is a valid number 3 | * @name isNumber.js 4 | * @author Nick Krause 5 | * @license MIT 6 | */ 7 | 8 | import { createRule } from '../rule' 9 | 10 | export const isNumber = createRule({ 11 | condition: (maybeNum) => 12 | typeof maybeNum === `number` && !Number.isNaN(maybeNum), 13 | message: (notNum) => `Value ${notNum} is not a number`, 14 | }) 15 | -------------------------------------------------------------------------------- /src/rules/is-object.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Check to ensure that the supplied value is an object. 3 | * @name isObject.js 4 | * @author Nick Krause 5 | * @license MIT 6 | */ 7 | import { createRule } from '../rule' 8 | 9 | export const isObject = createRule({ 10 | condition: (obj) => 11 | !!obj && 12 | typeof obj === `object` && 13 | !Array.isArray(obj) && 14 | obj === Object(obj), 15 | message: (notObj) => `Value must be an object, but has type ${typeof notObj}`, 16 | }) 17 | -------------------------------------------------------------------------------- /src/rules/is-string.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Check to see if a value is typeof string 3 | * @author Nick Krause 4 | * @license MIT 5 | */ 6 | 7 | import { createRule } from '../rule' 8 | 9 | export const isString = createRule({ 10 | condition: (maybeStr) => typeof maybeStr === `string`, 11 | message: () => `Value is not a string`, 12 | }) 13 | -------------------------------------------------------------------------------- /src/rules/is-valid-email.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file checks to make sure that an email address has a valid format 3 | * @author Nick Krause 4 | */ 5 | 6 | import { createRule } from '../rule' 7 | 8 | /** 9 | * Check to ensure that an email address is in a valid format. 10 | * Does NOT check that the email is a valid, in-use address. 11 | */ 12 | export const isValidEmail = createRule({ 13 | // credit to https://tylermcginnis.com/validate-email-address-javascript/ for this regex 14 | condition: (str) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str), 15 | message: `Must be a valid email address`, 16 | }) 17 | -------------------------------------------------------------------------------- /src/rules/less-than.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file 3 | * @name lessThan.js 4 | * @author Nick Krause 5 | * @license MIT 6 | */ 7 | import { createRule } from '../rule' 8 | 9 | /** 10 | * Rule to validate a number that must be less than some amount. 11 | * @param {Number} max - Maximum value. 12 | */ 13 | export const lessThan = (max) => 14 | createRule({ 15 | condition: (num) => num < max, 16 | message: (num) => `${num} must be less than ${max}`, 17 | }) 18 | -------------------------------------------------------------------------------- /src/rules/matches-regex.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file rule to verify that a given string matches a certain regex 3 | * @author Nick Krause 4 | */ 5 | 6 | import { createRule } from '../rule' 7 | 8 | /** 9 | * Check to see that a value matches a given regex. 10 | * Will fail for non-string values. 11 | * @param regex {RegExp} validation regex 12 | */ 13 | export const matchesRegex = (regex: RegExp) => 14 | createRule({ 15 | condition: (maybeStr: string) => regex.test(maybeStr), 16 | message: (val) => `Value ${val} does not match regular expression ${regex}`, 17 | }) 18 | -------------------------------------------------------------------------------- /src/rules/max-length.ts: -------------------------------------------------------------------------------- 1 | import { createRule } from '../rule' 2 | 3 | /** 4 | * Function to ensure that a string is below or equal to a certain length. 5 | * @param {Number} maxLength - Max length of the string. 6 | */ 7 | export const maxLength = (max) => 8 | createRule({ 9 | condition: (str) => Boolean(str) && str.length <= max, 10 | message: `Must be shorter than ${max + 1} characters`, 11 | }) 12 | -------------------------------------------------------------------------------- /src/rules/min-length.ts: -------------------------------------------------------------------------------- 1 | import { createRule } from '../rule' 2 | 3 | export const minLength = (length) => 4 | createRule({ 5 | condition: (str) => str.length >= length, 6 | message: `Must be at least ${length} characters`, 7 | }) 8 | -------------------------------------------------------------------------------- /src/success.ts: -------------------------------------------------------------------------------- 1 | import { checkIsValidationM } from './internal/utils' 2 | import { ValidationActions, ValidationM } from './types' 3 | 4 | class Success implements Success { 5 | static of(value: any): Success { 6 | return new Success(value) 7 | } 8 | 9 | isSuccess = true 10 | errors = [] 11 | value: any = null 12 | 13 | constructor(value: any) { 14 | this.value = value 15 | this.errors = [] 16 | } 17 | 18 | map(fn: (v: any) => any) { 19 | return new Success(fn(this.value)) 20 | } 21 | 22 | chain(validationFn: (v: any) => ValidationM): ValidationM { 23 | try { 24 | const result = validationFn(this.value) 25 | 26 | checkIsValidationM(result) 27 | 28 | return result 29 | } catch (error) { 30 | console.error(error.message) 31 | console.error(error.stack) 32 | } 33 | } 34 | 35 | fold({ onSuccess }: ValidationActions) { 36 | return onSuccess(this.value) 37 | } 38 | } 39 | 40 | export { Success } 41 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export interface Validator { 2 | rules: ValidationRuleset 3 | result: ValidationM | null 4 | of(rules: ValidationRuleset): Validator 5 | validate(value: any): Validator 6 | then(opts: ValidationActions): any 7 | } 8 | 9 | export interface ValidationRule { 10 | message: string | ((v?: any) => string) 11 | opts: CustomValidatorOptions 12 | check(v: any): ValidationM 13 | } 14 | 15 | export interface ValidationActions { 16 | onSuccess: (val: any) => any 17 | onFail: (errs: string[]) => any 18 | } 19 | 20 | export interface CustomValidatorOptions { 21 | condition: (v: any) => boolean 22 | message: string | ((v: any) => string) 23 | // function to ensure delayed execution 24 | // so that we don't get any accidental 25 | // access errors in the middle of 26 | // a long list of checks. 27 | // value works the same way as condition, 28 | // using the value being checked as an argument. 29 | transform?: (v: any) => any 30 | } 31 | 32 | export type ValidationM = Success | Fail 33 | 34 | export interface Fail { 35 | value: any 36 | errors: string[] 37 | isSuccess: boolean 38 | map(fn: (value: any) => any): Fail 39 | chain(fn: (value: any, errors) => ValidationM): Fail 40 | fold(opts: ValidationActions): any 41 | } 42 | 43 | export interface Success { 44 | value: any 45 | // ghost type - errors will never exist on a Success 46 | errors: string[] 47 | isSuccess: boolean 48 | map(fn: (value: any) => any): Success 49 | chain(fn: (value: any) => ValidationM): ValidationM 50 | fold(opts: ValidationActions): any 51 | } 52 | 53 | export type ValidationCheck = (v: any, vs?: any) => ValidationM 54 | export type ValidationRuleset = ( 55 | | ValidationRule 56 | | ((v: any) => ValidationRule) 57 | )[] 58 | export type ValidationErrorMessage = (fn: (v: any) => any) => string 59 | -------------------------------------------------------------------------------- /src/utils/compose.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * compose a set of functions over an argument 3 | * 4 | * @author Nick Krause 5 | * @license MIT 6 | */ 7 | 8 | const compose = 9 | (...fns: Array<(v: T) => any>) => 10 | (x: T) => 11 | fns.reduceRight((v, f) => f(v), x) 12 | 13 | export { compose } 14 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { compose } from './compose' 2 | export { pipe } from './pipe' 3 | -------------------------------------------------------------------------------- /src/utils/pipe.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Pipe a set of functions over an argument, starting at the leftmost argument 3 | * 4 | * @author Nick Krause 5 | * @license MIT 6 | */ 7 | 8 | const pipe = 9 | (...fns: Array<(v: T) => any>) => 10 | (x: T) => 11 | fns.reduce((v, f) => f(v), x) 12 | 13 | export { pipe } 14 | -------------------------------------------------------------------------------- /src/validation.ts: -------------------------------------------------------------------------------- 1 | import { pipeValidators, ValidationError } from './internal/utils' 2 | import { ValidationActions, ValidationM, ValidationRuleset } from './types' 3 | 4 | /** 5 | * convenience method to generate a Success or Fail with a custom message (or message function), 6 | * based on the passing of a supplied condition 7 | * 8 | */ 9 | 10 | class Validator implements Validator { 11 | static of(...rules: ValidationRuleset) { 12 | return new Validator(...rules) 13 | } 14 | 15 | rules: ValidationRuleset = [] 16 | result: ValidationM | null = null 17 | 18 | constructor(...rules: ValidationRuleset) { 19 | this.rules = rules.flat() 20 | } 21 | 22 | /** 23 | * Run all of the supplied validators against the value that Validator() was supplied with, 24 | * then operate on the results based on whether this succeeded or failed. 25 | * @param {Object} opts 26 | * @property {Function} onSuccess 27 | * @property {Function} onFail 28 | * 29 | * @returns {Any} result 30 | */ 31 | validate(value: any): Validator { 32 | this.result = pipeValidators(this.rules)(value) 33 | 34 | return this 35 | } 36 | 37 | // eslint-disable-next-line unicorn/no-thenable 38 | then(opts: ValidationActions) { 39 | if (this.result) { 40 | return this.result.fold(opts) 41 | } else { 42 | throw new ValidationError( 43 | `Validator failed to run - did you supply rules and use validate() first?` 44 | ) 45 | } 46 | } 47 | } 48 | 49 | export { Validator } 50 | -------------------------------------------------------------------------------- /test/Builtins.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Tests for built-in validations. 3 | */ 4 | 5 | import { 6 | getProp, 7 | greaterThan, 8 | hasProp, 9 | isArray, 10 | isEqualTo, 11 | isNonEmptyArray, 12 | isNonEmptyObject, 13 | isNonEmptyString, 14 | isNumber, 15 | isObject, 16 | isString, 17 | isValidEmail, 18 | lessThan, 19 | matchesRegex, 20 | maxLength, 21 | minLength, 22 | } from '../src' 23 | import { pipeValidators } from '../src/internal/utils' 24 | 25 | describe(`@codeparticle/formal built-in validations`, () => { 26 | const testStrings = [ 27 | ``, 28 | `0`, 29 | `1`, 30 | `str`, 31 | `100`, 32 | `@@%/&&`, 33 | `Templated with a fancy ${2}`, 34 | `"escaped"`, 35 | ] 36 | const testNumbers = [ 37 | NaN, 38 | 0, 39 | 1, 40 | 1492, 41 | 1776, 42 | Infinity, 43 | // float 44 | 0.112_358, 45 | // scientific 46 | 1.3e9, 47 | // binary 48 | 0b001, 49 | // octal 50 | 0o012, 51 | // hex 52 | 0xff_ff_ff_ff, 53 | ] 54 | 55 | const emptyArray = [] 56 | const emptyObject = {} 57 | 58 | const [goodEmail, badEmail] = [ 59 | `here.therebee.dragons@wowmail.org`, 60 | `who@what@why.com`, 61 | ] 62 | 63 | const regexes = { 64 | testCase: /\d/g, 65 | good: `0123459678`, 66 | bad: `what did I do?`, 67 | } 68 | 69 | const testObject = { 70 | deeply: { 71 | nested: { 72 | value: `value`, 73 | }, 74 | }, 75 | } 76 | const testArray = [...testStrings, ...testNumbers, emptyArray, emptyObject] 77 | 78 | describe(`getProp`, () => { 79 | it(`drills down into nested objects`, () => { 80 | const testCase = pipeValidators([ 81 | getProp(`deeply`), 82 | getProp(`nested`), 83 | getProp(`value`), 84 | ])(testObject) 85 | 86 | expect(testCase.isSuccess).toBe(true) 87 | expect(testCase.value).toBe(`value`) 88 | }) 89 | it(`fails with bad values`, () => { 90 | const testCase = pipeValidators([ 91 | getProp(`strangely`), 92 | getProp(`nested`), 93 | getProp(`corndogs`), 94 | ])(testObject) 95 | 96 | expect(testCase.isSuccess).toBe(false) 97 | }) 98 | }) 99 | 100 | describe(`greaterThan`, () => { 101 | const validation = greaterThan(5) 102 | 103 | it(`passes with good values`, () => { 104 | expect(validation.check(10).isSuccess).toBe(true) 105 | }) 106 | it(`fails with bad values`, () => { 107 | expect(validation.check(5).isSuccess).toBe(false) 108 | }) 109 | }) 110 | 111 | describe(`hasProp`, () => { 112 | const validation = hasProp(`deeply`, `nested`, `value`) 113 | 114 | it(`passes with good values`, () => { 115 | expect(validation.check(testObject).isSuccess).toBe(true) 116 | }) 117 | it(`fails with bad values`, () => { 118 | expect(validation.check(emptyObject).isSuccess).toBe(false) 119 | }) 120 | it(`does not mutate the object`, () => { 121 | expect(testObject.deeply).toBeDefined() 122 | }) 123 | }) 124 | 125 | describe(`isArray`, () => { 126 | it(`passes with good values`, () => { 127 | expect(isArray.check(testArray).isSuccess).toBe(true) 128 | expect(isArray.check(emptyArray).isSuccess).toBe(true) 129 | }) 130 | it(`fails with bad values`, () => { 131 | expect(isArray.check(10).isSuccess).toBe(false) 132 | }) 133 | }) 134 | 135 | describe(`isNonEmptyArray`, () => { 136 | it(`passes with good values`, () => { 137 | expect(isNonEmptyArray.check(testArray).isSuccess).toBe(true) 138 | }) 139 | it(`fails with bad values`, () => { 140 | expect(isNonEmptyArray.check(emptyArray).isSuccess).toBe(false) 141 | }) 142 | }) 143 | 144 | describe(`isNonEmptyObject`, () => { 145 | it(`passes with good values`, () => { 146 | expect(isNonEmptyObject.check(testObject).isSuccess).toBe(true) 147 | }) 148 | it(`fails with bad values`, () => { 149 | expect(isNonEmptyObject.check(emptyObject).isSuccess).toBe(false) 150 | }) 151 | }) 152 | 153 | describe(`isNonEmptyString`, () => { 154 | it(`passes with good values`, () => { 155 | expect(isNonEmptyString.check(`wow`).isSuccess).toBe(true) 156 | }) 157 | it(`fails with bad values`, () => { 158 | expect(isNonEmptyString.check(``).isSuccess).toBe(false) 159 | }) 160 | }) 161 | 162 | describe(`isNumber`, () => { 163 | const [nan, ...others] = testNumbers 164 | 165 | it(`passes with good values`, () => { 166 | for (const num of others) { 167 | expect(isNumber.check(num).isSuccess).toBe(true) 168 | } 169 | }) 170 | it(`fails with bad values`, () => { 171 | expect(isNumber.check(nan).isSuccess).toBe(false) 172 | 173 | for (const str of testStrings) { 174 | expect(isNumber.check(str).isSuccess).toBe(false) 175 | } 176 | }) 177 | }) 178 | 179 | describe(`isObject`, () => { 180 | it(`passes with good values`, () => { 181 | expect(isObject.check(emptyObject).isSuccess).toBe(true) 182 | expect(isObject.check(testObject).isSuccess).toBe(true) 183 | }) 184 | it(`fails with bad values`, () => { 185 | expect(isObject.check([]).isSuccess).toBe(false) 186 | }) 187 | }) 188 | 189 | describe(`isString`, () => { 190 | it(`passes with good values`, () => { 191 | for (const str of testStrings) { 192 | expect(isString.check(str).isSuccess).toBe(true) 193 | } 194 | }) 195 | it(`fails with bad values`, () => { 196 | for (const num of testNumbers) { 197 | expect(isString.check(num).isSuccess).toBe(false) 198 | } 199 | 200 | expect(isString.check(testObject).isSuccess).toBe(false) 201 | expect(isString.check(testArray).isSuccess).toBe(false) 202 | }) 203 | }) 204 | 205 | describe(`isValidEmail`, () => { 206 | it(`passes with good values`, () => { 207 | expect(isValidEmail.check(goodEmail).isSuccess).toBe(true) 208 | }) 209 | it(`fails with bad values`, () => { 210 | expect(isValidEmail.check(badEmail).isSuccess).toBe(false) 211 | }) 212 | }) 213 | 214 | describe(`lessThan`, () => { 215 | it(`passes with good values`, () => { 216 | expect(lessThan(10).check(9).isSuccess).toBe(true) 217 | }) 218 | it(`fails with bad values`, () => { 219 | expect(lessThan(10).check(11).isSuccess).toBe(false) 220 | }) 221 | }) 222 | 223 | describe(`matchesRegex`, () => { 224 | it(`passes with good values`, () => { 225 | expect(matchesRegex(regexes.testCase).check(regexes.good).isSuccess).toBe( 226 | true, 227 | ) 228 | }) 229 | it(`fails with bad values`, () => { 230 | expect(matchesRegex(regexes.testCase).check(regexes.bad).isSuccess).toBe( 231 | false, 232 | ) 233 | }) 234 | }) 235 | 236 | describe(`maxLength`, () => { 237 | it(`passes with good values`, () => { 238 | expect(maxLength(5).check(`wow`).isSuccess).toBe(true) 239 | }) 240 | it(`fails with bad values`, () => { 241 | expect(maxLength(1).check(`wow`).isSuccess).toBe(false) 242 | }) 243 | }) 244 | 245 | describe(`minLength`, () => { 246 | it(`passes with good values`, () => { 247 | expect(minLength(5).check(`wow cool`).isSuccess).toBe(true) 248 | }) 249 | it(`fails with bad values`, () => { 250 | expect(minLength(5).check(`wow`).isSuccess).toBe(false) 251 | }) 252 | }) 253 | 254 | describe(`isEqualTo`, () => { 255 | it(`passes with good values`, () => { 256 | expect(isEqualTo(5).check(5).isSuccess).toBe(true) 257 | }) 258 | it(`fails with bad values`, () => { 259 | expect(isEqualTo(0).check(5).isSuccess).toBe(false) 260 | }) 261 | }) 262 | }) 263 | -------------------------------------------------------------------------------- /test/Validation.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Unit tests for @codeparticle/formal 3 | */ 4 | import { id, validateObject } from '../src/internal/utils' 5 | import { createRule, withMessage } from '../src/rule' 6 | import { 7 | getProp, 8 | isEqualTo, 9 | isNonEmptyString, 10 | isNumber, 11 | isObject, 12 | isString, 13 | isValidEmail, 14 | } from '../src/rules' 15 | import { Validator } from '../src/validation' 16 | 17 | describe(`Validation`, () => { 18 | const testObject = { 19 | nested: { 20 | property: { 21 | num: 2, 22 | }, 23 | }, 24 | } 25 | 26 | const testOpts = { 27 | onSuccess: id, 28 | onFail: id, 29 | } 30 | 31 | it(`Takes a list of validators made with createValidator and checks a value accordingly`, () => { 32 | const testValue = Validator.of( 33 | isObject, 34 | getProp(`nested`, `property`), 35 | getProp(`num`), 36 | isNumber 37 | ).validate(testObject) 38 | 39 | const folded = testValue.then({ 40 | onSuccess: id, 41 | onFail: id, 42 | }) 43 | 44 | expect(testValue?.result?.isSuccess).toBe(true) 45 | expect(folded).toBe(2) 46 | }) 47 | 48 | it(`Collects errors when multiple conditions fail`, () => { 49 | const failedObject = Validator.of( 50 | isObject, 51 | getProp(`nested`, `property`, `fail`) 52 | ).validate(testObject) 53 | 54 | const failedString = Validator.of(isString).validate(2) 55 | const failedNumber = Validator.of(isNumber).validate(`wow`) 56 | 57 | expect(failedObject?.result?.isSuccess).toBe(false) 58 | expect(failedString?.result?.isSuccess).toBe(false) 59 | expect(failedNumber?.result?.isSuccess).toBe(false) 60 | 61 | expect(failedObject.then(testOpts)).toMatchObject([ 62 | `Object does not include property .fail at path .nested.property.fail`, 63 | ]) 64 | 65 | expect(failedString.then(testOpts)).toMatchObject([`Value is not a string`]) 66 | expect(failedNumber.then(testOpts)).toMatchObject([ 67 | `Value wow is not a number`, 68 | ]) 69 | }) 70 | }) 71 | 72 | describe(`Rule`, () => { 73 | const customRule = createRule({ 74 | condition: (val) => val > 3, 75 | message: `value must be greater than 3`, 76 | }) 77 | 78 | it(`allows for initialization through createRule`, () => { 79 | expect(customRule.check(3).isSuccess).toBe(false) 80 | expect(customRule.check(5).isSuccess).toBe(true) 81 | expect(customRule.check(3).errors[0]).toBe(`value must be greater than 3`) 82 | }) 83 | 84 | it(`allows for overwriting the message of an existing rule using withMessage`, () => { 85 | const overwriteText = withMessage(`Whoa`) 86 | const newRule = overwriteText(customRule) 87 | const overwriteFn = withMessage((d) => `Man, that's a crooked ${d}`) 88 | 89 | expect(newRule.check(2).isSuccess).toBe(false) 90 | expect(overwriteFn(newRule).check(2).errors[0]).toBe( 91 | `Man, that's a crooked 2` 92 | ) 93 | expect(newRule.check(2).errors[0]).toBe(`Whoa`) 94 | }) 95 | }) 96 | 97 | describe(`validateObject`, () => { 98 | it(`can validate over an object, returning the original with an attached error object containing messages`, () => { 99 | const hasExactlyTwoNameObjects = createRule({ 100 | condition: (arr) => arr.length === 2, 101 | message: `Exactly two names must exist`, 102 | }) 103 | 104 | const daveIsSpelledBackwardsSomewhere = createRule({ 105 | condition: (arr) => arr.some((o) => o.name === `evad`), 106 | message: `Dave is not spelled backwards somewhere`, 107 | }) 108 | 109 | const validate = validateObject({ 110 | // required fields 111 | firstName: [isNonEmptyString], 112 | lastName: [isNonEmptyString], 113 | email: [ 114 | isNonEmptyString, 115 | isValidEmail, 116 | (values) => isEqualTo(values[`confirmationEmail`]), 117 | ], 118 | confirmationEmail: [ 119 | (values) => isEqualTo(values[`email`]), 120 | isNonEmptyString, 121 | isValidEmail, 122 | ], 123 | // non-required fields 124 | city: [isString], 125 | names: [hasExactlyTwoNameObjects, daveIsSpelledBackwardsSomewhere], 126 | }) 127 | 128 | const testFormFields = { 129 | empty: {}, 130 | bad: { 131 | firstName: ``, 132 | lastName: 5, 133 | email: `notbad@email.com`, 134 | confirmationEmail: `bad`, 135 | city: 5, 136 | names: [ 137 | { 138 | name: `what?`, 139 | }, 140 | ], 141 | }, 142 | good: { 143 | firstName: `captain`, 144 | lastName: `wow`, 145 | email: `savesday@everyday.net`, 146 | confirmationEmail: `savesday@everyday.net`, 147 | city: ``, 148 | names: [ 149 | { 150 | name: `dave`, 151 | }, 152 | { 153 | name: `evad`, 154 | }, 155 | ], 156 | }, 157 | } 158 | 159 | const invalid = validate(testFormFields.bad) 160 | const valid = validate(testFormFields.good) 161 | 162 | expect(invalid.hasErrors).toBe(true) 163 | expect(valid.hasErrors).toBe(false) 164 | 165 | expect(invalid.errors).toMatchObject({ 166 | firstName: [`Value must be a non-empty string`], 167 | lastName: [`Value must be a non-empty string`], 168 | email: [`Values must be equal`], 169 | confirmationEmail: [ 170 | `Values must be equal`, 171 | `Must be a valid email address`, 172 | ], 173 | city: [`Value is not a string`], 174 | names: [ 175 | `Exactly two names must exist`, 176 | `Dave is not spelled backwards somewhere`, 177 | ], 178 | }) 179 | }) 180 | }) 181 | -------------------------------------------------------------------------------- /tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true 6 | }, 7 | "include": ["test", "./jest.config.js", "scripts", "./tsup.config.ts", "./.eslintrc.js"], 8 | "exclude": ["src"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": ".", 6 | "declaration": true, 7 | "declarationDir": "dist/types", 8 | "declarationMap": true, 9 | "downlevelIteration": true, 10 | "esModuleInterop": true, 11 | "lib": ["dom", "esnext"], 12 | "module": "ESNext", 13 | "moduleResolution": "node", 14 | "noImplicitAny": false, 15 | "outDir": "dist/esm5", 16 | "resolveJsonModule": true, 17 | "sourceMap": true, 18 | "strict": false, 19 | "strictNullChecks": false, 20 | "stripInternal": true, 21 | "target": "ES2015" 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | const baseConfig: object = { 4 | bundle: true, 5 | clean: true, 6 | entry: [`src/**/*`], 7 | format: [`cjs`, `esm`], 8 | minify: false, 9 | name: `build`, 10 | platform: `browser`, 11 | skipNodeModulesBundle: false, 12 | sourcemap: true, 13 | splitting: true, 14 | silent: true, 15 | tsconfig: `./tsconfig.json`, 16 | dts: { 17 | resolve: true, 18 | }, 19 | } 20 | 21 | export default defineConfig(baseConfig) 22 | --------------------------------------------------------------------------------