├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── LICENSE ├── README.md ├── assets └── logo.png ├── package-lock.json ├── package.json ├── publish.ps1 ├── renovate.json ├── site ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── gatsby-config.js ├── images │ └── favicon.png ├── package-lock.json ├── package.json ├── src │ ├── assets │ │ ├── scaleleap.js │ │ ├── schiphol.js │ │ └── sellmycode.js │ ├── components │ │ ├── DataTypeContent.js │ │ ├── DataTypeMethod.js │ │ ├── HL.js │ │ ├── HamburgerMenu.js │ │ ├── Meta.js │ │ ├── Sidebar.js │ │ ├── SidebarLink.js │ │ ├── UtilContent.js │ │ ├── layout.css │ │ └── layout.js │ ├── data.tsx │ └── pages │ │ ├── adts │ │ ├── Either.js │ │ ├── EitherAsync.js │ │ ├── Maybe.js │ │ ├── MaybeAsync.js │ │ ├── NonEmptyList.js │ │ └── Tuple.js │ │ ├── changelog.js │ │ ├── changelog │ │ ├── 0.11.js │ │ ├── 0.12.js │ │ ├── 0.13.js │ │ ├── 0.14.js │ │ ├── 0.15.js │ │ └── 0.16.js │ │ ├── faq.js │ │ ├── getting-started.js │ │ ├── guides │ │ ├── maybe-api-guide.js │ │ └── maybeasync-eitherasync-for-haskellers.js │ │ ├── index.js │ │ └── utils │ │ ├── Codec.js │ │ ├── Function.js │ │ └── List.js └── tsconfig.json ├── src ├── Codec.test.ts ├── Codec.ts ├── Either.test.ts ├── Either.ts ├── EitherAsync.test.ts ├── EitherAsync.ts ├── Function.test.ts ├── Function.ts ├── List.test.ts ├── List.ts ├── Maybe.test.ts ├── Maybe.ts ├── MaybeAsync.test.ts ├── MaybeAsync.ts ├── NonEmptyList.test.ts ├── NonEmptyList.ts ├── Tuple.test.ts ├── Tuple.ts └── index.ts ├── tsconfig.esm.json └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [gigobyte] 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ${{ matrix.os }} 12 | strategy: 13 | matrix: 14 | node-version: [20.x] 15 | os: [ubuntu-latest, macos-latest] 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - run: npm i 22 | - run: npm test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | coverage 4 | .cache 5 | dist 6 | .idea 7 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Stanislav Iliev 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Purify logo
3 | 4 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/gigobyte/purify/ci.yml?label=build&style=flat-square&branch=master)](https://github.com/gigobyte/purify/actions/workflows/ci.yml) 5 | ![Coveralls](https://img.shields.io/coverallsCoverage/github/gigobyte/purify?style=flat-square&color=brightGreen) 6 | [![TypeScript Version](http://img.shields.io/badge/built_with-TypeScript-brightgreen.svg?style=flat-square "Latest Typescript")](https://www.typescriptlang.org/download/) 7 | [![HitCount](https://hits.dwyl.com/gigobyte/purify.svg?style=flat-square)](http://hits.dwyl.com/gigobyte/purify) 8 | 9 |
10 | 11 | # What is purify? 12 | 13 | Purify is a library for functional programming in TypeScript. 14 | Its purpose is to allow developers to use popular patterns and abstractions that are available in most functional languages. 15 | It is also Fantasy Land conformant. 16 | 17 | # Core values 18 | 19 | - **Elegant and developer-friendly API** - purify's design decisions are made with developer experience in mind. Purify doesn't try to change how you write TypeScript, instead it provides useful tools for making your code easier to read and maintain without resolving to hacks or scary type definitions. 20 | 21 | - **Type-safety** - While purify can be used in vanilla JavaScript, it's entirely written with TypeScript and type safety in mind. While TypeScript does a great job at preventing runtime errors, purify goes a step further and provides utility functions for working with native objects like arrays in a type-safe manner. 22 | 23 | - **Emphasis on practical code** - Higher-kinded types and other type-level features would be great additions to this library, but as of right now they don't have reasonable implementations in TypeScript. Purify focuses on being a library that you can include in any TypeScript project and favors instance methods instead of functions, clean and readable type definitions instead of advanced type features and a curated API instead of trying to port over another language's standard library. 24 | 25 | # How to start? 26 | 27 | Purify is available as a package on npm. You can install it with a package manager of your choice: 28 | 29 | ``` 30 | $ npm install purify-ts 31 | ``` 32 | 33 | or 34 | 35 | ``` 36 | $ yarn add purify-ts 37 | ``` 38 | 39 | # Documentation 40 | 41 | You can find the documentation on the [official site](https://gigobyte.github.io/purify/). 42 | 43 | # Ecosystem 44 | 45 | - [purify-ts-extra-codec](https://github.com/airtoxin/purify-ts-extra-codec) - Extra utility codecs 46 | - [chai-purify](https://github.com/dave-inc/chai-purify) - Chai assert helpers 47 | - [purifree](https://github.com/nythrox/purifree) - A fork that allows you to program in a point-free style, and adds a few new capabilities 48 | 49 | # Inspired by 50 | 51 | - [Elm](https://elm-lang.org/) 52 | - [Arrow - Functional companion to Kotlin's Standard Library](http://arrow-kt.io/) 53 | - [fp-ts - Functional programming in TypeScript](https://github.com/gcanti/fp-ts) 54 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gigobyte/purify/da4498b0f302354c51c296dfacd93634a0c010bc/assets/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "purify-ts", 3 | "version": "2.1.1", 4 | "description": "Functional programming standard library for TypeScript ", 5 | "repository": "https://github.com/gigobyte/purify.git", 6 | "author": "gigobyte ", 7 | "license": "ISC", 8 | "sideEffects": false, 9 | "scripts": { 10 | "build": "tsc && tsc -p tsconfig.esm.json", 11 | "test": "vitest run --coverage", 12 | "coverage": "coveralls < coverage/lcov.info", 13 | "pretty": "prettier --write \"**/*.ts\"" 14 | }, 15 | "main": "index.js", 16 | "module": "esm/index.js", 17 | "types": "index.d.ts", 18 | "exports": { 19 | ".": { 20 | "import": { 21 | "types": "./esm/index.d.ts", 22 | "default": "./esm/index.js" 23 | }, 24 | "require": { 25 | "types": "./index.d.ts", 26 | "default": "./index.js" 27 | } 28 | }, 29 | "./*": { 30 | "import": { 31 | "types": "./esm/*.d.ts", 32 | "default": "./esm/*.js" 33 | }, 34 | "require": { 35 | "types": "./*.d.ts", 36 | "default": "./*.js" 37 | } 38 | } 39 | }, 40 | "devDependencies": { 41 | "@vitest/coverage-v8": "3.0.7", 42 | "ajv": "8.17.1", 43 | "ajv-formats": "3.0.1", 44 | "coveralls": "3.1.1", 45 | "prettier": "3.5.2", 46 | "typescript": "5.8.2", 47 | "vitest": "3.0.7" 48 | }, 49 | "dependencies": { 50 | "@types/json-schema": "7.0.15" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /publish.ps1: -------------------------------------------------------------------------------- 1 | $files = @("package.json", "LICENSE", "README.md", "package-lock.json") 2 | 3 | Remove-Item -Recurse node_modules 4 | Invoke-Expression "npm install" 5 | 6 | 7 | if (Test-Path -Path lib) { 8 | Remove-Item -Recurse lib 9 | } 10 | 11 | Invoke-Expression "npm test" 12 | 13 | Invoke-Expression "npm run build" 14 | Remove-Item -Recurse ./lib/*.test.* 15 | Remove-Item -Recurse ./lib/esm/*.test.* 16 | 17 | foreach ($file in $files) { 18 | Invoke-Expression "copy $($file) lib/$($file)" 19 | } 20 | 21 | Set-Content ./lib/esm/package.json '{ "type": "module" }' 22 | 23 | Invoke-Expression "cd lib" 24 | Invoke-Expression "npm publish" 25 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", ":pinAllExceptPeerDependencies"], 3 | "ignorePaths": ["./site"], 4 | "automerge": true, 5 | "major": { 6 | "automerge": false 7 | }, 8 | "requiredStatusChecks": null, 9 | "schedule": ["after 9pm on sunday"], 10 | "packageRules": [ 11 | { 12 | "matchPackagePatterns": ["*"], 13 | "matchUpdateTypes": ["minor", "patch"], 14 | "groupName": "all non-major dependencies", 15 | "groupSlug": "all-minor-patch" 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /site/.gitignore: -------------------------------------------------------------------------------- 1 | # Project dependencies 2 | .cache 3 | node_modules 4 | yarn-error.log 5 | 6 | # Build directory 7 | /public 8 | -------------------------------------------------------------------------------- /site/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false, 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /site/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 gatsbyjs 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 | 23 | -------------------------------------------------------------------------------- /site/README.md: -------------------------------------------------------------------------------- 1 | # gatsby-starter-default 2 | The default Gatsby starter. 3 | 4 | For an overview of the project structure please refer to the [Gatsby documentation - Building with Components](https://www.gatsbyjs.org/docs/building-with-components/). 5 | 6 | ## Install 7 | 8 | Make sure that you have the Gatsby CLI program installed: 9 | ```sh 10 | npm install --global gatsby-cli 11 | ``` 12 | 13 | And run from your CLI: 14 | ```sh 15 | gatsby new gatsby-example-site 16 | ``` 17 | 18 | Then you can run it by: 19 | ```sh 20 | cd gatsby-example-site 21 | npm run develop 22 | ``` 23 | 24 | ## Deploy 25 | 26 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/gatsbyjs/gatsby-starter-default) 27 | -------------------------------------------------------------------------------- /site/gatsby-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | 'gatsby-plugin-react-helmet', 4 | 'gatsby-plugin-styled-components', 5 | 'gatsby-plugin-typescript', 6 | { 7 | resolve: 'gatsby-plugin-google-fonts', 8 | options: { fonts: ['Titillium Web'] }, 9 | }, 10 | ], 11 | pathPrefix: '/purify', 12 | } 13 | -------------------------------------------------------------------------------- /site/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gigobyte/purify/da4498b0f302354c51c296dfacd93634a0c010bc/site/images/favicon.png -------------------------------------------------------------------------------- /site/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "purify-website", 3 | "description": "Purify official website", 4 | "version": "1.0.0", 5 | "author": "Stanislav Iliev ", 6 | "dependencies": { 7 | "@types/react-syntax-highlighter": "13.5.2", 8 | "babel-plugin-styled-components": "2.1.4", 9 | "gatsby": "4.25.9", 10 | "gatsby-link": "4.25.0", 11 | "gatsby-plugin-google-fonts": "1.0.1", 12 | "gatsby-plugin-react-helmet": "5.25.0", 13 | "gatsby-plugin-styled-components": "5.25.0", 14 | "gatsby-plugin-typescript": "4.25.0", 15 | "gh-pages": "6.3.0", 16 | "react": "17.0.2", 17 | "react-dom": "17.0.2", 18 | "react-helmet": "6.1.0", 19 | "react-syntax-highlighter": "15.6.1", 20 | "styled-components": "5.3.11" 21 | }, 22 | "license": "MIT", 23 | "scripts": { 24 | "build": "gatsby build", 25 | "develop": "gatsby develop", 26 | "format": "prettier --write src/**/*.js", 27 | "test": "echo \"Error: no test specified\" && exit 1", 28 | "deploy": "gatsby build --prefix-paths && gh-pages -d public" 29 | }, 30 | "devDependencies": { 31 | "prettier": "3.5.2" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /site/src/assets/scaleleap.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default () => ( 4 | 10 | 11 | {' '} 15 | {' '} 19 | 23 | {' '} 24 | 25 | {' '} 30 | {' '} 34 | {' '} 38 | {' '} 42 | {' '} 46 | {' '} 50 | {' '} 54 | {' '} 58 | {' '} 62 | {' '} 66 | 70 | 71 | 72 | ) 73 | -------------------------------------------------------------------------------- /site/src/assets/schiphol.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default () => ( 4 | 11 | 15 | 16 | ) 17 | -------------------------------------------------------------------------------- /site/src/assets/sellmycode.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default () => ( 4 | 10 | 11 | 17 | 18 | 22 | 28 | 29 | SellMyCode 30 | 31 | 32 | 33 | 37 | 44 | 45 | $ 46 | 47 | 48 | 49 | 50 | ) 51 | -------------------------------------------------------------------------------- /site/src/components/DataTypeContent.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'gatsby-link' 3 | import styled from 'styled-components' 4 | import DataTypeMethod from './DataTypeMethod' 5 | import SyntaxHighlighter from 'react-syntax-highlighter' 6 | import highlightStyle from 'react-syntax-highlighter/dist/esm/styles/hljs/googlecode' 7 | import Layout from './Layout' 8 | 9 | const Container = styled.div`` 10 | 11 | const Title = styled.h1` 12 | font-weight: inherit; 13 | 14 | @media only screen and (max-width: 768px) { 15 | text-align: center; 16 | margin-top: 0; 17 | } 18 | ` 19 | 20 | const Description = styled.div` 21 | padding-right: 15%; 22 | font-size: 1.05em; 23 | 24 | @media only screen and (max-width: 768px) { 25 | padding-right: 0; 26 | text-align: center; 27 | } 28 | ` 29 | 30 | const TopicHeader = styled.h2` 31 | font-weight: inherit; 32 | margin-bottom: 0; 33 | 34 | @media only screen and (max-width: 768px) { 35 | text-align: center; 36 | } 37 | ` 38 | const TypeclassBadges = styled.div` 39 | margin-top: -20px; 40 | padding-bottom: 20px; 41 | display: flex; 42 | flex-wrap: wrap; 43 | 44 | @media only screen and (max-width: 768px) { 45 | justify-content: center; 46 | } 47 | ` 48 | 49 | const TypeclassBadge = styled.span` 50 | background-color: #af87e6; 51 | border-radius: 6px; 52 | color: white; 53 | padding: 0px 5px; 54 | font-size: 13px; 55 | margin-right: 4px; 56 | margin-bottom: 5px; 57 | text-decoration: none; 58 | ` 59 | 60 | const TypeclassTooltip = styled.div` 61 | background-color: #975ce7; 62 | border-radius: 100%; 63 | width: 17px; 64 | position: relative; 65 | height: 17px; 66 | margin-top: 1px; 67 | margin-left: 4px; 68 | 69 | &::after { 70 | content: '?'; 71 | color: white; 72 | position: absolute; 73 | left: 5px; 74 | top: -2px; 75 | font-size: 13px; 76 | } 77 | 78 | @media (hover: none) { 79 | display: none; 80 | } 81 | ` 82 | 83 | const ExamplesContainer = styled.div` 84 | pre { 85 | margin: 0; 86 | } 87 | ` 88 | 89 | const ExampleHeader = styled.div` 90 | text-align: center; 91 | background-color: #f9f4f4; 92 | padding: 4px; 93 | ` 94 | 95 | const Example = styled.div` 96 | max-width: 650px; 97 | margin: 10px 0; 98 | border: 1px solid #f3eeee; 99 | ` 100 | 101 | const Guide = styled(Link)` 102 | display: inline-block; 103 | text-align: center; 104 | text-decoration: none; 105 | width: 100%; 106 | 107 | &:not(:last-of-type) { 108 | border-bottom: 1px solid #f3eeee; 109 | } 110 | ` 111 | 112 | const DataTypeContent = (adt) => (props) => ( 113 | 114 | 115 | {adt.name} 116 | 117 | {adt.implements.map((typeclass) => ( 118 | {typeclass} 119 | ))} 120 | {adt.implements.length > 0 && ( 121 | 122 | )} 123 | 124 | {adt.description} 125 | 126 | {adt.examples.map((example) => ( 127 | 128 | {example.title} 129 | 130 | {example.content.join('\n')} 131 | 132 | 133 | ))} 134 | {adt.guides.length > 0 && ( 135 | 136 | Official Guides 137 | {adt.guides.map((guide) => ( 138 | {guide.title} 139 | ))} 140 | 141 | )} 142 | 143 | {adt.content.map((x) => ( 144 | <> 145 | {x.title} 146 | {x.methods.map((method) => DataTypeMethod(x.id, method))} 147 | 148 | ))} 149 | 150 | 151 | ) 152 | 153 | export default DataTypeContent 154 | -------------------------------------------------------------------------------- /site/src/components/DataTypeMethod.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import { Highlight } from './HL' 4 | 5 | const Container = styled.div` 6 | padding: 15px 0px; 7 | border-bottom: 1px solid #b8d1e2; 8 | border-style: dashed; 9 | border-left: 0; 10 | border-top: 0; 11 | border-right: 0; 12 | ` 13 | 14 | const MethodName = styled.a` 15 | font-size: 19px; 16 | color: #3b74d7; 17 | text-decoration: none; 18 | 19 | &:hover { 20 | text-decoration: underline; 21 | } 22 | 23 | @media only screen and (max-width: 768px) { 24 | display: block; 25 | text-align: center; 26 | padding-bottom: 5px; 27 | } 28 | ` 29 | 30 | const MethodSignature = styled.span` 31 | display: inline-block; 32 | font-family: Consolas, Menlo, monospace; 33 | background-color: #e7edf1; 34 | border-radius: 4px; 35 | font-size: 0.8rem; 36 | margin-right: 10px; 37 | margin-bottom: 10px; 38 | 39 | &:before { 40 | content: ${(props) => (props.ml ? "'λ'" : "'TS'")}; 41 | background-color: ${(props) => (props.ml ? '#9756f3' : '#3b74d7')}; 42 | color: white; 43 | border-bottom-left-radius: 4px; 44 | border-top-left-radius: 4px; 45 | padding: 3px 5px; 46 | display: inline-block; 47 | min-width: 13px; 48 | text-align: center; 49 | letter-spacing: ${(props) => (props.ts ? '-1px' : '0')}; 50 | } 51 | 52 | @media only screen and (max-width: 768px) { 53 | position: relative; 54 | text-align: center; 55 | width: 100%; 56 | margin-bottom: 0; 57 | padding: 5px; 58 | border-top-right-radius: 0; 59 | border-bottom-right-radius: 0; 60 | border-top: 1px solid #dfe4e6; 61 | font-size: 13px; 62 | text-overflow: ellipsis; 63 | overflow: hidden; 64 | white-space: nowrap; 65 | 66 | &:before { 67 | position: absolute; 68 | left: 0; 69 | padding: 5px 5px; 70 | margin-top: -5px; 71 | } 72 | } 73 | ` 74 | 75 | const MethodSignatureText = styled.span` 76 | padding: 0 6px; 77 | ` 78 | 79 | const MethodExample = styled.div` 80 | display: flex; 81 | background-color: #f0f4ff; 82 | border-left: 4px solid #8acefb; 83 | padding: 5px; 84 | margin: 5px 0; 85 | overflow-x: scroll; 86 | max-width: calc(95vw - 200px); 87 | 88 | @media only screen and (max-width: 768px) { 89 | white-space: nowrap; 90 | text-overflow: ellipsis; 91 | max-width: 100vw; 92 | } 93 | ` 94 | 95 | const MethodExampleColumn = styled.div` 96 | display: flex; 97 | flex-direction: column; 98 | 99 | pre { 100 | border-radius: 2px; 101 | min-height: 20px; 102 | display: flex !important; 103 | justify-content: center; 104 | flex-direction: column; 105 | margin-bottom: 5px; 106 | margin-top: 0; 107 | background: #f1f5ff !important; 108 | padding: 0.3em 0.4em !important; 109 | 110 | &:last-child { 111 | margin-bottom: 0; 112 | } 113 | } 114 | ` 115 | 116 | const MethodDescription = styled.div` 117 | width: 70%; 118 | 119 | @media only screen and (max-width: 768px) { 120 | width: 100%; 121 | } 122 | 123 | @media only screen and (max-width: 768px) { 124 | padding: 10px 0; 125 | } 126 | ` 127 | 128 | const DataTypeMethod = (categoryId, method) => ( 129 | 130 | 134 | {method.name} 135 | 136 |
137 | {method.signatureML && ( 138 | 139 | {method.signatureML} 140 | 141 | )} 142 | {method.signatureTS && ( 143 | 144 | {method.signatureTS} 145 | 146 | )} 147 | {method.description} 148 | {method.examples.length > 0 && ( 149 | 150 | 151 | {method.examples.map((example) => ( 152 | {example.input} 153 | ))} 154 | 155 | 156 | 157 | {method.examples.map((example) => ( 158 | 159 | ))} 160 | 161 | 162 | 163 | {method.examples.map((example) => ( 164 | {example.output} 165 | ))} 166 | 167 | 168 | )} 169 |
170 |
171 | ) 172 | 173 | export default DataTypeMethod 174 | -------------------------------------------------------------------------------- /site/src/components/HL.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import SyntaxHighlighter from 'react-syntax-highlighter' 4 | import highlightStyle from 'react-syntax-highlighter/dist/esm/styles/hljs/googlecode' 5 | 6 | export const HL = styled.span` 7 | background: white; 8 | padding: 0.5em; 9 | font-family: monospace; 10 | white-space: pre; 11 | ` 12 | 13 | export const Highlight = ({ children }) => ( 14 | 15 | {children} 16 | 17 | ) 18 | -------------------------------------------------------------------------------- /site/src/components/HamburgerMenu.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const Container = styled.div` 5 | position: absolute; 6 | right: 19px; 7 | display: none; 8 | 9 | @media only screen and (max-width: 768px) { 10 | display: inline-block; 11 | } 12 | ` 13 | 14 | const Bar = styled.div` 15 | width: 35px; 16 | height: 5px; 17 | background-color: white; 18 | margin: 6px 0; 19 | transition: 0.4s; 20 | ` 21 | 22 | const Bar1 = styled(Bar)` 23 | ${(props) => 24 | props.changed && 25 | ` 26 | transform: rotate(-45deg) translate(-9px, 6px); 27 | `}; 28 | ` 29 | 30 | const Bar2 = styled(Bar)` 31 | ${(props) => 32 | props.changed && 33 | ` 34 | opacity: 0; 35 | `}; 36 | ` 37 | 38 | const Bar3 = styled(Bar)` 39 | ${(props) => 40 | props.changed && 41 | ` 42 | transform: rotate(45deg) translate(-8px, -8px); 43 | `}; 44 | ` 45 | 46 | const HamburgerMenu = ({ onClick, opened }) => ( 47 | 48 | 49 | 50 | 51 | 52 | ) 53 | 54 | export default HamburgerMenu 55 | -------------------------------------------------------------------------------- /site/src/components/Meta.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Helmet } from 'react-helmet' 3 | 4 | const Meta = () => ( 5 | 6 | Purify - Functional programming library for TypeScript 7 | 11 | 15 | 16 | 20 | 26 | 27 | ) 28 | 29 | export default Meta 30 | -------------------------------------------------------------------------------- /site/src/components/Sidebar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'gatsby-link' 3 | import styled from 'styled-components' 4 | import SidebarLink from './SidebarLink' 5 | import HamburgerMenu from './HamburgerMenu' 6 | import data from '../data' 7 | 8 | const Container = styled.div` 9 | min-height: 100%; 10 | ` 11 | 12 | const Header = styled.div` 13 | background-color: #3b74d7; 14 | color: white; 15 | display: flex; 16 | justify-content: center; 17 | height: 60px; 18 | flex-direction: column; 19 | align-items: center; 20 | border-bottom: 1px solid #e0e0e0; 21 | ` 22 | 23 | const HeaderTitle = styled(Link)` 24 | font-size: 20px; 25 | color: white; 26 | text-decoration: none; 27 | ` 28 | 29 | const HeaderTitleVersion = styled.span` 30 | margin-top: -7px; 31 | font-size: 15px; 32 | ` 33 | 34 | const Nav = styled.div` 35 | height: 100%; 36 | transition: 0.2s; 37 | 38 | @media only screen and (max-width: 768px) { 39 | opacity: ${(props) => (props.shown ? '1' : '0')}; 40 | height: ${(props) => (props.shown ? '100%' : '0')}; 41 | visibility: ${(props) => (props.shown ? 'visible' : 'hidden')}; 42 | } 43 | ` 44 | 45 | class Sidebar extends React.Component { 46 | state = { isMenuShown: false } 47 | 48 | toggleMenu = () => { 49 | this.setState({ isMenuShown: !this.state.isMenuShown }) 50 | } 51 | 52 | render() { 53 | return ( 54 | 55 |
56 | Purify 57 | v2.1.1 58 | 62 |
63 | 87 |
88 | ) 89 | } 90 | } 91 | 92 | export default Sidebar 93 | -------------------------------------------------------------------------------- /site/src/components/SidebarLink.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import Link from 'gatsby-link' 4 | 5 | const Container = styled(Link)` 6 | display: flex; 7 | justify-content: space-between; 8 | height: 45px; 9 | line-height: 45px; 10 | padding-left: 20px; 11 | transition: all 0.1s ease-in; 12 | text-decoration: none; 13 | color: inherit; 14 | border-bottom: 1px solid #ececec; 15 | 16 | &:hover { 17 | background-color: #f9f6f6; 18 | color: #398ae0; 19 | } 20 | ` 21 | 22 | const Tag = styled.div` 23 | background-color: ${(props) => props.palette.bgColor}; 24 | border-radius: 5px; 25 | color: ${(props) => props.palette.color}; 26 | height: 16px; 27 | line-height: 18px; 28 | margin-right: 20px; 29 | font-size: 12px; 30 | padding: 5px; 31 | min-width: 21px; 32 | text-align: center; 33 | align-self: center; 34 | ` 35 | 36 | const colorMap = { 37 | ADT: { color: '#2877ad', bgColor: '#d6eeff' }, 38 | Util: { color: '#0a9e1b', bgColor: '#d3f9d8' }, 39 | Typeclass: { color: 'white', bgColor: '#af87e6' } 40 | } 41 | 42 | const SidebarLink = ({ name, tag, link }) => ( 43 | 44 | {name} 45 | {tag && {tag}} 46 | 47 | ) 48 | 49 | export default SidebarLink 50 | -------------------------------------------------------------------------------- /site/src/components/UtilContent.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import DataTypeMethod from './DataTypeMethod' 4 | import SyntaxHighlighter from 'react-syntax-highlighter' 5 | import highlightStyle from 'react-syntax-highlighter/dist/esm/styles/hljs/googlecode' 6 | import Layout from './Layout' 7 | 8 | const Container = styled.div`` 9 | 10 | const Title = styled.h1` 11 | font-weight: inherit; 12 | 13 | @media only screen and (max-width: 768px) { 14 | text-align: center; 15 | margin-top: 0; 16 | } 17 | ` 18 | 19 | const Description = styled.div` 20 | padding-right: 15%; 21 | font-size: 1.05em; 22 | 23 | @media only screen and (max-width: 768px) { 24 | padding-right: 0; 25 | text-align: center; 26 | } 27 | ` 28 | 29 | const TopicHeader = styled.h2` 30 | font-weight: inherit; 31 | margin-bottom: 0; 32 | 33 | @media only screen and (max-width: 768px) { 34 | text-align: center; 35 | } 36 | ` 37 | 38 | const ExamplesContainer = styled.div` 39 | pre { 40 | margin: 0; 41 | } 42 | ` 43 | 44 | const ExampleHeader = styled.div` 45 | text-align: center; 46 | background-color: #f9f4f4; 47 | padding: 4px; 48 | ` 49 | 50 | const Example = styled.div` 51 | max-width: 650px; 52 | margin: 10px 0; 53 | border: 1px solid #f3eeee; 54 | ` 55 | 56 | const UtilContent = (util) => (props) => ( 57 | 58 | 59 | {util.name} 60 | {util.description} 61 | 62 | 63 | How to import 64 | 65 | {util.example.import} 66 | 67 | 68 | {util.example.before && ( 69 | 70 | Without {util.name} 71 | 72 | {util.example.before.join('\n')} 73 | 74 | 75 | )} 76 | {util.example.after && ( 77 | 78 | With {util.name} 79 | 80 | {util.example.after.join('\n')} 81 | 82 | 83 | )} 84 | 85 | {util.content.map((x) => ( 86 | <> 87 | {x.title} 88 | {x.methods.map((method) => DataTypeMethod(x.id, method))} 89 | 90 | ))} 91 | 92 | 93 | ) 94 | 95 | export default UtilContent 96 | -------------------------------------------------------------------------------- /site/src/components/layout.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | min-height: 100%; 4 | height: 100%; 5 | font-family: 'Titillium Web', sans-serif; 6 | margin: 0; 7 | padding: 0; 8 | } 9 | -------------------------------------------------------------------------------- /site/src/components/layout.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Meta from '../components/Meta' 4 | 5 | import Sidebar from '../components/Sidebar' 6 | import './Layout.css' 7 | 8 | const Container = styled.div` 9 | min-height: 100vh; 10 | display: flex; 11 | 12 | @media only screen and (max-width: 768px) { 13 | flex-direction: column; 14 | } 15 | ` 16 | 17 | const SidebarContainer = styled.div` 18 | flex: 1; 19 | min-height: 100%; 20 | box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1); 21 | z-index: 1; 22 | min-width: 200px; 23 | ` 24 | 25 | const ContentContainer = styled.div` 26 | flex: 7; 27 | background-color: #fbfbfb; 28 | padding: 20px; 29 | ` 30 | 31 | const LayoutWithSidebar = (children) => ( 32 | 33 | 34 | 35 | 36 | 37 | {children} 38 | 39 | ) 40 | 41 | const Layout = ({ children, location }) => 42 | location.pathname === '/' || location.pathname === '/purify/' 43 | ? children 44 | : LayoutWithSidebar(children) 45 | 46 | export default Layout 47 | -------------------------------------------------------------------------------- /site/src/pages/adts/Either.js: -------------------------------------------------------------------------------- 1 | import DataTypeContent from '../../components/DataTypeContent' 2 | import data from '../../data' 3 | 4 | export default DataTypeContent(data.datatypes.find((x) => x.name === 'Either')) 5 | -------------------------------------------------------------------------------- /site/src/pages/adts/EitherAsync.js: -------------------------------------------------------------------------------- 1 | import DataTypeContent from '../../components/DataTypeContent' 2 | import data from '../../data' 3 | 4 | export default DataTypeContent( 5 | data.datatypes.find((x) => x.name === 'EitherAsync') 6 | ) 7 | -------------------------------------------------------------------------------- /site/src/pages/adts/Maybe.js: -------------------------------------------------------------------------------- 1 | import DataTypeContent from '../../components/DataTypeContent' 2 | import data from '../../data' 3 | 4 | export default DataTypeContent(data.datatypes.find((x) => x.name === 'Maybe')) 5 | -------------------------------------------------------------------------------- /site/src/pages/adts/MaybeAsync.js: -------------------------------------------------------------------------------- 1 | import DataTypeContent from '../../components/DataTypeContent' 2 | import data from '../../data' 3 | 4 | export default DataTypeContent( 5 | data.datatypes.find((x) => x.name === 'MaybeAsync') 6 | ) 7 | -------------------------------------------------------------------------------- /site/src/pages/adts/NonEmptyList.js: -------------------------------------------------------------------------------- 1 | import DataTypeContent from '../../components/DataTypeContent' 2 | import data from '../../data' 3 | 4 | export default DataTypeContent( 5 | data.datatypes.find((x) => x.name === 'NonEmptyList') 6 | ) 7 | -------------------------------------------------------------------------------- /site/src/pages/adts/Tuple.js: -------------------------------------------------------------------------------- 1 | import DataTypeContent from '../../components/DataTypeContent' 2 | import data from '../../data' 3 | 4 | export default DataTypeContent(data.datatypes.find((x) => x.name === 'Tuple')) 5 | -------------------------------------------------------------------------------- /site/src/pages/changelog.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'gatsby-link' 3 | import styled, { css } from 'styled-components' 4 | import Layout from '../components/Layout' 5 | 6 | const versionStyle = css` 7 | margin-right: 15px; 8 | background: #d6eeff; 9 | padding: 0 5px; 10 | border-radius: 6px; 11 | text-decoration: none; 12 | color: #2877ad; 13 | min-width: 42px; 14 | text-align: center; 15 | height: 25px; 16 | ` 17 | 18 | const Version = styled(Link)` 19 | display: block; 20 | ${versionStyle}; 21 | ` 22 | 23 | const VersionStatic = styled.div` 24 | ${versionStyle}; 25 | color: black; 26 | ` 27 | 28 | const Description = styled.div` 29 | flex: 1; 30 | ` 31 | 32 | const Date = styled.div` 33 | flex: 1; 34 | 35 | @media only screen and (max-width: 768px) { 36 | text-align: right; 37 | } 38 | ` 39 | 40 | const VersionContainer = styled.div` 41 | display: flex; 42 | width: 85%; 43 | padding-bottom: 10px; 44 | 45 | @media only screen and (max-width: 768px) { 46 | width: 100%; 47 | } 48 | ` 49 | 50 | const Changelog = (props) => ( 51 | 52 |
53 |

Choose version:

54 | 55 | 1.0.0 - 2.1.1 56 | 57 | Since v1 purify follows semantic versioning, you can check out all 58 | releases on{' '} 59 | GitHub 60 | 61 | 62 | 63 | 0.16.3 64 | 65 | Check out the release on{' '} 66 | 67 | GitHub 68 | 69 | 70 | May 2021 71 | 72 | 73 | 0.16.2 74 | 75 | Check out the release on{' '} 76 | 77 | GitHub 78 | 79 | 80 | April 2021 81 | 82 | 83 | 0.16 84 | Version 1.0.0 preparation 85 | November 2020 86 | 87 | 88 | 0.15 89 | Polished Maybe/EitherAsync API 90 | April 2020 91 | 92 | 93 | 0.14.1 94 | 95 | Check out the release on{' '} 96 | 97 | GitHub 98 | 99 | 100 | January 2020 101 | 102 | 103 | 0.14 104 | JSON codecs and a new build 105 | December 2019 106 | 107 | 108 | 0.13.2 109 | 110 | Check out the release on{' '} 111 | 112 | GitHub 113 | 114 | 115 | September 2019 116 | 117 | 118 | 0.13.1 119 | 120 | Check out the release on{' '} 121 | 122 | GitHub 123 | 124 | 125 | August 2019 126 | 127 | 128 | 0.13 129 | Mostly quality of life utilities 130 | August 2019 131 | 132 | 133 | 0.12 134 | 135 | Complete rewrite, Async for all and more fantasy-land support 136 | 137 | January 2019 138 | 139 | 140 | 0.11 141 | 142 | NonEmptyList, Tuple destructuring, Improved pretty printing and more 143 | 144 | September 2018 145 | 146 | 147 | 0.10 148 | Initial release 149 | July 2018 150 | 151 |
152 |
153 | ) 154 | 155 | export default Changelog 156 | -------------------------------------------------------------------------------- /site/src/pages/changelog/0.11.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Link from 'gatsby-link' 4 | import SyntaxHighlighter from 'react-syntax-highlighter' 5 | import highlightStyle from 'react-syntax-highlighter/dist/esm/styles/hljs/googlecode' 6 | import Layout from '../../components/Layout' 7 | import { HL } from '../../components/HL' 8 | 9 | const Title = styled.h1` 10 | margin-bottom: 0; 11 | ` 12 | 13 | const Subtitle = styled.div` 14 | padding-bottom: 30px; 15 | ` 16 | 17 | const Topic = styled.h2` 18 | font-weight: normal; 19 | ` 20 | 21 | const TopicDescription = styled.div` 22 | padding-right: 15%; 23 | 24 | @media only screen and (max-width: 768px) { 25 | padding-right: 0; 26 | } 27 | ` 28 | 29 | const v011 = (props) => ( 30 | 31 | Purify v0.11 32 | September 20, 2018 33 |
34 | Not sure what purify is? Check out the{' '} 35 | Getting Started page. The package was 36 | renamed from `pure-ts` because of NSFW search results. 37 |
38 | 39 | NonEmptyList 40 | 41 | The new NonEmptyList ADT is a list that is guaranteed to have at least one 42 | value. Because of it's utility there is an implementation of this data 43 | structure in pretty much all ML languages, which is why it's now a part of 44 | purify too. Let's look at some example code: 45 | 46 | {`import { NonEmptyList, head } from 'purify-ts/adts/NonEmptyList' 47 | 48 | // Create functions with a contract - the caller has to verify that the input is valid instead of the callee 49 | // Since the list parameter is guaranteed to have at least one element, this function will always return a value 50 | const getRandomElement = (list: NonEmptyList): T => 51 | list[Math.floor(Math.random() * list.length)] 52 | 53 | // Doesn't compile 54 | getRandomElement(NonEmptyList([])) 55 | 56 | // Compiles, you don't need to check for elements if the list length is known at compile time 57 | getRandomElement(NonEmptyList([1])) 58 | 59 | // For runtime values, you have to deal with a Maybe 60 | const numbers: number[] = getArrayFromForm() 61 | const randEl: Maybe = NonEmptyList.fromArray(numbers).map(getRandomElement) 62 | `} 63 | 64 | 65 | 66 | Maybe and Either predicates narrow the type 67 | 68 | v0.11 makes a lot of improvements to type safety. Using one of 69 | TypeScript's more unique features - type predicates, the compiler can now 70 | know when it's safe to extract a value from a Maybe or Either. 71 | 72 | {`const sometimesValue: Maybe = ... 73 | 74 | sometimesValue.extract() // number | null 75 | 76 | if (sometimesValue.isJust()) { 77 | // Because extract() is in a block that guarantees the presence of a value, it's safe to return a number instead of a nullable number 78 | sometimesValue.extract() // number 79 | } 80 | `} 81 | 82 | 83 | 84 | Wildcard pattern for pattern matching 85 | 86 | You can now use a wildcard when pattern matching a Maybe, Either or any 87 | other ADT that supports pattern matching. 88 | 89 | {` // v0.10 90 | adt.caseOf({ Just: value => 0, Nothing: () => 0}) 91 | 92 | // Now 93 | adt.caseOf({ _: () => 0 })`} 94 | 95 | 96 | 97 | Tuple support for more array behaviour 98 | 99 | Tuples now implement the Iterable and ArrayLike interfaces. 100 | 101 | 102 | 103 | {` const [ fst, snd ] = Tuple(1, 2)`} 104 | 105 | 106 | New Maybe and Either methods 107 | 108 | Check out the docs for Maybe and Either to find out more about the 109 | following methods: 110 |
    111 |
  • 112 | Maybe.fromPredicate, Maybe#join and{' '} 113 | Maybe#orDefaultLazy 114 |
  • 115 |
  • 116 | Either#join and Either#orDefaultLazy 117 |
  • 118 |
119 |
120 | 121 | Improved pretty printing 122 | 123 | When using toString on ADT instances now it displays the 124 | constructor name. Keep in mind that this behaviour is strictly for pretty 125 | printing, in the case of JSON.stringify it strips out any ADT 126 | info and leaves only relevant JSON data. 127 | 128 | {`const val = Just(5) 129 | console.log(val.toString()) // "Just(5)" 130 | console.log(JSON.stringify(val)) // "5"`} 131 | 132 | 133 | 134 | 135 | All functions with multiple arguments support partial application 136 | 137 | 138 |
139 | Added partial application support to: List#at 140 |
141 |
142 | Improved partial application for: Tuple.fanout,{' '} 143 | Maybe.mapMaybe 144 |
145 |
146 | 147 | Other changes 148 | 149 |
    150 |
  • 151 | Removed Semigroup and Ord instances because they 152 | were not sound and making them typesafe required using very confusing 153 | type definitions. 154 |
  • 155 |
  • 156 | Fixed Either#isRight type definition (thanks{' '} 157 | sledorze) 158 |
  • 159 |
  • 160 | Made the value property inside the Maybe class private 161 |
  • 162 |
  • Reduced package size by excluding the tests
  • 163 |
  • 164 | Many improvements (rewordings, corrections and clarifications) made to 165 | the docs (thanks squirly) 166 |
  • 167 |
168 |
169 |
170 | ) 171 | 172 | export default v011 173 | -------------------------------------------------------------------------------- /site/src/pages/changelog/0.12.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Link from 'gatsby-link' 4 | import Layout from '../../components/Layout' 5 | import { HL } from '../../components/HL' 6 | 7 | const Title = styled.h1` 8 | margin-bottom: 0; 9 | ` 10 | 11 | const Subtitle = styled.div` 12 | padding-bottom: 30px; 13 | ` 14 | 15 | const Topic = styled.h2` 16 | font-weight: normal; 17 | ` 18 | 19 | const TopicDescription = styled.div` 20 | padding-right: 15%; 21 | 22 | @media only screen and (max-width: 768px) { 23 | padding-right: 0; 24 | } 25 | ` 26 | 27 | const v012 = (props) => ( 28 | 29 | Purify v0.12 30 | January 30, 2019 31 |
32 | Not sure what purify is? Check out the{' '} 33 | Getting Started page. Not sure if you 34 | want to introduce purify as a dependency to your project? Check out the 35 | new FAQ page! 36 |
37 |
38 | Before starting, I want to thank everyone that contributed to the project 39 | with bug reports, fixes and suggestions ⭐️. 40 |
41 | 42 | MaybeAsync and EitherAsync 43 | 44 | Dealing with asynchronous values was a huge pain point and I've spent a 45 | lot of time prototyping different solutions. 46 |
47 | The general approach to error handling in imperative languages is to throw 48 | exceptions, which didn't fit into the functional nature of purify. 49 |
50 | At the same time, TypeScript's type system made expressing functional 51 | patterns cumbersome, which didn't leave me with a lot of options. 52 |
53 | Despite those challenges I believe the final APIs for{' '} 54 | MaybeAsync and{' '} 55 | EitherAsync turned out fairly elegant 56 | and easy to use, please let me know your opinion! 57 |
58 | 59 | Complete rewrite 60 | 61 | Put simply, the library had too many issues mainly because of the 62 | "single-class" implementation of the ADTs, which have since been rewritten 63 | into plain functions and objects. 64 |
65 | This removed a whole class of errors (pun not intended), like a strange 66 | bug that prevented functions returning a Nothing to be annotated with the 67 | proper Maybe type (so strange I've filed{' '} 68 | 69 | an issue 70 | {' '} 71 | )
72 | This change is completely under the hood, the public API remains the same. 73 |
74 | 75 | Proper fantasy-land support 76 | 77 | All data types provided by purify now include a proper implementation of 78 | the `constructor` property which points to the type representative. 79 |
80 | As a bonus, there is also a Foldable instance for Tuple now! 81 |
82 | 83 | 84 | Typeclasses - scrapped. 85 |
86 | Id and Validation - removed. 87 |
88 | 89 | Old versions of purify exported interfaces which were designed to serve 90 | the purpose of typeclasses. 91 |
92 | There were numerous issues though - typeclasses like Monad could be easily 93 | represented as object methods, but functions like Applicative's `pure` (or 94 | `of` in fantasy-land) are meant to go on the type representative, not on 95 | the object. A Monad instance requires an Applicative instance which was 96 | unrepresentable in TypeScript's type system without resorting to 97 | techniques that don't fit into the "interfaces with generics" model. 98 | There's also the issues with typeclasses like Ord, Setoid and Semigroup 99 | which don't make much sense in JavaScript where you can compare all 100 | values. 101 |
102 |
103 | All of these things led to the removal of typeclasses from the library. 104 | With that went the Id datatype which serves no need anymore. 105 |
106 | Since typeclasses were also the justification for having folders in the 107 | library exports, now the folder structure is flat. 108 |
109 | This means that imports like{' '} 110 | {`import { Maybe } from 'purify-ts/adts/Maybe`} are now just{' '} 111 | {`import { Maybe } from 'purify-ts/Maybe'`}. 112 |
113 | The Validation module was removed for a completely different reason though 114 | - the API was just too limiting and ad-hoc, hopefully it will return soon 115 | in a better, more generic form. 116 |
117 | 118 | New Maybe methods 119 | 120 | The original focus for this release was better JS interop and before the 121 | implementation of MaybeAsync and EitherAsync took most of my time working 122 | on this project, two new methods were added to the Maybe data type. 123 |
124 |
    125 |
  • 126 | Maybe#chainNullable - The same as Maybe#chain but for 127 | functions that return a nullable value instead of Maybe. 128 |
  • 129 |
  • 130 | Maybe#extract - Now returns an undefined instead of null as 131 | undefined is used more often to reprent missing values. 132 |
  • 133 |
  • 134 | Maybe#extractNullable - The new name of Maybe#extract from 135 | previous versions of purify 136 |
  • 137 |
138 |
139 | 140 | Other changes 141 | 142 |
    143 |
  • 144 | There is now a "Guides" section for each data type which will 145 | hopefully include a lot of useful information in the near future. Stay 146 | tuned. 147 |
  • 148 |
  • 149 | Docs are now part of the npm package, which means you should be 150 | getting more information in your editor during autocomplete. 151 |
  • 152 |
  • 153 | Fixed bug where Just(null) would be treated as{' '} 154 | Nothing. 155 |
  • 156 |
157 |
158 |
159 | ) 160 | 161 | export default v012 162 | -------------------------------------------------------------------------------- /site/src/pages/changelog/0.13.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Link from 'gatsby-link' 4 | import Layout from '../../components/Layout' 5 | import { HL } from '../../components/HL' 6 | 7 | const Title = styled.h1` 8 | margin-bottom: 0; 9 | ` 10 | 11 | const Subtitle = styled.div` 12 | padding-bottom: 30px; 13 | ` 14 | 15 | const Topic = styled.h2` 16 | font-weight: normal; 17 | ` 18 | 19 | const TopicDescription = styled.div` 20 | padding-right: 15%; 21 | 22 | @media only screen and (max-width: 768px) { 23 | padding-right: 0; 24 | } 25 | ` 26 | 27 | const v013 = (props) => ( 28 | 29 | Purify v0.13 30 | August 15, 2019 31 |
32 | Not sure what purify is? Check out the{' '} 33 | Getting Started page. Not sure if you 34 | want to introduce purify as a dependency to your project? Check out the{' '} 35 | FAQ page! 36 |
37 | This release is a small one, it includes mostly utilities and typesafe 38 | versions of already existing JavaScript functions. 39 |
40 | 41 | New Function module 42 | 43 | Purify now has a general utility module called Function, it includes 44 | things like the identity function. As of this release it's quite small but 45 | hopefully it grows as TypeScript starts supporting more and more 46 | functional utilities like compose and pipe,{' '} 47 | check it out!
48 |
49 | 50 | More List functions 51 | 52 | The main goal of the List module is to provide typesafe alternatives of 53 | the built-in Array.prototype methods. 54 |
55 | With that in mind, List now includes find, findIndex and 56 | also immutable List.sort. 57 |
58 | 59 | Faster implementation 60 | 61 | Purify went throught another redesign in this release, the new class-based 62 | solution is what the original rewrite of purify in{' '} 63 | 0.12 should've been. 64 |
65 | Like last time, this redesign does not affect the public API of the 66 | library. 67 |
68 |
69 | ) 70 | 71 | export default v013 72 | -------------------------------------------------------------------------------- /site/src/pages/changelog/0.14.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Link from 'gatsby-link' 4 | import Layout from '../../components/Layout' 5 | import SyntaxHighlighter from 'react-syntax-highlighter' 6 | import highlightStyle from 'react-syntax-highlighter/dist/esm/styles/hljs/googlecode' 7 | 8 | const Title = styled.h1` 9 | margin-bottom: 0; 10 | ` 11 | 12 | const Subtitle = styled.div` 13 | padding-bottom: 30px; 14 | ` 15 | 16 | const Topic = styled.h2` 17 | font-weight: normal; 18 | ` 19 | 20 | const TopicDescription = styled.div` 21 | padding-right: 15%; 22 | 23 | @media only screen and (max-width: 768px) { 24 | padding-right: 0; 25 | } 26 | ` 27 | 28 | const v014 = (props) => ( 29 | 30 | Purify v0.14 31 | December 16, 2019 32 |
33 | Not sure what purify is? Check out the{' '} 34 | Getting Started page. Not sure if you 35 | want to introduce purify as a dependency to your project? Check out the{' '} 36 | FAQ page! 37 |
38 |
39 | 40 | New Codec module 41 | 42 | Purify now has a JSON validation/deserialization module called Codec. It's 43 | inspired by JSON decoders and encoders from Elm and various TypeScript 44 | libraries like io-ts and runtypes. 45 |
46 | Check it out! 47 |
48 | 49 | New, optimized build 50 | 51 | In 0.13.1, the build target was changed to "es5" to support IE11 and other 52 | older browsers. 53 |
To balance out all of the regressions that resulted from this 54 | (bigger bundle, slower performance) GitHub user{' '} 55 | imcotton added an ES6 build 56 |
57 | which you can now use as of 0.14 if your project runs on the server or you 58 | don't care about browser compatibility. 59 |
60 | 61 | {`//Before 62 | import { Either } from 'purify-ts/Either' 63 | 64 | //After 65 | import { Either } from 'purify-ts/es/Either'`} 66 | 67 | The new "es" build is 30% smaller and hopefully a little bit faster. 68 |
69 | 70 | A lot of improvements to this site 71 | 72 | Although this is not related to the update, this website received a lot of 73 | typo/stylistic fixes, examples and notes added, and some embarrassing 74 | copy-paste errors were removed. 75 | 76 | 77 | Other changes 78 | 79 |
    80 |
  • 81 | The implementation of Either.lefts and Either.rights was refactored 82 |
  • 83 |
  • 84 | {`The Just constructor now returns the correct type (Maybe) instead of Just`} 85 |
  • 86 |
  • 87 | Added a .prettierrc file to the repository for consistency across 88 | contributions 89 |
  • 90 |
91 |
92 |
93 | ) 94 | 95 | export default v014 96 | -------------------------------------------------------------------------------- /site/src/pages/changelog/0.15.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Link from 'gatsby-link' 4 | import Layout from '../../components/Layout' 5 | import SyntaxHighlighter from 'react-syntax-highlighter' 6 | import highlightStyle from 'react-syntax-highlighter/dist/esm/styles/hljs/googlecode' 7 | import { HL } from '../../components/HL' 8 | 9 | const Title = styled.h1` 10 | margin-bottom: 0; 11 | ` 12 | 13 | const Subtitle = styled.div` 14 | padding-bottom: 30px; 15 | ` 16 | 17 | const Topic = styled.h2` 18 | font-weight: normal; 19 | ` 20 | 21 | const TopicDescription = styled.div` 22 | padding-right: 15%; 23 | 24 | @media only screen and (max-width: 768px) { 25 | padding-right: 0; 26 | } 27 | ` 28 | 29 | const v015 = (props) => ( 30 | 31 | Purify v0.15 32 | April 30, 2020 33 |
34 | Not sure what purify is? Check out the{' '} 35 | Getting Started page. Also check out 36 | the FAQ page! 37 |
38 |
39 | 40 | Breaking change inside Codec 41 | 42 | 43 | Codec had a undefinedType codec 44 | which was insufficient as there was no way to create optional properties 45 | on an object. 46 |
47 | This was brought up in an issue and to resolve this I removed this codec 48 | in favor of a optional combinator, please check out{' '} 49 | the docs on how to use it. 50 |
51 | An updated Either/MaybeAsync API 52 | 53 | When I first started designing the API for Either and MaybeAsync I wanted 54 | to bring the ease of use of proper error handling within IO, just like it 55 | is in languages with do-notation and for-comprehensions. 56 |
That's why the original API in version{' '} 57 | 0.12 only worked with async/await and I 58 | thought and this will be enough for most people. 59 |
60 | Turns out most people started creating wrappers to make it more chainable 61 | and issues started piling in GitHub, no one liked the "imperative" API. 62 |
63 | That's why I decided to spend some time brainstorming a new API, that 64 | didn't force people to use async/await, and this is the result: 65 | 66 | {`// async/await 67 | const deleteUser = (req) => 68 | EitherAsync(async ({ liftEither, fromPromise, throwE }) => { 69 | const request = await liftEither(validateRequest(req)) 70 | 71 | try { 72 | const user = await fromPromise(getUser(request.userId)) 73 | } catch { 74 | throwE(Error.UserDoesNotExist) 75 | } 76 | 77 | return deleteUserDb(user) 78 | })`} 79 | 80 | 81 | {`// new API 82 | const deleteUser = (req) => 83 | liftEither(validateRequest(req)) 84 | .chain(request => fromPromise(() => getUser(request.userId))) 85 | .mapLeft(_ => Error.UserDoesNotExist) 86 | .chain(user => liftPromise(() => deleteUserDb(user)))`} 87 | 88 | This is stripped down version of the code, just to demonstrate the 89 | similarities, for the full version check out the updated documentation of{' '} 90 | EitherAsync and{' '} 91 | MaybeAsync.
92 | Both APIs will exist simultaneously and you're free to use whichever you 93 | like, much like how you can use both async/await and Promise.then. 94 |
95 | Other changes 96 | 97 |
    98 |
  • 99 | Added a new Either#swap method 100 |
  • 101 |
102 |
103 |
104 | ) 105 | 106 | export default v015 107 | -------------------------------------------------------------------------------- /site/src/pages/changelog/0.16.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Link from 'gatsby-link' 4 | import Layout from '../../components/Layout' 5 | import { HL } from '../../components/HL' 6 | 7 | const Title = styled.h1` 8 | margin-bottom: 0; 9 | ` 10 | 11 | const Subtitle = styled.div` 12 | padding-bottom: 30px; 13 | ` 14 | 15 | const Topic = styled.h2` 16 | font-weight: normal; 17 | ` 18 | 19 | const v016 = (props) => ( 20 | 21 | Purify v0.16 22 | November 7, 2020 23 |
24 | Not sure what purify is? Check out the{' '} 25 | Getting Started page. Also check out 26 | the FAQ page! 27 |
28 |
29 | This is a huge release with a lot of changes and I'm really exited! 0.16 30 | will be the final 0.x version before the official 1.0.0 release, you can 31 | think of 0.16 as a Version 1 RC. 32 | 33 | Breaking: Codec 34 | 35 |
    36 |
  • 37 | Renamed GetInterface to GetType. 38 |
  • 39 |
  • 40 | Running decode will not populate a field inside an object 41 | it's undefined, instead it will just leave it out. 42 |
  • 43 |
44 | 45 | Breaking: Either and Maybe 46 | 47 |
    48 |
  • 49 | Removed internal __value property inside both Either and 50 | Maybe. It was not supposed to be used anyway so there shouldn't be any 51 | breakages. 52 |
  • 53 |
  • 54 | Running Either#unsafeDecode used to throw a generic error if 55 | the value inside was Left. That error is still there, but if the value 56 | is an instance of Error, it will throw the value instead. 57 | This makes debugging and logging easier. 58 |
  • 59 |
60 | 61 | Breaking: EitherAsync and 62 | MaybeAsync 63 | 64 |
    65 |
  • 66 | Removed liftPromise from both EitherAsync and MaybeAsync. 67 | With the addition of PromiseLike support this utility is just 68 | an alias for the normal constructors, making it redundant. 69 |
  • 70 |
  • 71 | Since PromiseLike is now supported in both modules you should 72 | be using the special constructors liftEither,{' '} 73 | liftMaybe and fromPromise way less now. 74 |
    75 | Because of that they are now static methods (e.g. to use run{' '} 76 | EitherAsync.liftEither or MaybeAsync.fromPromise) 77 |
  • 78 |
79 | 80 | Additions: EitherAsync and 81 | MaybeAsync (there are a lot) 82 | 83 |
    84 |
  • 85 | Both EitherAsync and MaybeAsync now extend and support the{' '} 86 | PromiseLike interface. This means you can await them and you 87 | can interchange *Async and PromiseLike in most utility methods.
    88 | This is a huge win for productivity and reducing boilerplate, I hope 89 | we get to see cool examples of how this helps people. 90 |
  • 91 |
  • 92 | Both EitherAsync and MaybeAsync are now fantasy-land compatible. 93 |
  • 94 |
  • 95 | Added static methods to EitherAsync - lefts, rights,{' '} 96 | sequence, liftEither, fromPromise. 97 |
  • 98 |
  • 99 | Added instance methods to EitherAsync - swap, ifLeft 100 | , ifRight, bimap, join, ap,{' '} 101 | alt, extend, leftOrDefault,{' '} 102 | orDefault. 103 |
  • 104 |
  • 105 | Added static methods to MaybeAsync - catMaybes,{' '} 106 | liftMaybe, fromPromise. 107 |
  • 108 |
  • 109 | Added instance methods to EitherAsync - ifJust,{' '} 110 | ifNothing, join, ap, alt,{' '} 111 | extend, filter, orDefault. 112 |
  • 113 |
  • 114 | EitherAsync now has a looser type definition for{' '} 115 | EitherAsync#chain as it will merge the two errors together in 116 | an union type instead of showing a compiler error if the error types 117 | are different. 118 |
  • 119 |
120 | 121 | Additions: Either and Maybe 122 | 123 |
    124 |
  • 125 | Added static method to Maybe - isMaybe. 126 |
  • 127 |
  • 128 | Added static methods to Either - isEither and{' '} 129 | sequence. 130 |
  • 131 |
  • 132 | Either now has a looser type definition for Either#chain as 133 | it will merge the two errors together in an union type instead of 134 | showing a compiler error if the error types are different . 135 |
  • 136 |
  • 137 | Either now has a runtime tag so that values are easier to debug 138 | (previously when you logged an Either you couldn't tell if it's Left 139 | or Right). 140 |
  • 141 |
142 | 143 | Additions: Codec 144 | 145 |
    146 |
  • 147 | Added new codecs and combinators: nullable,{' '} 148 | enumeration, intersect. 149 |
  • 150 |
  • 151 | Added a new property of each codec - schema, it returns a 152 | JSON Schema V6 of that codec so that you can reuse validation in 153 | non-JavaScript environments (tools, other languages etc.). 154 |
  • 155 |
  • 156 | Added a new utility type, FromType, that helps with creating 157 | codecs based on existing types. 158 |
  • 159 |
  • 160 | Added a new utility function - parseError, that takes an 161 | error string generated after running decode on a value and 162 | returns a meta object 163 |
    164 | which you can use to create all kinds of stuff - from custom error 165 | reporters to recovery mechanisms. 166 |
  • 167 |
  • 168 | If you use the maybe codec combinator inside a{' '} 169 | Codec.interface it will handle optional properties just like{' '} 170 | optional. 171 |
  • 172 |
173 | 174 | Additions: Other 175 | 176 |
    177 |
  • 178 | Added two new methods to Tuple - some and every that 179 | act like the Array methods of the same name. 180 |
  • 181 |
  • 182 | Added a new utility function to NonEmptyList - tail. 183 |
  • 184 |
185 | 186 | Bugfixes: Codec 187 | 188 |
    189 |
  • 190 | Fixed a critical bug in oneOf that caused encoding to fail. 191 |
  • 192 |
193 |
194 |
195 | ) 196 | 197 | export default v016 198 | -------------------------------------------------------------------------------- /site/src/pages/faq.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Layout from '../components/Layout' 3 | 4 | const FAQ = (props) => ( 5 | 6 |

FAQ

7 |
    8 |
  • 9 |

    Q: What are the future goals of the library?

    10 | The library's overall goals are to continue bringing popular patterns 11 | from FP languages into TypeScript. 12 |
  • 13 |
  • 14 |

    Q: How are new features decided and planned?

    15 | Most of the development stems from dogfooding the library in personal 16 | projects.
    17 | User feedback is also extremely important - check out the question at 18 | the bottom to see why. 19 |
  • 20 |
  • 21 |

    22 | Q: Is this library intended to be used on the front-end or the 23 | back-end? 24 |

    25 | Purify is intended to be used both on the front-end and the back-end. 26 |
    27 | Everything from the build config to the API choices and features 28 | included is made with this in mind. 29 |
  • 30 |
  • 31 |

    32 | Q: Is this library intended to be part of an ecosystem of likeminded 33 | libraries or handle additional functionality itself? 34 |

    35 | Purify is intented to be a single library with a focus on general 36 | purpose API. 37 |
    38 | Official bindings to popular libraries like React, Angular, Express etc. 39 | are not planned and most likely won't happen. 40 |
    41 |
  • 42 |
  • 43 |

    Q: What is the timeline for the new releases?

    44 | There are no exact dates for upcoming versions of Purify. 45 |
    46 | Now that the library is post-v1 you can expect a more irregular release 47 | schedule. 48 |
  • 49 |
  • 50 |

    Q: Should I expect breaking changes?

    51 | Situations in which you can definitely expect breaking changes are:{' '} 52 |
    53 | There is a new TypeScript release that allows for more type safety 54 |
    55 | There is a new TypeScript release that makes expressing certain 56 | constructs (like HKTs) possible 57 |
    58 | ECMAScript proposals that allow for a more elegant API (like the 59 | pipeline operator) reach stage 3

    60 | TL;DR - Breaking changes will be rare, but if the language evolves in a 61 | way that makes FP code easier to write then there will be changes for 62 | sure. 63 |
  • 64 |
  • 65 |

    Q: What should future contributors focus on?

    66 | Pull request for bugfixes or documentation improvements are always 67 | welcome, but trying to add new methods via a PR most likely won't be 68 | accepted. 69 |
    70 | Sharing use cases or pain points are the fastest way to see something 71 | implemented in the library and I'll greatly appreciate it. 72 |
    73 |
  • 74 |
75 |
76 | ) 77 | 78 | export default FAQ 79 | -------------------------------------------------------------------------------- /site/src/pages/getting-started.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'gatsby-link' 3 | import styled from 'styled-components' 4 | import Layout from '../components/Layout' 5 | 6 | const Container = styled.div` 7 | width: 50%; 8 | 9 | @media only screen and (max-width: 1024px) { 10 | width: 100%; 11 | } 12 | ` 13 | 14 | const Console = styled.div` 15 | background-color: #283646; 16 | padding: 5px 10px; 17 | color: white; 18 | margin: 10px 0; 19 | border-radius: 4px; 20 | ` 21 | 22 | const GettingStarted = (props) => ( 23 | 24 | 25 |

What is purify?

26 | Purify is a library for functional programming in TypeScript. Its purpose 27 | is to allow developers to use popular patterns and abstractions that are 28 | available in most functional languages. It is also{' '} 29 | 30 | Fantasy Land 31 | {' '} 32 | conformant. 33 |

Core values

34 |
    35 |
  • 36 | Elegant and developer-friendly API - Purify's design decisions 37 | are made with developer experience in mind. Purify doesn't try to 38 | change how you write TypeScript, instead it provides useful tools for 39 | making your code easier to read and maintain without resolving to 40 | hacks or scary type definitions. 41 |
  • 42 |
  • 43 | Type-safety - While purify can be used in vanilla JavaScript, 44 | it's entirely written with TypeScript and type safety in mind. While 45 | TypeScript does a great job at preventing runtime errors, purify goes 46 | a step further and provides utility functions for working with native 47 | objects like arrays in a type-safe manner. 48 |
  • 49 |
  • 50 | Emphasis on practical code - Higher-kinded types and other 51 | type-level features would be great additions to this library, but as 52 | of right now they don't have reasonable implementations in TypeScript. 53 | Purify focuses on being a library that you can include in any 54 | TypeScript project and favors clean and readable type definitions 55 | instead of advanced type features and a curated API instead of trying 56 | to port over another language's standard library. 57 |
  • 58 |
59 |

How to start?

60 | Purify is available as a package on npm. You can install it with a package 61 | manager of your choice: 62 | 63 | $ npm install purify-ts
$ yarn add purify-ts 64 |
65 | On the left sidebar you can find all of purify's contents, each page 66 | contains a guide on how to start using it.
67 | You can start by visiting the page about{' '} 68 | Maybe, one of the most popular data types. 69 |
70 | If you are worried about the future of the project, because perhaps you 71 | are evaluating its usage in a large project, consider checking out the{' '} 72 | FAQ. 73 |
74 |
75 | ) 76 | 77 | export default GettingStarted 78 | -------------------------------------------------------------------------------- /site/src/pages/guides/maybe-api-guide.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | import Layout from '../../components/Layout' 4 | import { HL } from '../../components/HL' 5 | import Link from 'gatsby-link' 6 | import SyntaxHighlighter from 'react-syntax-highlighter' 7 | import highlightStyle from 'react-syntax-highlighter/dist/esm/styles/hljs/googlecode' 8 | 9 | export const Note = styled.div` 10 | display: inline-block; 11 | background-color: #fcf4cd; 12 | border: 0 solid #f7e070; 13 | border-left-width: 8px; 14 | padding: 10px; 15 | margin: 10px 0; 16 | overflow-x: auto; 17 | 18 | @media only screen and (max-width: 768px) { 19 | display: block; 20 | } 21 | ` 22 | 23 | const MethodName = styled(Link)` 24 | font-size: 17px; 25 | font-weight: bold; 26 | color: #3b74d7; 27 | margin-top: 5px; 28 | display: inline-block; 29 | text-decoration: none; 30 | 31 | &:hover { 32 | text-decoration: underline; 33 | } 34 | ` 35 | 36 | const SmallMethodName = styled(MethodName)` 37 | font-size: initial; 38 | font-weight: initial; 39 | margin-top: 0; 40 | ` 41 | 42 | const MaybeApiGuide = (props) => ( 43 | 44 |

Which Maybe method am I supposed to use now? (API guide)

45 | We've all been in that research phase where we're still learning the API of 46 | library and deciding if it suits our usecases.
47 | The purpose of this guide is to make that process easier by grouping all 48 | available methods for the Maybe data type. 49 |

50 | Scenario #1 - I want to use Maybe but my codebase already has 51 | null/undefined all over the place 52 |

53 | One of purify's main goals is great interoperability with existing code. 54 | That is why the API for Maybe is rich in utility methods for working with 55 | nullable values.
56 | 57 | One might question the usage of Maybe (and purify) if you are still going 58 | to use nulls, there are already a lot of utility libraries like ramda and 59 | lodash that allow you to do that.
60 | With purify you can start using ubiquitous data structures that come with 61 | a lot of literature and examples in various programming languages (in this 62 | case Maybe)
63 | without sacrificing coding style or ease of interop, that's why using it 64 | instead of other libraries might be a good idea. 65 |
66 |
67 | 68 | Maybe.fromNullable 69 | /{' '} 70 | Maybe.fromFalsy /{' '} 71 | Maybe.fromPredicate{' '} 72 | / Maybe.encase 73 |
74 | These methods allow you to construct Maybe values from, as the names 75 | suggest, nullable and falsy values or in the case of the{' '} 76 | encase method - 77 | from a function that may throw an exception.
78 | `fromPredicate` is on the list because it can be used to cover all kinds of 79 | complicated checks, for example: 80 | 81 | {`const _ = Maybe.fromPredicate(x => x && x.length > 0, value)`} 82 | 83 | chainNullable 84 |
85 | Now that you have constructed your Maybe out of an optional value, you may 86 | want to transform it with a function that returns yet another optional 87 | value.
88 | If you are already familiar with the{' '} 89 | chain method 90 | (a.k.a. bind, flatMap or {'>>='}) you may think 91 | of using it in combination with any of the methods mentioned above:
92 | 93 | {`myMaybe.chain(x => Maybe.fromNullable(transform(x)))`} 94 | 95 | There's nothing wrong with that approach, but there's a helper method called 96 | `chainNullable` that does exactly the same thing
97 | without you having to manually construct a Maybe out of the return value of 98 | the transformation function.
99 | 100 | {`myMaybe.chainNullable(x => transform(x)) 101 | // or just straight up 102 | myMaybe.chainNullable(transform)`} 103 | 104 | extract /{' '} 105 | extractNullable /{' '} 106 | unsafeCoerce 107 |
108 | Sometimes you have to interact with code that expects a nullable value, in 109 | that case you can just unwrap a Maybe down to a primitive value like null or 110 | undefined using the methods above.
111 | 112 | Please note that while you may be tempted to wrap and unwrap manually 113 | every time you encounter a nullable value,
114 | consider that code designed with Maybe in mind is easier to maintain and 115 | use in the long term.
116 | Try to keep usage of the methods mentioned in this part of the guide low 117 | and only for compatibility reasons.
118 | Don't be afraid to start returning or expecing Maybe values in functions, 119 | you'll notice some benefits you haven't considered before! 120 |
121 |

Scenario #2 - I'm not sure how to check if a value exists or not

122 | There are numerous ways to check if a value exists with purify, but I want 123 | to focus on the fact that you rarely need to do so explicitly. 124 |
125 | Try to split up your code into functions and then find ways to combine them 126 | using many of the available transformation methods like 127 |
128 | Maybe#map or{' '} 129 | Maybe#chain or{' '} 130 | Maybe#extend or{' '} 131 | Maybe#filter... 132 | you get the point. 133 |
134 | There are so many methods you can chain so that your code is nice and 135 | declarative that you'll almost never have to unpack a Maybe and check 136 | manually. 137 |
138 | There are some cases where that is needed though, let's go through them:{' '} 139 |

140 | Maybe#isJust /{' '} 141 | Maybe#isNothing 142 |
143 | The most primitive of the bunch, these methods enable us to do JS-style 144 | checking if a value is missing or not. 145 |
146 | The method names are pretty self-explanatory so we won't go into much 147 | details, but it's generally not recommend to use those methods. 148 |
149 | Better choices are almost always available. 150 |
151 |
152 | Maybe#caseOf /{' '} 153 | Maybe#reduce 154 |
155 | caseOf is the 156 | go-to choice when none of the other methods seem good enough. 157 |
158 | Since pattern matching is still not available (yet) in JavaScript, caseOf 159 | tries to mimic this behaviour, allowing you to branch your logic by asking 160 | you for two functions that will handle each case. 161 |
162 | reduce is very, 163 | very similar, in fact it's so similar that it looks almost useless. The goal 164 | of reduce is to provide an instance for the Foldable typeclass for Maybe. 165 |
166 | If you like the minimalism of reduce and you don't care about Foldable or 167 | you haven't heard of it - no problem, you can use it instead of caseOf just 168 | fine! 169 |
170 |
171 | ) 172 | 173 | export default MaybeApiGuide 174 | -------------------------------------------------------------------------------- /site/src/pages/guides/maybeasync-eitherasync-for-haskellers.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Layout from '../../components/Layout' 3 | import { HL } from '../../components/HL' 4 | import SyntaxHighlighter from 'react-syntax-highlighter' 5 | import highlightStyle from 'react-syntax-highlighter/dist/esm/styles/hljs/googlecode' 6 | import { Note } from './maybe-api-guide' 7 | 8 | const MaybeApiGuide = (props) => ( 9 | 10 |

MaybeAsync and EitherAsync for Haskellers

11 | 12 | Keep in mind a lot of stuff have changed since this was written (back in 13 | January 2019), Either and MaybeAsync evolved to be more distant than the 14 | monad transformer design in late 2020 15 |
and instead adopted the PromiseLike interface to make it easier to 16 | work with and reduce the large amount of boilerplate the original 17 | implementation required. 18 |
19 |
20 | As mentioned in the description of those data types, MaybeAsync and 21 | EitherAsync are funky Promise-specialized monad transformers for Maybe and 22 | Either. 23 |
24 | Some things may feel out of place and that is completely intentional, 25 | porting monad transformers over to TypeScript was just not practical, 26 | especially the higher-kinded types and typeclasses part. 27 |
A lot of thought went into designing the APIs and I believe that the 28 | result is satisfactory. In fact, even though the implementation is 29 | completely different, code written in mtl style looks pretty similar! Here, 30 | take a look: 31 | 32 | {`tryToInsertUser user = runExceptT $ do 33 | validatedUser <- liftEither $ validateUser user 34 | userExists <- lift $ doesUserAlreadyExist validatedUser 35 | 36 | when userExists (throwE UserAlreadyExists) 37 | 38 | maybeToExceptT ServerError $ do 39 | updatedUser <- MaybeT $ hashPasswordInUser user 40 | lift $ insertUser updatedUser`} 41 | 42 | Keep in mind this code is not representative of the perfect or cleanest 43 | implementation for such a feature, I tried to shove as much functions, that 44 | are also possible in Maybe-EitherAsync, as I could. 45 |
46 | Here's the same logic implemented with purify in TypeScript: 47 | 48 | {`const tryToInsertUser = user => 49 | EitherAsync(async ({ liftEither, throwE, fromPromise }) => { 50 | const validatedUser = await liftEither(validateUser(user)) 51 | const userExists = await doesUserAlreadyExist(validatedUser) 52 | 53 | if (userExists) throwE('UserAlreadyExists') 54 | 55 | return fromPromise(MaybeAsync(async ({ fromPromise }) => { 56 | const updatedUser = await fromPromise(hashPasswordInUser(user)) 57 | return insertUser(updatedUser) 58 | }).toEitherAsync('ServerError').run()) 59 | })`} 60 | 61 | One important thing to understand about Maybe and EitherAsync is that the 62 | docs and the API create the illusion that code is running in some custom 63 | magical context that lets you safely unwrap values. 64 |
65 | Is it referred to as "MaybeAsync context" or "EitherAsync context", but in 66 | fact there's no magic and the only real context is the async/await block. 67 |
68 | That allows us to simulate do-notation using await and what those "lifting" 69 | function actually do is return Promises that get rejected when a value is 70 | missing.
71 | The `run` function will later on catch all those rejections and return a 72 | proper Maybe/Either value. 73 |

Glossary of functions

74 | 95 |
96 | ) 97 | 98 | export default MaybeApiGuide 99 | -------------------------------------------------------------------------------- /site/src/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'gatsby-link' 3 | import styled from 'styled-components' 4 | import Meta from '../components/Meta' 5 | import Layout from '../components/Layout' 6 | import ScaleLeap from '../assets/scaleleap' 7 | import SellMyCode from '../assets/sellmycode' 8 | import Schiphol from '../assets/schiphol' 9 | 10 | const Container = styled.div` 11 | display: flex; 12 | flex-direction: column; 13 | height: 100%; 14 | ` 15 | 16 | const Title = styled.h1` 17 | margin: 0; 18 | font-style: italic; 19 | ` 20 | 21 | const Subtitle = styled.h2` 22 | margin: 0; 23 | padding: 0 10px; 24 | 25 | @media only screen and (max-width: 768px) { 26 | font-size: 19px; 27 | } 28 | ` 29 | 30 | const NavBar = styled.div` 31 | background-color: #3b74d7; 32 | height: 60px; 33 | min-height: 60px; 34 | display: flex; 35 | justify-content: flex-end; 36 | padding-right: 80px; 37 | 38 | @media only screen and (max-width: 768px) { 39 | justify-content: center; 40 | padding-right: 0; 41 | 42 | height: 50px; 43 | min-height: 50px; 44 | } 45 | ` 46 | 47 | const NavBarLink = styled.span` 48 | color: white !important; 49 | font-size: 22px; 50 | align-self: center; 51 | padding: 0 10px; 52 | 53 | > a { 54 | text-decoration: none; 55 | 56 | &:-webkit-any-link { 57 | color: white !important; 58 | } 59 | 60 | &:link, 61 | &:visited, 62 | &:focus, 63 | &:hover, 64 | &:active { 65 | color: white; 66 | } 67 | } 68 | ` 69 | 70 | const Content = styled.div` 71 | text-align: center; 72 | background-color: #fbfbfb; 73 | height: 100%; 74 | 75 | @media only screen and (max-width: 768px) { 76 | padding-bottom: 25px; 77 | height: initial; 78 | } 79 | ` 80 | 81 | const Heading = styled.div` 82 | padding-top: 4%; 83 | ` 84 | 85 | const InstallBox = styled.div` 86 | padding: 12px 0; 87 | margin: 10px 0; 88 | background-color: white; 89 | border-top: 1px dashed #3b74d7; 90 | border-bottom: 1px dashed #3b74d7; 91 | ` 92 | 93 | const FeaturesContainer = styled.div` 94 | display: flex; 95 | justify-content: center; 96 | flex-wrap: wrap; 97 | ` 98 | 99 | const Feature = styled.div` 100 | flex: 1; 101 | padding: 0 10px; 102 | max-width: 380px; 103 | 104 | @media only screen and (max-width: 768px) { 105 | flex-basis: 100%; 106 | } 107 | ` 108 | 109 | const Footer = styled.div` 110 | position: absolute; 111 | bottom: 0; 112 | width: 100%; 113 | background-color: white; 114 | text-align: center; 115 | height: 94px; 116 | font-size: 14px; 117 | 118 | @media only screen and (max-width: 768px) { 119 | position: relative; 120 | background-color: #fbfbfb; 121 | height: initial; 122 | line-height: initial; 123 | padding-bottom: 10px; 124 | } 125 | 126 | a { 127 | margin-right: 15px; 128 | } 129 | 130 | img { 131 | height: 33px; 132 | } 133 | ` 134 | 135 | const FeatureTitle = styled.h3`` 136 | 137 | const WhoIsUsing = styled.h4` 138 | margin: 0; 139 | padding: 12px 0; 140 | ` 141 | 142 | const IndexPage = (props) => ( 143 | 144 | 145 | 146 | 147 | 148 | Docs 149 | 150 | 151 | Github 152 | 153 | 154 | 155 | 156 | 157 | <img 158 | src="https://raw.githubusercontent.com/gigobyte/purify/master/assets/logo.png" 159 | alt="Purify" 160 | title="Purify is developed and maintained by Stanislav Iliev, distributed under the ISC License." 161 | /> 162 | 163 | Functional programming library for TypeScript 164 | $ npm install purify-ts 165 | 166 | 167 | 168 | Not just a port 169 | For purify, bringing popular patterns doesn't mean copying the 170 | implementation down to the last details, it means expressing ideas 171 | in the cleanest way possible using the tools of the language 172 | 173 | 174 | Algebraic Data Types 175 | Purify provides a collection of algebraic data structures that will 176 | help you tackle common problems that increase code complexity, such 177 | as conditional logic and error handling 178 | 179 | 180 | Practical approach 181 | Purify is a library focused on practical functional programming in 182 | TypeScript. You will find many examples and tutorials in the{' '} 183 | docs section of this site. 184 | 185 | 186 | 187 | 230 | 231 | 232 | ) 233 | 234 | export default IndexPage 235 | -------------------------------------------------------------------------------- /site/src/pages/utils/Codec.js: -------------------------------------------------------------------------------- 1 | import UtilContent from '../../components/UtilContent' 2 | import data from '../../data' 3 | 4 | export default UtilContent(data.utils.find((x) => x.name === 'Codec')) 5 | -------------------------------------------------------------------------------- /site/src/pages/utils/Function.js: -------------------------------------------------------------------------------- 1 | import UtilContent from '../../components/UtilContent' 2 | import data from '../../data' 3 | 4 | export default UtilContent(data.utils.find((x) => x.name === 'Function')) 5 | -------------------------------------------------------------------------------- /site/src/pages/utils/List.js: -------------------------------------------------------------------------------- 1 | import UtilContent from '../../components/UtilContent' 2 | import data from '../../data' 3 | 4 | export default UtilContent(data.utils.find((x) => x.name === 'List')) 5 | -------------------------------------------------------------------------------- /site/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist/", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "module": "commonjs", 7 | "target": "esnext", 8 | "jsx": "react", 9 | "allowJs": true, 10 | "lib": ["dom", "es2015", "es2017"] 11 | }, 12 | "include": ["./src/**/*"] 13 | } 14 | -------------------------------------------------------------------------------- /src/Either.test.ts: -------------------------------------------------------------------------------- 1 | import { Nothing, Just } from './Maybe' 2 | import { Either, Left, Right } from './Either' 3 | import { describe, expect, test, vi } from 'vitest' 4 | 5 | const anything = Math.random() 6 | 7 | describe('Either', () => { 8 | test('fantasy-land', () => { 9 | expect(Left(Error()).constructor).toEqual(Either) 10 | expect(Right(5).constructor).toEqual(Either) 11 | }) 12 | 13 | test('inspect', () => { 14 | expect(Left('Err').inspect()).toEqual('Left("Err")') 15 | expect(Right(1).inspect()).toEqual('Right(1)') 16 | }) 17 | 18 | test('toString', () => { 19 | expect(Left('Err').toString()).toEqual('Left("Err")') 20 | expect(Right(1).toString()).toEqual('Right(1)') 21 | }) 22 | 23 | test('toJSON', () => { 24 | expect(JSON.stringify(Left('Err'))).toEqual('"Err"') 25 | expect(JSON.stringify(Right(1)).toString()).toEqual('1') 26 | }) 27 | 28 | test('of', () => { 29 | expect(Either.of(5)).toEqual(Right(5)) 30 | expect(Either['fantasy-land/of'](5)).toEqual(Right(5)) 31 | }) 32 | 33 | test('lefts', () => { 34 | expect(Either.lefts([Left('Error'), Left('Error2'), Right(5)])).toEqual([ 35 | 'Error', 36 | 'Error2' 37 | ]) 38 | }) 39 | 40 | test('rights', () => { 41 | expect(Either.rights([Right(10), Left('Error'), Right(5)])).toEqual([10, 5]) 42 | }) 43 | 44 | test('encase', () => { 45 | expect( 46 | Either.encase(() => { 47 | throw new Error('a') 48 | }) 49 | ).toEqual(Left(new Error('a'))) 50 | expect(Either.encase(() => 10)).toEqual(Right(10)) 51 | }) 52 | 53 | test('sequence', () => { 54 | expect(Either.sequence([])).toEqual(Right([])) 55 | expect(Either.sequence([Right(1), Right(2)])).toEqual(Right([1, 2])) 56 | expect(Either.sequence([Right(1), Left('Nope')])).toEqual(Left('Nope')) 57 | }) 58 | 59 | test('isEither', () => { 60 | expect(Either.isEither(Left(''))).toEqual(true) 61 | expect(Either.isEither(Right(''))).toEqual(true) 62 | expect(Either.isEither(undefined)).toEqual(false) 63 | expect(Either.isEither('')).toEqual(false) 64 | expect(Either.isEither({})).toEqual(false) 65 | }) 66 | 67 | test('isLeft', () => { 68 | expect(Left(anything).isLeft()).toEqual(true) 69 | expect(Right(anything).isLeft()).toEqual(false) 70 | }) 71 | 72 | test('isRight', () => { 73 | expect(Left(anything).isRight()).toEqual(false) 74 | expect(Right(anything).isRight()).toEqual(true) 75 | }) 76 | 77 | test('bimap', () => { 78 | expect( 79 | Left('Error').bimap( 80 | (x) => x + '!', 81 | (x) => x + 1 82 | ) 83 | ).toEqual(Left('Error!')) 84 | expect( 85 | Right(5).bimap( 86 | (x) => x + '!', 87 | (x) => x + 1 88 | ) 89 | ).toEqual(Right(6)) 90 | 91 | expect( 92 | Left('Error')['fantasy-land/bimap']( 93 | (x) => x + '!', 94 | (x) => x + 1 95 | ) 96 | ).toEqual(Left('Error!')) 97 | expect( 98 | Right(5)['fantasy-land/bimap']( 99 | (x) => x + '!', 100 | (x) => x + 1 101 | ) 102 | ).toEqual(Right(6)) 103 | }) 104 | 105 | test('map', () => { 106 | expect(Left('Error').map((x) => x + 1)).toEqual(Left('Error')) 107 | expect(Right(5).map((x) => x + 1)).toEqual(Right(6)) 108 | 109 | expect(Right(5)['fantasy-land/map']((x) => x + 1)).toEqual(Right(6)) 110 | }) 111 | 112 | test('mapLeft', () => { 113 | expect(Left('Error').mapLeft((x) => x + '!')).toEqual(Left('Error!')) 114 | expect(Right(5).mapLeft((x) => x + '!')).toEqual(Right(5)) 115 | }) 116 | 117 | test('ap', () => { 118 | expect(Right(5).ap(Right((x) => x + 1))).toEqual(Right(6)) 119 | expect(Right(5).ap(Left('Error' as never))).toEqual(Left('Error')) 120 | expect(Left('Error').ap(Right((x) => x + 1))).toEqual(Left('Error')) 121 | expect(Left('Error').ap(Left('Function Error'))).toEqual( 122 | Left('Function Error') 123 | ) 124 | 125 | expect(Right(5)['fantasy-land/ap'](Right((x) => x + 1))).toEqual(Right(6)) 126 | }) 127 | 128 | test('equals', () => { 129 | expect(Left('Error').equals(Left('Error'))).toEqual(true) 130 | expect(Left('Error').equals(Left('Error!'))).toEqual(false) 131 | expect(Left('Error').equals(Right('Error') as any)).toEqual(false) 132 | expect(Right(5).equals(Right(5))).toEqual(true) 133 | expect(Right(5).equals(Right(6))).toEqual(false) 134 | expect(Right(5).equals(Left('Error') as any)).toEqual(false) 135 | 136 | expect(Right(5)['fantasy-land/equals'](Right(5))).toEqual(true) 137 | }) 138 | 139 | test('chain', () => { 140 | expect(Left('Error').chain((x) => Right(x + 1))).toEqual(Left('Error')) 141 | expect(Right(5).chain((x) => Right(x + 1))).toEqual(Right(6)) 142 | 143 | expect(Right(5)['fantasy-land/chain']((x) => Right(x + 1))).toEqual( 144 | Right(6) 145 | ) 146 | }) 147 | 148 | test('chainLeft', () => { 149 | expect(Left('Error').chainLeft((x) => Left(x + '!'))).toEqual( 150 | Left('Error!') 151 | ) 152 | expect(Right(5).chainLeft((x) => Right(x + 1))).toEqual(Right(5)) 153 | }) 154 | 155 | test('join', () => { 156 | expect(Right(Right(5)).join()).toEqual(Right(5)) 157 | expect(Left(Left('')).join()).toEqual(Left(Left(''))) 158 | }) 159 | 160 | test('alt', () => { 161 | expect(Left('Error').alt(Left('Error!'))).toEqual(Left('Error!')) 162 | expect(Left('Error').alt(Right(5) as any)).toEqual(Right(5)) 163 | expect(Right(5).alt(Left('Error') as any)).toEqual(Right(5)) 164 | expect(Right(5).alt(Right(6))).toEqual(Right(5)) 165 | 166 | expect(Right(5)['fantasy-land/alt'](Right(6))).toEqual(Right(5)) 167 | }) 168 | 169 | test('altLazy', () => { 170 | const fn = vi.fn(() => Left('Error!')) 171 | const fn2 = vi.fn(() => Right(5)) 172 | expect(Left('Error').altLazy(fn)).toEqual(Left('Error!')) 173 | expect(Right(5).altLazy(fn2)).toEqual(Right(5)) 174 | 175 | expect(fn).toBeCalledTimes(1) 176 | expect(fn2).not.toHaveBeenCalled() 177 | }) 178 | 179 | test('reduce', () => { 180 | expect(Right(5).reduce((acc, x) => x * acc, 2)).toEqual(10) 181 | expect(Left('Error').reduce((acc, x) => x * acc, 0)).toEqual(0) 182 | 183 | expect(Right(5)['fantasy-land/reduce']((acc, x) => x * acc, 2)).toEqual(10) 184 | }) 185 | 186 | test('extend', () => { 187 | expect(Left('Error').extend((x) => x.isRight())).toEqual(Left('Error')) 188 | expect(Right(5).extend((x) => x.isRight())).toEqual(Right(true)) 189 | 190 | expect(Right(5)['fantasy-land/extend']((x) => x.isRight())).toEqual( 191 | Right(true) 192 | ) 193 | }) 194 | 195 | test('unsafeCoerce', () => { 196 | expect(Right(5).unsafeCoerce()).toEqual(5) 197 | expect(() => Left('Error').unsafeCoerce()).toThrow() 198 | expect(() => Left(new Error('a')).unsafeCoerce()).toThrowError( 199 | new Error('a') 200 | ) 201 | }) 202 | 203 | test('caseOf', () => { 204 | expect( 205 | Left('Error').caseOf({ Left: (x) => x, Right: () => 'No error' }) 206 | ).toEqual('Error') 207 | expect(Right(6).caseOf({ Left: (_) => 0, Right: (x) => x + 1 })).toEqual(7) 208 | expect(Right(6).caseOf({ _: () => 0 })).toEqual(0) 209 | expect(Left('Error').caseOf({ _: () => 0 })).toEqual(0) 210 | }) 211 | 212 | test('leftOrDefault', () => { 213 | expect(Left('Error').leftOrDefault('No error')).toEqual('Error') 214 | expect(Right(5).leftOrDefault('No error' as never)).toEqual('No error') 215 | }) 216 | 217 | test('orDefault', () => { 218 | expect(Left('Error').orDefault(0 as never)).toEqual(0) 219 | expect(Right(5).orDefault(0)).toEqual(5) 220 | }) 221 | 222 | test('leftOrDefaultLazy', () => { 223 | expect(Left('Error').leftOrDefaultLazy(() => 'No error')).toEqual('Error') 224 | expect(Right(5).leftOrDefaultLazy(() => 'No error' as never)).toEqual( 225 | 'No error' 226 | ) 227 | }) 228 | 229 | test('orDefaultLazy', () => { 230 | expect(Left('Error').orDefaultLazy(() => 0 as never)).toEqual(0) 231 | expect(Right(5).orDefaultLazy(() => 0)).toEqual(5) 232 | }) 233 | 234 | test('ifLeft', () => { 235 | let a = 0 236 | Left('Error').ifLeft(() => { 237 | a = 5 238 | }) 239 | expect(a).toEqual(5) 240 | 241 | let b = 0 242 | Right(5).ifLeft(() => { 243 | b = 5 244 | }) 245 | expect(b).toEqual(0) 246 | }) 247 | 248 | test('ifRight', () => { 249 | let a = 0 250 | Left('Error').ifRight(() => { 251 | a = 5 252 | }) 253 | expect(a).toEqual(0) 254 | 255 | let b = 0 256 | Right(5).ifRight(() => { 257 | b = 5 258 | }) 259 | expect(b).toEqual(5) 260 | }) 261 | 262 | test('toMaybe', () => { 263 | expect(Left('Error').toMaybe()).toEqual(Nothing) 264 | expect(Right(5).toMaybe()).toEqual(Just(5)) 265 | }) 266 | 267 | test('leftToMaybe', () => { 268 | expect(Left('Error').leftToMaybe()).toEqual(Just('Error')) 269 | expect(Right(5).leftToMaybe()).toEqual(Nothing) 270 | }) 271 | 272 | test('extract', () => { 273 | expect(Right(5).extract()).toEqual(5) 274 | expect(Left('Error').extract()).toEqual('Error') 275 | }) 276 | 277 | test('swap', () => { 278 | expect(Right(5).swap()).toEqual(Left(5)) 279 | expect(Left(5).swap()).toEqual(Right(5)) 280 | }) 281 | }) 282 | -------------------------------------------------------------------------------- /src/Either.ts: -------------------------------------------------------------------------------- 1 | import { Maybe, Just, Nothing } from './Maybe.js' 2 | 3 | export type EitherPatterns = 4 | | { Left: (l: L) => T; Right: (r: R) => T } 5 | | { _: () => T } 6 | 7 | export interface Either { 8 | /** Returns true if `this` is `Left`, otherwise it returns false */ 9 | isLeft(): this is Either 10 | /** Returns true if `this` is `Right`, otherwise it returns false */ 11 | isRight(): this is Either 12 | toJSON(): L | R 13 | inspect(): string 14 | toString(): string 15 | /** Given two functions, maps the value inside `this` using the first if `this` is `Left` or using the second one if `this` is `Right`. 16 | * If both functions return the same type consider using `Either#either` instead 17 | */ 18 | bimap(f: (value: L) => L2, g: (value: R) => R2): Either 19 | /** Maps the `Right` value of `this`, acts like an identity if `this` is `Left` */ 20 | map(f: (value: R) => R2): Either 21 | /** Maps the `Left` value of `this`, acts like an identity if `this` is `Right` */ 22 | mapLeft(f: (value: L) => L2): Either 23 | /** Applies a `Right` function over a `Right` value. Returns `Left` if either `this` or the function are `Left` */ 24 | ap(other: Either R2>): Either 25 | /** Compares `this` to another `Either`, returns false if the constructors or the values inside are different, e.g. `Right(5).equals(Left(5))` is false */ 26 | equals(other: Either): boolean 27 | /** Transforms `this` with a function that returns an `Either`. Useful for chaining many computations that may fail */ 28 | chain(f: (value: R) => Either): Either 29 | /** The same as Either#chain but executes the transformation function only if the value is Left. Useful for recovering from errors */ 30 | chainLeft(f: (value: L) => Either): Either 31 | /** Flattens nested Eithers. `e.join()` is equivalent to `e.chain(x => x)` */ 32 | join(this: Either>): Either 33 | /** Returns the first `Right` between `this` and another `Either` or the `Left` in the argument if both `this` and the argument are `Left` */ 34 | alt(other: Either): Either 35 | /** Lazy version of `alt` */ 36 | altLazy(other: () => Either): Either 37 | /** Takes a reducer and an initial value and returns the initial value if `this` is `Left` or the result of applying the function to the initial value and the value inside `this` */ 38 | reduce(reducer: (accumulator: T, value: R) => T, initialValue: T): T 39 | /** Returns `this` if it's a `Left`, otherwise it returns the result of applying the function argument to `this` and wrapping it in a `Right` */ 40 | extend(f: (value: Either) => R2): Either 41 | /** Returns the value inside `this` if it's a `Right` or either throws the value or a generic exception depending on whether the value is an Error */ 42 | unsafeCoerce(): R 43 | /** Structural pattern matching for `Either` in the form of a function */ 44 | caseOf(patterns: EitherPatterns): T 45 | /** Returns the value inside `this` if it\'s `Left` or a default value if `this` is `Right` */ 46 | leftOrDefault(defaultValue: L): L 47 | /** Returns the value inside `this` if it\'s `Right` or a default value if `this` is `Left` */ 48 | orDefault(defaultValue: R): R 49 | /** Lazy version of `orDefault`. Takes a function that returns the default value, that function will be called only if `this` is `Left` */ 50 | orDefaultLazy(getDefaultValue: () => R): R 51 | /** Lazy version of `leftOrDefault`. Takes a function that returns the default value, that function will be called only if `this` is `Right` */ 52 | leftOrDefaultLazy(getDefaultValue: () => L): L 53 | /** Runs an effect if `this` is `Left`, returns `this` to make chaining other methods possible */ 54 | ifLeft(effect: (value: L) => any): this 55 | /** Runs an effect if `this` is `Right`, returns `this` to make chaining other methods possible */ 56 | ifRight(effect: (value: R) => any): this 57 | /** Constructs a `Just` with the value of `this` if it\'s `Right` or a `Nothing` if `this` is `Left` */ 58 | toMaybe(): Maybe 59 | /** Constructs a `Just` with the value of `this` if it\'s `Left` or a `Nothing` if `this` is `Right` */ 60 | leftToMaybe(): Maybe 61 | /** Extracts the value out of `this` */ 62 | extract(): L | R 63 | /** Returns `Right` if `this` is `Left` and vice versa */ 64 | swap(): Either 65 | 66 | 'fantasy-land/bimap'( 67 | f: (value: L) => L2, 68 | g: (value: R) => R2 69 | ): Either 70 | 'fantasy-land/map'(f: (value: R) => R2): Either 71 | 'fantasy-land/ap'(other: Either R2>): Either 72 | 'fantasy-land/equals'(other: Either): boolean 73 | 'fantasy-land/chain'( 74 | f: (value: R) => Either 75 | ): Either 76 | 'fantasy-land/alt'(other: Either): Either 77 | 'fantasy-land/reduce'( 78 | reducer: (accumulator: T, value: R) => T, 79 | initialValue: T 80 | ): T 81 | 'fantasy-land/extend'(f: (value: Either) => R2): Either 82 | } 83 | 84 | interface EitherTypeRef { 85 | /** Takes a value and wraps it in a `Right` */ 86 | of(value: R): Either 87 | /** Takes a list of `Either`s and returns a list of all `Left` values */ 88 | lefts(list: readonly Either[]): L[] 89 | /** Takes a list of `Either`s and returns a list of all `Right` values */ 90 | rights(list: readonly Either[]): R[] 91 | /** Calls a function and returns a `Right` with the return value or an exception wrapped in a `Left` in case of failure */ 92 | encase(throwsF: () => R): Either 93 | /** Turns a list of `Either`s into an `Either` of list */ 94 | sequence(eithers: readonly Either[]): Either 95 | isEither(x: unknown): x is Either 96 | 97 | 'fantasy-land/of'(value: R): Either 98 | } 99 | 100 | export const Either: EitherTypeRef = { 101 | of(value: R): Either { 102 | return right(value) 103 | }, 104 | lefts(list: readonly Either[]): L[] { 105 | let result = [] 106 | 107 | for (const x of list) { 108 | if (x.isLeft()) { 109 | result.push(x.extract()) 110 | } 111 | } 112 | 113 | return result 114 | }, 115 | rights(list: readonly Either[]): R[] { 116 | let result = [] 117 | 118 | for (const x of list) { 119 | if (x.isRight()) { 120 | result.push(x.extract()) 121 | } 122 | } 123 | 124 | return result 125 | }, 126 | encase(throwsF: () => R): Either { 127 | try { 128 | return right(throwsF()) 129 | } catch (e: any) { 130 | return left(e) 131 | } 132 | }, 133 | sequence(eithers: readonly Either[]): Either { 134 | let res: R[] = [] 135 | 136 | for (const e of eithers) { 137 | if (e.isLeft()) { 138 | return e 139 | } 140 | res.push(e.extract() as R) 141 | } 142 | 143 | return right(res) 144 | }, 145 | isEither(x: unknown): x is Either { 146 | return x instanceof Left || x instanceof Right 147 | }, 148 | 149 | 'fantasy-land/of'(value: R): Either { 150 | return Either.of(value) 151 | } 152 | } 153 | 154 | class Right implements Either { 155 | private _ = 'R' 156 | 157 | constructor(private __value: R) {} 158 | 159 | isLeft(): this is Either { 160 | return false 161 | } 162 | 163 | isRight(): this is Either { 164 | return true 165 | } 166 | 167 | toJSON(): R { 168 | return this.__value 169 | } 170 | 171 | inspect(): string { 172 | return `Right(${this.__value})` 173 | } 174 | 175 | [Symbol.for('nodejs.util.inspect.custom')]( 176 | _depth: number, 177 | opts: unknown, 178 | inspect: Function 179 | ) { 180 | return `Right(${inspect(this.__value, opts)})` 181 | } 182 | 183 | toString(): string { 184 | return this.inspect() 185 | } 186 | 187 | bimap(_: (value: L) => L2, g: (value: R) => R2): Either { 188 | return right(g(this.__value)) 189 | } 190 | 191 | map(f: (value: R) => R2): Either { 192 | return right(f(this.__value)) 193 | } 194 | 195 | mapLeft(_: (value: L) => L2): Either { 196 | return this as any 197 | } 198 | 199 | ap(other: Either R2>): Either { 200 | return other.isRight() ? this.map(other.extract()) : (other as any) 201 | } 202 | 203 | equals(other: Either): boolean { 204 | return other.isRight() ? this.__value === other.extract() : false 205 | } 206 | 207 | chain(f: (value: R) => Either): Either { 208 | return f(this.__value) 209 | } 210 | 211 | chainLeft(_: (value: L) => Either): Either { 212 | return this as any 213 | } 214 | 215 | join(this: Right, L>): Either { 216 | return this.__value as any 217 | } 218 | 219 | alt(_: Either): Either { 220 | return this 221 | } 222 | 223 | altLazy(_: () => Either): Either { 224 | return this 225 | } 226 | 227 | reduce(reducer: (accumulator: T, value: R) => T, initialValue: T): T { 228 | return reducer(initialValue, this.__value) 229 | } 230 | 231 | extend(f: (value: Either) => R2): Either { 232 | return right(f(this)) 233 | } 234 | 235 | unsafeCoerce(): R { 236 | return this.__value 237 | } 238 | 239 | caseOf(patterns: EitherPatterns): T { 240 | return '_' in patterns ? patterns._() : patterns.Right(this.__value) 241 | } 242 | 243 | leftOrDefault(defaultValue: L): L { 244 | return defaultValue 245 | } 246 | 247 | orDefault(_: R): R { 248 | return this.__value 249 | } 250 | 251 | orDefaultLazy(_: () => R): R { 252 | return this.__value 253 | } 254 | 255 | leftOrDefaultLazy(getDefaultValue: () => L): L { 256 | return getDefaultValue() 257 | } 258 | 259 | ifLeft(_: (value: L) => any): this { 260 | return this 261 | } 262 | 263 | ifRight(effect: (value: R) => any): this { 264 | return effect(this.__value), this 265 | } 266 | 267 | toMaybe(): Maybe { 268 | return Just(this.__value) 269 | } 270 | 271 | leftToMaybe(): Maybe { 272 | return Nothing 273 | } 274 | 275 | extract(): L | R { 276 | return this.__value 277 | } 278 | 279 | swap(): Either { 280 | return left(this.__value) 281 | } 282 | 283 | declare 'fantasy-land/bimap': typeof this.bimap 284 | declare 'fantasy-land/map': typeof this.map 285 | declare 'fantasy-land/ap': typeof this.ap 286 | declare 'fantasy-land/equals': typeof this.equals 287 | declare 'fantasy-land/chain': typeof this.chain 288 | declare 'fantasy-land/alt': typeof this.alt 289 | declare 'fantasy-land/reduce': typeof this.reduce 290 | declare 'fantasy-land/extend': typeof this.extend 291 | } 292 | 293 | Right.prototype['fantasy-land/bimap'] = Right.prototype.bimap 294 | Right.prototype['fantasy-land/map'] = Right.prototype.map 295 | Right.prototype['fantasy-land/ap'] = Right.prototype.ap 296 | Right.prototype['fantasy-land/equals'] = Right.prototype.equals 297 | Right.prototype['fantasy-land/chain'] = Right.prototype.chain 298 | Right.prototype['fantasy-land/alt'] = Right.prototype.alt 299 | Right.prototype['fantasy-land/reduce'] = Right.prototype.reduce 300 | Right.prototype['fantasy-land/extend'] = Right.prototype.extend 301 | Right.prototype.constructor = Either as any 302 | 303 | class Left implements Either { 304 | private _ = 'L' 305 | 306 | constructor(private __value: L) {} 307 | 308 | isLeft(): this is Either { 309 | return true 310 | } 311 | 312 | isRight(): this is Either { 313 | return false 314 | } 315 | 316 | toJSON(): L { 317 | return this.__value 318 | } 319 | 320 | inspect(): string { 321 | return `Left(${JSON.stringify(this.__value)})` 322 | } 323 | 324 | [Symbol.for('nodejs.util.inspect.custom')]( 325 | _depth: number, 326 | opts: unknown, 327 | inspect: Function 328 | ) { 329 | return `Left(${inspect(this.__value, opts)})` 330 | } 331 | 332 | toString(): string { 333 | return this.inspect() 334 | } 335 | 336 | bimap(f: (value: L) => L2, _: (value: R) => R2): Either { 337 | return left(f(this.__value)) 338 | } 339 | 340 | map(_: (value: R) => R2): Either { 341 | return this as any 342 | } 343 | 344 | mapLeft(f: (value: L) => L2): Either { 345 | return left(f(this.__value)) 346 | } 347 | 348 | ap(other: Either R2>): Either { 349 | return other.isLeft() ? other : (this as any) 350 | } 351 | 352 | equals(other: Either): boolean { 353 | return other.isLeft() ? other.extract() === this.__value : false 354 | } 355 | 356 | chain(_: (value: R) => Either): Either { 357 | return this as any 358 | } 359 | 360 | chainLeft(f: (value: L) => Either): Either { 361 | return f(this.__value) 362 | } 363 | 364 | join(this: Either>): Either { 365 | return this as any 366 | } 367 | 368 | alt(other: Either): Either { 369 | return other 370 | } 371 | 372 | altLazy(other: () => Either): Either { 373 | return other() 374 | } 375 | 376 | reduce(_: (accumulator: T, value: R) => T, initialValue: T): T { 377 | return initialValue 378 | } 379 | 380 | extend(_: (value: Either) => R2): Either { 381 | return this as any 382 | } 383 | 384 | unsafeCoerce(): never { 385 | if (this.__value instanceof Error) { 386 | throw this.__value 387 | } 388 | 389 | throw new Error('Either#unsafeCoerce was ran on a Left') 390 | } 391 | 392 | caseOf(patterns: EitherPatterns): T { 393 | return '_' in patterns ? patterns._() : patterns.Left(this.__value) 394 | } 395 | 396 | leftOrDefault(_: L): L { 397 | return this.__value 398 | } 399 | 400 | orDefault(defaultValue: R): R { 401 | return defaultValue 402 | } 403 | 404 | orDefaultLazy(getDefaultValue: () => R): R { 405 | return getDefaultValue() 406 | } 407 | 408 | leftOrDefaultLazy(_: () => L): L { 409 | return this.__value 410 | } 411 | 412 | ifLeft(effect: (value: L) => any): this { 413 | return effect(this.__value), this 414 | } 415 | 416 | ifRight(_: (value: R) => any): this { 417 | return this 418 | } 419 | 420 | toMaybe(): Maybe { 421 | return Nothing 422 | } 423 | 424 | leftToMaybe(): Maybe { 425 | return Just(this.__value) 426 | } 427 | 428 | extract(): L | R { 429 | return this.__value 430 | } 431 | 432 | swap(): Either { 433 | return right(this.__value) 434 | } 435 | 436 | declare 'fantasy-land/bimap': typeof this.bimap 437 | declare 'fantasy-land/map': typeof this.map 438 | declare 'fantasy-land/ap': typeof this.ap 439 | declare 'fantasy-land/equals': typeof this.equals 440 | declare 'fantasy-land/chain': typeof this.chain 441 | declare 'fantasy-land/alt': typeof this.alt 442 | declare 'fantasy-land/reduce': typeof this.reduce 443 | declare 'fantasy-land/extend': typeof this.extend 444 | } 445 | 446 | Left.prototype['fantasy-land/bimap'] = Left.prototype.bimap 447 | Left.prototype['fantasy-land/map'] = Left.prototype.map 448 | Left.prototype['fantasy-land/ap'] = Left.prototype.ap 449 | Left.prototype['fantasy-land/equals'] = Left.prototype.equals 450 | Left.prototype['fantasy-land/chain'] = Left.prototype.chain 451 | Left.prototype['fantasy-land/alt'] = Left.prototype.alt 452 | Left.prototype['fantasy-land/reduce'] = Left.prototype.reduce 453 | Left.prototype['fantasy-land/extend'] = Left.prototype.extend 454 | Left.prototype.constructor = Either as any 455 | 456 | const left = (value: L): Either => new Left(value) 457 | 458 | const right = (value: R): Either => new Right(value) 459 | 460 | export { left as Left, right as Right } 461 | -------------------------------------------------------------------------------- /src/EitherAsync.test.ts: -------------------------------------------------------------------------------- 1 | import { EitherAsync } from './EitherAsync' 2 | import { Left, Right, Either } from './Either' 3 | import { Nothing, Just } from './Maybe' 4 | import { describe, expect, test, it, vi } from 'vitest' 5 | 6 | describe('EitherAsync', () => { 7 | test('fantasy-land', () => { 8 | expect(EitherAsync(async () => {}).constructor).toEqual(EitherAsync) 9 | }) 10 | 11 | test('liftEither', () => { 12 | EitherAsync(async ({ liftEither }) => { 13 | const value: 5 = await liftEither(Right<5>(5)) 14 | }) 15 | }) 16 | 17 | test('fromPromise', () => { 18 | EitherAsync(async ({ fromPromise }) => { 19 | const value: 5 = await fromPromise(Promise.resolve(Right<5>(5))) 20 | }) 21 | }) 22 | 23 | test('throwE', async () => { 24 | const ea = EitherAsync(async ({ liftEither, throwE }) => { 25 | const value: 5 = await liftEither(Right<5>(5)) 26 | throwE('Test') 27 | return value 28 | }) 29 | 30 | expect(await ea.run()).toEqual(Left('Test')) 31 | }) 32 | 33 | test('try/catch', async () => { 34 | const ea = EitherAsync(async ({ fromPromise, throwE }) => { 35 | try { 36 | await fromPromise(Promise.reject('shouldnt show')) 37 | } catch { 38 | throwE('should show') 39 | } 40 | }) 41 | 42 | expect(await ea.run()).toEqual(Left('should show')) 43 | }) 44 | 45 | test('Promise compatibility', async () => { 46 | const result = await EitherAsync(() => { 47 | throw 'Err' 48 | }) 49 | 50 | const result2 = await EitherAsync(async () => { 51 | return 'A' 52 | }) 53 | 54 | expect(result).toEqual(Left('Err')) 55 | expect(result2).toEqual(Right('A')) 56 | }) 57 | 58 | test('bimap', async () => { 59 | const newEitherAsync = EitherAsync(() => Promise.resolve(5)).bimap( 60 | (_) => 'left', 61 | (_) => 'right' 62 | ) 63 | const newEitherAsync2 = EitherAsync(() => { 64 | throw '' 65 | }).bimap( 66 | (_) => 'left', 67 | (_) => 'right' 68 | ) 69 | 70 | expect(await newEitherAsync.run()).toEqual(Right('right')) 71 | expect(await newEitherAsync2.run()).toEqual(Left('left')) 72 | }) 73 | 74 | test('async bimap', async () => { 75 | const newEitherAsync = EitherAsync(() => Promise.resolve(5)).bimap( 76 | async (_) => 'left', 77 | async (_) => 'right' 78 | ) 79 | 80 | const newEitherAsync2 = EitherAsync(() => { 81 | throw '' 82 | }).bimap( 83 | async (_) => 'left', 84 | async (_) => 'right' 85 | ) 86 | 87 | const newEitherAsync3 = EitherAsync(() => { 88 | throw '' 89 | }).bimap( 90 | async (_) => { 91 | throw 'left' 92 | }, 93 | async (_) => 'right' 94 | ) 95 | 96 | expect(await newEitherAsync.run()).toEqual(Right('right')) 97 | expect(await newEitherAsync2.run()).toEqual(Left('left')) 98 | expect(await newEitherAsync3.run()).toEqual(Left('left')) 99 | }) 100 | 101 | test('map', async () => { 102 | const newEitherAsync = EitherAsync(() => Promise.resolve(5)).map( 103 | (_) => 'val' 104 | ) 105 | 106 | expect(await newEitherAsync.run()).toEqual(Right('val')) 107 | }) 108 | 109 | test('mapLeft', async () => { 110 | const newEitherAsync = EitherAsync(() => 111 | Promise.reject(0) 112 | ).mapLeft((x) => x + 1) 113 | 114 | const newEitherAsync2 = EitherAsync(() => 115 | Promise.resolve(0) 116 | ).mapLeft((x) => x + 1) 117 | 118 | const newEitherAsync3 = EitherAsync.fromPromise(() => 119 | Promise.resolve(Left(2)) 120 | ).mapLeft(async (i) => i + 1) 121 | 122 | expect(await newEitherAsync.run()).toEqual(Left(1)) 123 | expect(await newEitherAsync2.run()).toEqual(Right(0)) 124 | expect(await newEitherAsync3.run()).toEqual(Left(3)) 125 | }) 126 | 127 | test('chain', async () => { 128 | const newEitherAsync = EitherAsync(() => Promise.resolve(5)).chain((_) => 129 | EitherAsync(() => Promise.resolve('val')) 130 | ) 131 | const newEitherAsync2 = EitherAsync(() => Promise.resolve(5))[ 132 | 'fantasy-land/chain' 133 | ]((_) => EitherAsync(() => Promise.resolve('val'))) 134 | 135 | expect(await newEitherAsync.run()).toEqual(Right('val')) 136 | expect(await newEitherAsync2.run()).toEqual(Right('val')) 137 | }) 138 | 139 | test('chain (with PromiseLike)', async () => { 140 | const newEitherAsync = EitherAsync(() => Promise.resolve(5)).chain((_) => 141 | Promise.resolve(Right('val')) 142 | ) 143 | const newEitherAsync2 = EitherAsync(() => Promise.resolve(5))[ 144 | 'fantasy-land/chain' 145 | ]((_) => Promise.resolve(Right('val'))) 146 | 147 | expect(await newEitherAsync.run()).toEqual(Right('val')) 148 | expect(await newEitherAsync2.run()).toEqual(Right('val')) 149 | }) 150 | 151 | test('chainLeft', async () => { 152 | const newEitherAsync = EitherAsync(() => Promise.resolve(5)).chainLeft( 153 | (_) => EitherAsync(() => Promise.resolve(7)) 154 | ) 155 | const newEitherAsync2 = EitherAsync(() => 156 | Promise.reject(5) 157 | ).chainLeft((e) => EitherAsync(() => Promise.resolve(e + 1))) 158 | 159 | expect(await newEitherAsync.run()).toEqual(Right(5)) 160 | expect(await newEitherAsync2.run()).toEqual(Right(6)) 161 | }) 162 | 163 | test('chainLeft (with PromiseLike)', async () => { 164 | const newEitherAsync = EitherAsync(() => Promise.resolve(5)).chainLeft( 165 | (_) => Promise.resolve(Right(7)) 166 | ) 167 | const newEitherAsync2 = EitherAsync(() => 168 | Promise.reject(5) 169 | ).chainLeft((e) => Promise.resolve(Right(e + 1))) 170 | 171 | expect(await newEitherAsync.run()).toEqual(Right(5)) 172 | expect(await newEitherAsync2.run()).toEqual(Right(6)) 173 | }) 174 | 175 | test('toMaybeAsync', async () => { 176 | const ma = EitherAsync(({ liftEither }) => liftEither(Left('123'))) 177 | 178 | expect(await ma.toMaybeAsync().run()).toEqual(Nothing) 179 | 180 | const ma2 = EitherAsync(({ liftEither }) => liftEither(Right(5))) 181 | 182 | expect(await ma2.toMaybeAsync().run()).toEqual(Just(5)) 183 | }) 184 | 185 | test('swap', async () => { 186 | const eitherAsyncRight = EitherAsync(() => Promise.resolve(5)) 187 | expect(await eitherAsyncRight.swap().run()).toEqual(Left(5)) 188 | 189 | const eitherAsyncLeft = EitherAsync(async () => Promise.reject('fail')) 190 | expect(await eitherAsyncLeft.swap().run()).toEqual(Right('fail')) 191 | }) 192 | 193 | test('ifLeft', async () => { 194 | let a = 0 195 | await EitherAsync.liftEither(Left('Error')).ifLeft(() => { 196 | a = 5 197 | }) 198 | expect(a).toEqual(5) 199 | 200 | let b = 0 201 | await EitherAsync.liftEither(Right(5)).ifLeft(() => { 202 | b = 5 203 | }) 204 | expect(b).toEqual(0) 205 | }) 206 | 207 | test('ifRight', async () => { 208 | let a = 0 209 | await EitherAsync.liftEither(Left('Error')).ifRight(() => { 210 | a = 5 211 | }) 212 | expect(a).toEqual(0) 213 | 214 | let b = 0 215 | await EitherAsync.liftEither(Right(5)).ifRight(() => { 216 | b = 5 217 | }) 218 | expect(b).toEqual(5) 219 | }) 220 | 221 | describe('run', () => { 222 | it('resolves to Left if any of the async Eithers are Left', async () => { 223 | expect( 224 | await EitherAsync(({ fromPromise }) => 225 | fromPromise(Promise.resolve(Left('Error'))) 226 | ).run() 227 | ).toEqual(Left('Error')) 228 | }) 229 | 230 | it('resolves to a Left with the rejected value if there is a rejected promise', async () => { 231 | expect( 232 | await EitherAsync(({ fromPromise }) => 233 | fromPromise(Promise.reject('Some error')) 234 | ).run() 235 | ).toEqual(Left('Some error')) 236 | }) 237 | 238 | it('resolves to Left with an exception if there is an exception thrown', async () => { 239 | expect( 240 | await EitherAsync(() => { 241 | throw new Error('!') 242 | }).run() 243 | ).toEqual(Left(Error('!'))) 244 | }) 245 | 246 | it('resolve to Right if the promise resolves successfully', async () => { 247 | expect( 248 | await EitherAsync(({ fromPromise }) => 249 | fromPromise(Promise.resolve(Right(5))) 250 | ).run() 251 | ).toEqual(Right(5)) 252 | }) 253 | }) 254 | 255 | test('leftOrDefault', async () => { 256 | const eitherAsyncRight = EitherAsync(() => Promise.resolve(5)) 257 | expect(await eitherAsyncRight.leftOrDefault(5)).toEqual(5) 258 | 259 | const eitherAsyncLeft = EitherAsync(async () => Promise.reject('fail')) 260 | expect(await eitherAsyncLeft.leftOrDefault(5)).toEqual('fail') 261 | }) 262 | 263 | test('orDefault', async () => { 264 | const eitherAsyncRight = EitherAsync(() => Promise.resolve(5)) 265 | expect(await eitherAsyncRight.orDefault(10)).toEqual(5) 266 | 267 | const eitherAsyncLeft = EitherAsync(async () => 268 | Promise.reject('fail') 269 | ) 270 | expect(await eitherAsyncLeft.orDefault(5)).toEqual(5) 271 | }) 272 | 273 | test('join', async () => { 274 | const ea = EitherAsync(async () => 1).map((x) => 275 | EitherAsync(async () => x + 1) 276 | ) 277 | 278 | expect(await ea.join()).toEqual(Right(2)) 279 | 280 | const ea2 = EitherAsync(async () => 1).map(() => 281 | EitherAsync(async () => { 282 | throw 'Err' 283 | }) 284 | ) 285 | 286 | expect(await ea2.join()).toEqual(Left('Err')) 287 | 288 | const ea3 = EitherAsync(async () => { 289 | throw 'Err' 290 | }) 291 | 292 | expect(await ea3.join()).toEqual(Left('Err')) 293 | }) 294 | 295 | test('ap', async () => { 296 | expect( 297 | await EitherAsync.liftEither(Right(5)).ap( 298 | EitherAsync(async () => (x: number) => x + 1) 299 | ) 300 | ).toEqual(Right(6)) 301 | expect( 302 | await EitherAsync.liftEither(Right(5)).ap( 303 | EitherAsync(() => { 304 | throw 'Error' 305 | }) 306 | ) 307 | ).toEqual(Left('Error')) 308 | expect( 309 | await EitherAsync.liftEither(Left('Error')).ap( 310 | EitherAsync(async () => (x: number) => x + 1) 311 | ) 312 | ).toEqual(Left('Error')) 313 | expect( 314 | await EitherAsync.liftEither(Left('Error')).ap( 315 | EitherAsync(() => { 316 | throw 'Function Error' 317 | }) 318 | ) 319 | ).toEqual(Left('Function Error')) 320 | }) 321 | 322 | test('alt', async () => { 323 | expect( 324 | await EitherAsync.liftEither(Left('Error')).alt( 325 | EitherAsync.liftEither(Left('Error!')) 326 | ) 327 | ).toEqual(Left('Error!')) 328 | expect( 329 | await EitherAsync.liftEither(Left('Error')).alt( 330 | EitherAsync.liftEither(Right(5) as any) 331 | ) 332 | ).toEqual(Right(5)) 333 | expect( 334 | await EitherAsync.liftEither(Right(5)).alt( 335 | EitherAsync.liftEither(Left('Error') as any) 336 | ) 337 | ).toEqual(Right(5)) 338 | expect( 339 | await EitherAsync.liftEither(Right(5)).alt( 340 | EitherAsync.liftEither(Right(6)) 341 | ) 342 | ).toEqual(Right(5)) 343 | 344 | expect( 345 | await EitherAsync.liftEither(Right(5))['fantasy-land/alt']( 346 | EitherAsync.liftEither(Right(6)) 347 | ) 348 | ).toEqual(Right(5)) 349 | }) 350 | 351 | test('extend', async () => { 352 | expect( 353 | await EitherAsync.liftEither(Left('Error')).extend((x) => 354 | x.orDefault(6) 355 | ) 356 | ).toEqual(Left('Error')) 357 | expect( 358 | await EitherAsync.liftEither(Right(5)).extend((x) => x.orDefault(6)) 359 | ).toEqual(Right(5)) 360 | }) 361 | 362 | test('fromPromise static', async () => { 363 | expect( 364 | await EitherAsync.fromPromise(() => Promise.resolve(Right(5))).run() 365 | ).toEqual(Right(5)) 366 | expect( 367 | await EitherAsync.fromPromise(() => Promise.reject(5)).run() 368 | ).toEqual(Left(5)) 369 | }) 370 | 371 | test('liftEither static', async () => { 372 | expect(await EitherAsync.liftEither(Right(5)).run()).toEqual(Right(5)) 373 | expect(await EitherAsync.liftEither(Left(5)).run()).toEqual(Left(5)) 374 | }) 375 | 376 | test('lefts', async () => { 377 | expect( 378 | await EitherAsync.lefts([ 379 | EitherAsync.liftEither(Left('Error')), 380 | EitherAsync.liftEither(Left('Error2')), 381 | EitherAsync.liftEither(Right(5)) 382 | ]) 383 | ).toEqual(['Error', 'Error2']) 384 | }) 385 | 386 | test('rights', async () => { 387 | expect( 388 | await EitherAsync.rights([ 389 | EitherAsync.liftEither(Right(10)), 390 | EitherAsync.liftEither(Left('Error')), 391 | EitherAsync.liftEither(Right(5)) 392 | ]) 393 | ).toEqual([10, 5]) 394 | }) 395 | 396 | test('sequence', async () => { 397 | expect(await EitherAsync.sequence([])).toEqual(Right([])) 398 | 399 | const uncalledFn = vi.fn() 400 | 401 | expect( 402 | await EitherAsync.sequence([ 403 | EitherAsync( 404 | () => 405 | new Promise((_, reject) => { 406 | setTimeout(() => { 407 | reject('A') 408 | }, 200) 409 | }) 410 | ), 411 | EitherAsync(uncalledFn) 412 | ]) 413 | ).toEqual(Left('A')) 414 | 415 | expect(uncalledFn).toHaveBeenCalledTimes(0) 416 | 417 | const calledFn = vi.fn() 418 | 419 | expect( 420 | await EitherAsync.sequence([ 421 | EitherAsync.liftEither(Right(1)), 422 | EitherAsync(async () => { 423 | calledFn() 424 | return 2 425 | }) 426 | ]) 427 | ).toEqual(Right([1, 2])) 428 | 429 | expect(calledFn).toHaveBeenCalledTimes(1) 430 | }) 431 | 432 | test('all', async () => { 433 | expect(await EitherAsync.all([])).toEqual(Right([])) 434 | 435 | const fn1 = vi.fn() 436 | 437 | expect( 438 | await EitherAsync.all([ 439 | EitherAsync( 440 | () => 441 | new Promise((_, reject) => { 442 | setTimeout(() => { 443 | reject('A') 444 | }, 200) 445 | }) 446 | ), 447 | EitherAsync(async () => { 448 | fn1() 449 | return 2 450 | }) 451 | ]) 452 | ).toEqual(Left('A')) 453 | 454 | expect(fn1).toHaveBeenCalledTimes(1) 455 | 456 | const fn2 = vi.fn() 457 | 458 | expect( 459 | await EitherAsync.all([ 460 | EitherAsync.liftEither(Right(1)), 461 | EitherAsync(async () => { 462 | fn2() 463 | return 2 464 | }) 465 | ]) 466 | ).toEqual(Right([1, 2])) 467 | 468 | expect(fn2).toHaveBeenCalledTimes(1) 469 | }) 470 | 471 | test('throwing in some method', async () => { 472 | const ea = EitherAsync(async () => 5).map(() => { 473 | throw 'AAA' 474 | }) 475 | 476 | expect(await ea).toEqual(Left('AAA')) 477 | }) 478 | 479 | test('void', async () => { 480 | const ea: EitherAsync = EitherAsync( 481 | async () => 5 482 | ).void() 483 | 484 | expect(await ea).toEqual(Right(undefined)) 485 | }) 486 | 487 | test('caseOf', async () => { 488 | expect( 489 | await EitherAsync.liftEither(Left('Error')).caseOf({ 490 | Left: (x) => x, 491 | Right: () => 'No error' 492 | }) 493 | ).toEqual('Error') 494 | expect( 495 | await EitherAsync.liftEither(Right(6)).caseOf({ 496 | Left: (_) => 0, 497 | Right: (x) => x + 1 498 | }) 499 | ).toEqual(7) 500 | expect( 501 | await EitherAsync.liftEither(Right(6)).caseOf({ _: () => 0 }) 502 | ).toEqual(0) 503 | expect( 504 | await EitherAsync.liftEither(Left('Error')).caseOf({ _: () => 0 }) 505 | ).toEqual(0) 506 | }) 507 | 508 | test('finally', async () => { 509 | let a = 0 510 | await EitherAsync.liftEither(Left('Error')).finally(() => { 511 | a = 5 512 | }) 513 | expect(a).toEqual(5) 514 | 515 | let b = 0 516 | await EitherAsync.liftEither(Right(5)).finally(() => { 517 | b = 5 518 | }) 519 | expect(b).toEqual(5) 520 | }) 521 | }) 522 | -------------------------------------------------------------------------------- /src/EitherAsync.ts: -------------------------------------------------------------------------------- 1 | import { Either, EitherPatterns, Left, Right } from './Either.js' 2 | import { MaybeAsync } from './MaybeAsync.js' 3 | 4 | /** You can use this to extract the type of the `Right` value out of an `EitherAsync`. */ 5 | export type ExtractRight = 6 | T extends PromiseLike> ? R : never 7 | 8 | /** You can use this to extract the type of the `Left` value out of an `EitherAsync`. */ 9 | export type ExtractLeft = 10 | T extends PromiseLike> ? L : never 11 | 12 | export interface EitherAsyncTypeRef { 13 | /** Constructs an `EitherAsync` object from a function that takes an object full of helpers that let you lift things into the `EitherAsync` context and returns a Promise */ 14 | ( 15 | runPromise: (helpers: EitherAsyncHelpers) => PromiseLike 16 | ): EitherAsync 17 | /** Constructs an `EitherAsync` object from a function that returns an Either wrapped in a Promise */ 18 | fromPromise(f: () => PromiseLike>): EitherAsync 19 | /** Constructs an `EitherAsync` object from an Either */ 20 | liftEither(either: Either): EitherAsync 21 | /** Takes a list of `EitherAsync`s and returns a Promise that will resolve with all `Left` values. Internally it uses `Promise.all` to wait for all results */ 22 | lefts(list: readonly EitherAsync[]): Promise 23 | /** Takes a list of `EitherAsync`s and returns a Promise that will resolve with all `Right` values. Internally it uses `Promise.all` to wait for all results */ 24 | rights(list: readonly EitherAsync[]): Promise 25 | /** Turns a list of `EitherAsync`s into an `EitherAsync` of list. The returned `Promise` will be rejected as soon as a single `EitherAsync` resolves to a `Left`, it will not wait for all Promises to resolve and since `EitherAsync` is lazy, unlike `Promise`, the remaining async operations will not be executed at all */ 26 | sequence(eas: readonly EitherAsync[]): EitherAsync 27 | /** The same as `EitherAsync.sequence`, but it will run all async operations at the same time rather than sequentially */ 28 | all(eas: readonly EitherAsync[]): EitherAsync 29 | } 30 | 31 | export interface EitherAsync extends PromiseLike> { 32 | /** 33 | * It's important to remember how `run` will behave because in an 34 | * async context there are other ways for a function to fail other 35 | * than to return a Nothing, for example: 36 | * If any of the computations inside EitherAsync resolved to a Left, 37 | * `run` will return a Promise resolved to that Left. 38 | * If any of the promises were to be rejected then `run` will return 39 | * a Promise resolved to a Left with the rejection value inside 40 | * If an exception is thrown then `run` will return a Promise 41 | * resolved to a Left with the exception inside 42 | * If none of the above happen then a promise resolved to the 43 | * returned value wrapped in a Right will be returned 44 | */ 45 | run(): Promise> 46 | /** Given two functions, maps the value that the Promise inside `this` resolves to using the first if it is `Left` or using the second one if it is `Right` */ 47 | bimap( 48 | f: (value: L) => L2, 49 | g: (value: R) => R2 50 | ): EitherAsync, Awaited> 51 | /** Transforms the `Right` value of `this` with a given function. If the `EitherAsync` that is being mapped resolves to a Left then the mapping function won't be called and `run` will resolve the whole thing to that Left, just like the regular Either#map */ 52 | map(f: (value: R) => R2): EitherAsync> 53 | /** Maps the `Left` value of `this`, acts like an identity if `this` is `Right` */ 54 | mapLeft(f: (value: L) => L2): EitherAsync, R> 55 | /** Transforms `this` with a function that returns a `EitherAsync`. Behaviour is the same as the regular Either#chain */ 56 | chain( 57 | f: (value: R) => PromiseLike> 58 | ): EitherAsync 59 | /** The same as EitherAsync#chain but executes the transformation function only if the value is Left. Useful for recovering from errors */ 60 | chainLeft( 61 | f: (value: L) => PromiseLike> 62 | ): EitherAsync 63 | /** Flattens an `Either` nested inside an `EitherAsync`. `e.join()` is equivalent to `e.chain(async x => x)` */ 64 | join(this: EitherAsync>): EitherAsync 65 | /** Converts `this` to a MaybeAsync, discarding any error values */ 66 | toMaybeAsync(): MaybeAsync 67 | /** Returns `Right` if `this` is `Left` and vice versa */ 68 | swap(): EitherAsync 69 | /** Runs an effect if `this` is `Left`, returns `this` to make chaining other methods possible */ 70 | ifLeft(effect: (value: L) => any): EitherAsync 71 | /** Runs an effect if `this` is `Right`, returns `this` to make chaining other methods possible */ 72 | ifRight(effect: (value: R) => any): EitherAsync 73 | /** Applies a `Right` function wrapped in `EitherAsync` over a future `Right` value. Returns `Left` if either the `this` resolves to a `Left` or the function is `Left` */ 74 | ap( 75 | other: PromiseLike R2>> 76 | ): EitherAsync> 77 | /** Returns the first `Right` between the future value of `this` and another `EitherAsync` or the `Left` in the argument if both `this` and the argument resolve to `Left` */ 78 | alt(other: EitherAsync): EitherAsync 79 | /** Returns `this` if it resolves to a `Left`, otherwise it returns the result of applying the function argument to `this` and wrapping it in a `Right` */ 80 | extend(f: (value: EitherAsync) => R2): EitherAsync> 81 | /** Returns a Promise that resolves to the value inside `this` if it\'s `Left` or a default value if `this` is `Right` */ 82 | leftOrDefault(defaultValue: L): Promise 83 | /** Returns a Promise that resolves to the value inside `this` if it\'s `Right` or a default value if `this` is `Left` */ 84 | orDefault(defaultValue: R): Promise 85 | /** Useful if you are not interested in the result of an operation */ 86 | void(): EitherAsync 87 | /** Structural pattern matching for `EitherAsync` in the form of a function */ 88 | caseOf(patterns: EitherPatterns): Promise 89 | /* Similar to the Promise method of the same name, the provided function is called when the `EitherAsync` is executed regardless of whether the `Either` result is `Left` or `Right` */ 90 | finally(effect: () => any): EitherAsync 91 | 92 | 'fantasy-land/chain'( 93 | f: (value: R) => PromiseLike> 94 | ): EitherAsync 95 | 'fantasy-land/alt'(other: EitherAsync): EitherAsync 96 | /** WARNING: This is implemented only for Promise compatibility. Please use `chain` instead. */ 97 | then: PromiseLike>['then'] 98 | } 99 | 100 | export interface EitherAsyncValue extends PromiseLike {} 101 | 102 | export interface EitherAsyncHelpers { 103 | /** Allows you to take a regular Either value and lift it to the `EitherAsync` context. Awaiting a lifted Either will give you the `Right` value inside. If the Either is Left then the function will exit immediately and EitherAsync will resolve to that Left after running it */ 104 | liftEither(either: Either): EitherAsyncValue 105 | /** Allows you to take an Either inside a Promise and lift it to the `EitherAsync` context. Awaiting a lifted Promise will give you the `Right` value inside the Either. If the Either is Left or the Promise is rejected then the function will exit immediately and MaybeAsync will resolve to that Left or the rejection value after running it */ 106 | fromPromise(promise: PromiseLike>): EitherAsyncValue 107 | /** A type safe version of throwing an exception. Unlike the Error constructor, which will take anything, throwE only accepts values of the same type as the Left part of the Either */ 108 | throwE(error: L): never 109 | } 110 | 111 | const helpers: EitherAsyncHelpers = { 112 | liftEither(either: Either): EitherAsyncValue { 113 | if (either.isRight()) { 114 | return Promise.resolve(either.extract()) 115 | } 116 | 117 | throw either.extract() 118 | }, 119 | fromPromise(promise: PromiseLike>): EitherAsyncValue { 120 | return promise.then(helpers.liftEither) as EitherAsyncValue 121 | }, 122 | throwE(error: L): never { 123 | throw error 124 | } 125 | } 126 | 127 | class EitherAsyncImpl implements EitherAsync { 128 | [Symbol.toStringTag]: 'EitherAsync' = 'EitherAsync' 129 | 130 | constructor( 131 | private runPromise: (helpers: EitherAsyncHelpers) => PromiseLike 132 | ) {} 133 | 134 | leftOrDefault(defaultValue: L): Promise { 135 | return this.run().then((x) => x.leftOrDefault(defaultValue)) 136 | } 137 | 138 | orDefault(defaultValue: R): Promise { 139 | return this.run().then((x) => x.orDefault(defaultValue)) 140 | } 141 | 142 | join(this: EitherAsync>): EitherAsync { 143 | return EitherAsync(async (helpers) => { 144 | const either = await this 145 | if (either.isRight()) { 146 | const nestedEither = await either.extract() 147 | return helpers.liftEither(nestedEither) 148 | } 149 | return helpers.liftEither(either as any as Either) 150 | }) 151 | } 152 | 153 | ap( 154 | eitherF: PromiseLike R2>> 155 | ): EitherAsync> { 156 | return EitherAsync(async (helpers) => { 157 | const otherValue = await eitherF 158 | 159 | if (otherValue.isRight()) { 160 | const thisValue = await this.run() 161 | 162 | if (thisValue.isRight()) { 163 | return otherValue.extract()(thisValue.extract()) 164 | } else { 165 | return helpers.liftEither(thisValue) as any 166 | } 167 | } 168 | 169 | return helpers.liftEither(otherValue) 170 | }) 171 | } 172 | 173 | alt(other: EitherAsync): EitherAsync { 174 | return EitherAsync(async (helpers) => { 175 | const thisValue = await this.run() 176 | 177 | if (thisValue.isRight()) { 178 | return thisValue.extract() 179 | } else { 180 | const otherValue = await other 181 | return helpers.liftEither(otherValue) 182 | } 183 | }) 184 | } 185 | 186 | extend(f: (value: EitherAsync) => R2): EitherAsync> { 187 | return EitherAsync(async (helpers) => { 188 | const either = await this.run() 189 | if (either.isRight()) { 190 | const v = EitherAsync.liftEither(either) 191 | return helpers.liftEither(Right(f(v))) 192 | } 193 | return helpers.liftEither(either) as any 194 | }) 195 | } 196 | 197 | async run(): Promise> { 198 | try { 199 | return Right(await this.runPromise(helpers)) 200 | } catch (e: any) { 201 | return Left(e) 202 | } 203 | } 204 | 205 | bimap( 206 | f: (value: L) => L2, 207 | g: (value: R) => R2 208 | ): EitherAsync, Awaited> { 209 | return EitherAsync(async (helpers) => { 210 | const either = await this.run() 211 | try { 212 | return (await helpers.liftEither(either.bimap(f, g) as any)) as any 213 | } catch (e: any) { 214 | throw await e 215 | } 216 | }) 217 | } 218 | 219 | map(f: (value: R) => R2): EitherAsync> { 220 | return EitherAsync((helpers) => this.runPromise(helpers).then(f as any)) 221 | } 222 | 223 | mapLeft(f: (value: L) => L2): EitherAsync, R> { 224 | return EitherAsync(async (helpers) => { 225 | try { 226 | return await this.runPromise(helpers as any as EitherAsyncHelpers) 227 | } catch (e: any) { 228 | throw await f(e) 229 | } 230 | }) 231 | } 232 | 233 | chain( 234 | f: (value: R) => PromiseLike> 235 | ): EitherAsync { 236 | return EitherAsync(async (helpers) => { 237 | const value = await this.runPromise(helpers) 238 | return helpers.fromPromise(f(value)) 239 | }) 240 | } 241 | 242 | chainLeft( 243 | f: (value: L) => PromiseLike> 244 | ): EitherAsync { 245 | return EitherAsync(async (helpers) => { 246 | try { 247 | return await this.runPromise(helpers as any as EitherAsyncHelpers) 248 | } catch (e: any) { 249 | return helpers.fromPromise(f(e)) 250 | } 251 | }) 252 | } 253 | 254 | toMaybeAsync(): MaybeAsync { 255 | return MaybeAsync(async ({ liftMaybe }) => { 256 | const either = await this.run() 257 | return liftMaybe(either.toMaybe()) 258 | }) 259 | } 260 | 261 | swap(): EitherAsync { 262 | return EitherAsync(async (helpers) => { 263 | const either = await this.run() 264 | if (either.isRight()) helpers.throwE(either.extract() as R) 265 | return helpers.liftEither(Right(either.extract() as L)) 266 | }) 267 | } 268 | 269 | ifLeft(effect: (value: L) => any): EitherAsync { 270 | return EitherAsync(async (helpers) => { 271 | const either = await this.run() 272 | either.ifLeft(effect) 273 | return helpers.liftEither(either) 274 | }) 275 | } 276 | 277 | ifRight(effect: (value: R) => any): EitherAsync { 278 | return EitherAsync(async (helpers) => { 279 | const either = await this.run() 280 | either.ifRight(effect) 281 | return helpers.liftEither(either) 282 | }) 283 | } 284 | 285 | void(): EitherAsync { 286 | return this.map((_) => {}) 287 | } 288 | 289 | caseOf(patterns: EitherPatterns): Promise { 290 | return this.run().then((x) => x.caseOf(patterns)) 291 | } 292 | 293 | finally(effect: () => any): EitherAsync { 294 | return EitherAsync(({ fromPromise }) => 295 | fromPromise(this.run().finally(effect)) 296 | ) 297 | } 298 | 299 | declare 'fantasy-land/chain': typeof this.chain 300 | declare 'fantasy-land/alt': typeof this.alt 301 | 302 | then: PromiseLike>['then'] = (onfulfilled, onrejected) => { 303 | return this.run().then(onfulfilled, onrejected) 304 | } 305 | } 306 | 307 | EitherAsyncImpl.prototype['fantasy-land/chain'] = 308 | EitherAsyncImpl.prototype.chain 309 | EitherAsyncImpl.prototype['fantasy-land/alt'] = EitherAsyncImpl.prototype.alt 310 | 311 | export const EitherAsync: EitherAsyncTypeRef = Object.assign( 312 | ( 313 | runPromise: (helpers: EitherAsyncHelpers) => PromiseLike 314 | ): EitherAsync => new EitherAsyncImpl(runPromise), 315 | { 316 | fromPromise: ( 317 | f: () => PromiseLike> 318 | ): EitherAsync => EitherAsync(({ fromPromise: fP }) => fP(f())), 319 | liftEither: (either: Either): EitherAsync => 320 | EitherAsync(({ liftEither }) => liftEither(either)), 321 | lefts: (list: readonly EitherAsync[]): Promise => 322 | Promise.all(list.map((x) => x.run())).then(Either.lefts), 323 | rights: (list: readonly EitherAsync[]): Promise => 324 | Promise.all(list.map((x) => x.run())).then(Either.rights), 325 | sequence: (eas: readonly EitherAsync[]): EitherAsync => 326 | EitherAsync(async (helpers) => { 327 | let res: R[] = [] 328 | 329 | for await (const e of eas) { 330 | if (e.isLeft()) { 331 | return helpers.liftEither(e) 332 | } 333 | 334 | res.push(e.extract() as R) 335 | } 336 | 337 | return helpers.liftEither(Right(res)) 338 | }), 339 | all: (eas: readonly EitherAsync[]): EitherAsync => 340 | EitherAsync.fromPromise(async () => 341 | Promise.all(eas).then(Either.sequence) 342 | ) 343 | } 344 | ) 345 | 346 | EitherAsyncImpl.prototype.constructor = EitherAsync 347 | -------------------------------------------------------------------------------- /src/Function.test.ts: -------------------------------------------------------------------------------- 1 | import { curry } from './Function' 2 | import { describe, expect, test } from 'vitest' 3 | 4 | describe('Function', () => { 5 | test('curry', () => { 6 | const sum3 = (x: number, y: number, z: number) => x + y + z 7 | const curriedSum3 = curry(sum3) 8 | 9 | expect(curriedSum3(1)(2)(3)).toEqual(6) 10 | expect(curriedSum3(1, 2)(3)).toEqual(6) 11 | expect(curriedSum3(1, 2, 3)).toEqual(6) 12 | expect(curriedSum3(1)(2, 3)).toEqual(6) 13 | 14 | // @ts-expect-error 15 | curriedSum3() 16 | // @ts-expect-error 17 | curriedSum3(5)() 18 | // @ts-expect-error 19 | curriedSum3(5)(3)() 20 | }) 21 | }) 22 | -------------------------------------------------------------------------------- /src/Function.ts: -------------------------------------------------------------------------------- 1 | /** The identity function, returns the value it was given */ 2 | export const identity = (x: T): T => x 3 | 4 | /** Returns a function that always returns the same value. Also known as `const` in other languages */ 5 | export const always = 6 | (x: T): ((y: U) => T) => 7 | () => 8 | x 9 | 10 | export const Order = { 11 | LT: 'LT', 12 | EQ: 'EQ', 13 | GT: 'GT' 14 | } as const 15 | 16 | export type Order = 'LT' | 'EQ' | 'GT' 17 | 18 | /** Compares two values using the default "<" and ">" operators */ 19 | export const compare = (x: T, y: T): Order => { 20 | if (x > y) { 21 | return Order.GT 22 | } else if (x < y) { 23 | return Order.LT 24 | } else { 25 | return Order.EQ 26 | } 27 | } 28 | 29 | /** Maps the Order enum to the values expected by the standard ECMAScript library when doing comparison (Array.prototype.sort, for example) */ 30 | export const orderToNumber = (order: Order): number => { 31 | switch (order) { 32 | case Order.LT: 33 | return -1 34 | case Order.EQ: 35 | return 0 36 | case Order.GT: 37 | return 1 38 | } 39 | } 40 | 41 | type TupleOfLength = Extract<{ [K in keyof T]: any }, any[]> 42 | 43 | export type CurriedFn = < 44 | TProvidedArgs extends TAllArgs extends [infer TFirstArg, ...infer TRestOfArgs] 45 | ? [TFirstArg, ...Partial] 46 | : never 47 | >( 48 | ...args: TProvidedArgs 49 | ) => TProvidedArgs extends TAllArgs 50 | ? TReturn 51 | : TAllArgs extends [...TupleOfLength, ...infer TRestOfArgs] 52 | ? CurriedFn 53 | : never 54 | 55 | /** Takes a function that receives multiple arguments and returns a "curried" version of that function that can take any number of those arguments and if they are less than needed a new function that takes the rest of them will be returned */ 56 | export const curry = ( 57 | fn: (...args: TArgs) => TReturn 58 | ): CurriedFn => 59 | function currify(...args: any[]): any { 60 | return args.length >= fn.length 61 | ? fn.apply(undefined, args as TArgs) 62 | : currify.bind(undefined, ...args) 63 | } 64 | -------------------------------------------------------------------------------- /src/List.test.ts: -------------------------------------------------------------------------------- 1 | import { List } from './List.js' 2 | import { Just, Nothing } from './Maybe' 3 | import { Tuple } from './Tuple' 4 | import { compare } from './Function' 5 | import { describe, expect, test } from 'vitest' 6 | 7 | describe('List', () => { 8 | test('at', () => { 9 | expect(List.at(0, [1, 2])).toEqual(Just(1)) 10 | expect(List.at(0)([1, 2])).toEqual(Just(1)) 11 | expect(List.at(0, [1, 2])).toEqual(Just(1)) 12 | expect(List.at(0, [])).toEqual(Nothing) 13 | }) 14 | 15 | test('head', () => { 16 | expect(List.head([1])).toEqual(Just(1)) 17 | expect(List.head([])).toEqual(Nothing) 18 | }) 19 | 20 | test('last', () => { 21 | expect(List.last([1, 2, 3])).toEqual(Just(3)) 22 | expect(List.last([])).toEqual(Nothing) 23 | }) 24 | 25 | test('tail', () => { 26 | expect(List.tail([1, 2, 3])).toEqual(Just([2, 3])) 27 | expect(List.tail([1])).toEqual(Just([])) 28 | expect(List.tail([])).toEqual(Nothing) 29 | }) 30 | 31 | test('init', () => { 32 | expect(List.init([1, 2, 3])).toEqual(Just([1, 2])) 33 | expect(List.init([1])).toEqual(Just([])) 34 | expect(List.init([])).toEqual(Nothing) 35 | }) 36 | 37 | test('uncons', () => { 38 | expect(List.uncons([])).toEqual(Nothing) 39 | expect(List.uncons([1])).toEqual(Just(Tuple(1, []))) 40 | expect(List.uncons([1, 2])).toEqual(Just(Tuple(1, [2]))) 41 | expect(List.uncons([1, 2, 3])).toEqual(Just(Tuple(1, [2, 3]))) 42 | }) 43 | 44 | test('sum', () => { 45 | expect(List.sum([])).toEqual(0) 46 | expect(List.sum([1, 2, 3])).toEqual(6) 47 | }) 48 | 49 | test('find', () => { 50 | expect(List.find((x) => x == 5)([1, 2, 3, 5])).toEqual(Just(5)) 51 | expect(List.find((x) => x == 5, [1, 2, 3, 5])).toEqual(Just(5)) 52 | expect(List.find((x) => x == 0, [1, 2, 3, 5])).toEqual(Nothing) 53 | }) 54 | 55 | test('findIndex', () => { 56 | expect(List.findIndex((x) => x == 5)([1, 2, 3, 5])).toEqual(Just(3)) 57 | expect(List.findIndex((x) => x == 5, [1, 2, 3, 5])).toEqual(Just(3)) 58 | expect(List.findIndex((x) => x == 0, [1, 2, 3, 5])).toEqual(Nothing) 59 | }) 60 | 61 | test('sort', () => { 62 | const arr = [4, 3, 1000, 0] 63 | 64 | expect(List.sort(compare, arr)).toEqual([0, 3, 4, 1000]) 65 | expect(List.sort(compare)(arr)).toEqual([0, 3, 4, 1000]) 66 | // immutability check 67 | expect(List.sort(compare, arr)).not.toBe(arr) 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /src/List.ts: -------------------------------------------------------------------------------- 1 | import { Tuple } from './Tuple.js' 2 | import { Maybe, Just, Nothing } from './Maybe.js' 3 | import { Order, orderToNumber } from './Function.js' 4 | 5 | /** Returns Just the first element of an array or Nothing if there is none. If you don't want to work with a Maybe but still keep type safety, check out `NonEmptyList` */ 6 | const head = (list: readonly T[]): Maybe => 7 | list.length > 0 ? Just(list[0]!) : Nothing 8 | 9 | /** Returns Just the last element of an array or Nothing if there is none */ 10 | const last = (list: readonly T[]): Maybe => 11 | list.length > 0 ? Just(list[list.length - 1]!) : Nothing 12 | 13 | /** Returns all elements of an array except the first */ 14 | const tail = (list: readonly T[]): Maybe => 15 | list.length > 0 ? Just(list.slice(1)) : Nothing 16 | 17 | /** Returns all elements of an array except the last */ 18 | const init = (list: readonly T[]): Maybe => 19 | list.length > 0 ? Just(list.slice(0, -1)) : Nothing 20 | 21 | /** Returns a tuple of an array's head and tail */ 22 | const uncons = (list: readonly T[]): Maybe> => 23 | list.length > 0 ? Just(Tuple(list[0]!, list.slice(1))) : Nothing 24 | 25 | /* Returns the sum of all numbers inside an array */ 26 | const sum = (list: readonly number[]): number => 27 | list.reduce((acc, x) => acc + x, 0) 28 | 29 | /** Returns the first element which satisfies a predicate. A more typesafe version of the already existing List.prototype.find */ 30 | function find( 31 | f: (x: T, index: number, arr: T[]) => boolean, 32 | list: T[] 33 | ): Maybe 34 | function find( 35 | f: (x: T, index: number, arr: T[]) => boolean 36 | ): (list: T[]) => Maybe 37 | function find( 38 | f: (x: T, index: number, arr: T[]) => boolean, 39 | list?: T[] 40 | ): any { 41 | switch (arguments.length) { 42 | case 1: 43 | return (list: T[]) => find(f, list) 44 | default: 45 | return Maybe.fromNullable(list!.find(f)) 46 | } 47 | } 48 | 49 | /** Returns the index of the first element which satisfies a predicate. A more typesafe version of the already existing List.prototype.findIndex */ 50 | function findIndex( 51 | f: (x: T, index: number, arr: T[]) => boolean, 52 | list: T[] 53 | ): Maybe 54 | function findIndex( 55 | f: (x: T, index: number, arr: T[]) => boolean 56 | ): (list: T[]) => Maybe 57 | function findIndex( 58 | f: (x: T, index: number, arr: T[]) => boolean, 59 | list?: T[] 60 | ): any { 61 | switch (arguments.length) { 62 | case 1: 63 | return (list: T[]) => findIndex(f, list) 64 | default: 65 | return Maybe.fromPredicate((x) => x !== -1, list!.findIndex(f)) 66 | } 67 | } 68 | 69 | /** Returns the element at a given index of a list */ 70 | function at(index: number, list: readonly T[]): Maybe 71 | function at(index: number): (list: readonly T[]) => Maybe 72 | function at(index: number, list?: readonly T[]): any { 73 | switch (arguments.length) { 74 | case 1: 75 | return (list: T[]) => at(index, list) 76 | default: 77 | return list![index] === undefined ? Nothing : Just(list![index]) 78 | } 79 | } 80 | 81 | /** Sorts an array with the given comparison function */ 82 | function sort(compare: (a: T, b: T) => Order, list: readonly T[]): T[] 83 | function sort(compare: (a: T, b: T) => Order): (list: readonly T[]) => T[] 84 | function sort(compare: (a: T, b: T) => Order, list?: readonly T[]): any { 85 | switch (arguments.length) { 86 | case 1: 87 | return (list: T[]) => sort(compare, list) 88 | default: 89 | return [...list!].sort((x, y) => orderToNumber(compare(x, y))) 90 | } 91 | } 92 | 93 | export const List = { 94 | init, 95 | uncons, 96 | at, 97 | head, 98 | last, 99 | tail, 100 | find, 101 | findIndex, 102 | sum, 103 | sort 104 | } 105 | -------------------------------------------------------------------------------- /src/Maybe.test.ts: -------------------------------------------------------------------------------- 1 | import { Maybe, Just, Nothing } from './Maybe' 2 | import { Left, Right } from './Either' 3 | import { describe, expect, test, vi } from 'vitest' 4 | 5 | describe('Maybe', () => { 6 | test('fantasy-land', () => { 7 | expect(Just(5).constructor).toEqual(Maybe) 8 | expect(Nothing.constructor).toEqual(Maybe) 9 | }) 10 | 11 | test('inspect', () => { 12 | expect(Nothing.inspect()).toEqual('Nothing') 13 | expect(Just(1).inspect()).toEqual('Just(1)') 14 | }) 15 | 16 | test('toString', () => { 17 | expect(Nothing.toString()).toEqual('Nothing') 18 | expect(Just(1).toString()).toEqual('Just(1)') 19 | }) 20 | 21 | test('toJSON', () => { 22 | expect(JSON.stringify({ a: Nothing })).toEqual('{}') 23 | expect(JSON.stringify(Just(1)).toString()).toEqual('1') 24 | }) 25 | 26 | test('of', () => { 27 | expect(Maybe.of(5)).toEqual(Just(5)) 28 | expect(Maybe['fantasy-land/of'](5)).toEqual(Just(5)) 29 | }) 30 | 31 | test('fromNullable', () => { 32 | expect(Maybe.fromNullable(null)).toEqual(Nothing) 33 | expect(Maybe.fromNullable(5)).toEqual(Just(5)) 34 | }) 35 | 36 | test('fromFalsy', () => { 37 | expect(Maybe.fromFalsy(0)).toEqual(Nothing) 38 | expect(Maybe.fromFalsy('')).toEqual(Nothing) 39 | expect(Maybe.fromFalsy(5)).toEqual(Just(5)) 40 | }) 41 | 42 | test('fromPredicate', () => { 43 | expect(Maybe.fromPredicate((x) => x > 0, 0)).toEqual(Nothing) 44 | expect(Maybe.fromPredicate((x) => x === 0, 0)).toEqual(Just(0)) 45 | expect(Maybe.fromPredicate((x) => x > 0)(0)).toEqual(Nothing) 46 | }) 47 | 48 | test('empty', () => { 49 | expect(Maybe.empty()).toEqual(Nothing) 50 | expect(Maybe['fantasy-land/empty']()).toEqual(Nothing) 51 | }) 52 | 53 | test('zero', () => { 54 | expect(Maybe.zero()).toEqual(Nothing) 55 | expect(Maybe['fantasy-land/zero']()).toEqual(Nothing) 56 | }) 57 | 58 | test('catMaybes', () => { 59 | expect(Maybe.catMaybes([Just(5), Nothing, Just(10)])).toEqual([5, 10]) 60 | expect(Maybe.catMaybes([Nothing])).toEqual([]) 61 | }) 62 | 63 | test('mapMaybe', () => { 64 | expect( 65 | Maybe.mapMaybe((x: number) => (x > 5 ? Just(x) : Nothing))([3, 7, 8, 9]) 66 | ).toEqual([7, 8, 9]) 67 | expect( 68 | Maybe.mapMaybe((x) => (x > 5 ? Just(x) : Nothing), [1, 2, 3, 7, 8, 9]) 69 | ).toEqual([7, 8, 9]) 70 | }) 71 | 72 | test('encase', () => { 73 | expect( 74 | Maybe.encase(() => { 75 | throw new Error('a') 76 | }) 77 | ).toEqual(Nothing) 78 | expect(Maybe.encase(() => 10)).toEqual(Just(10)) 79 | }) 80 | 81 | test('isMaybe', () => { 82 | expect(Maybe.isMaybe(Just(5))).toEqual(true) 83 | expect(Maybe.isMaybe(Nothing)).toEqual(true) 84 | expect(Maybe.isMaybe(5)).toEqual(false) 85 | expect(Maybe.isMaybe(undefined)).toEqual(false) 86 | }) 87 | 88 | test('sequence', () => { 89 | expect(Maybe.sequence([Just(1), Just(5), Just(10)])).toEqual( 90 | Just([1, 5, 10]) 91 | ) 92 | expect(Maybe.sequence([Just(1), Nothing, Just(10)])).toEqual(Nothing) 93 | }) 94 | 95 | test('isJust', () => { 96 | expect(Just(5).isJust()).toEqual(true) 97 | expect(Nothing.isJust()).toEqual(false) 98 | }) 99 | 100 | test('isNothing', () => { 101 | expect(Just(5).isNothing()).toEqual(false) 102 | expect(Nothing.isNothing()).toEqual(true) 103 | expect(Just(null).isNothing()).toEqual(false) 104 | }) 105 | 106 | test('equals', () => { 107 | expect(Just(5).equals(Just(5))).toEqual(true) 108 | expect(Just(5).equals(Just(10))).toEqual(false) 109 | expect(Just(5).equals(Nothing)).toEqual(false) 110 | expect(Nothing.equals(Just(5 as never))).toEqual(false) 111 | expect(Nothing.equals(Nothing)).toEqual(true) 112 | 113 | expect(Just(5)['fantasy-land/equals'](Just(5))).toEqual(true) 114 | }) 115 | 116 | test('map', () => { 117 | expect(Just(5).map((x) => x + 1)).toEqual(Just(6)) 118 | expect(Nothing.map((x) => x + 1)).toEqual(Nothing) 119 | 120 | expect(Just(5)['fantasy-land/map']((x) => x + 1)).toEqual(Just(6)) 121 | }) 122 | 123 | test('ap', () => { 124 | expect(Just(5).ap(Just((x) => x + 1))).toEqual(Just(6)) 125 | expect(Just(5).ap(Nothing)).toEqual(Nothing) 126 | expect(Nothing.ap(Just((x) => x + 1))).toEqual(Nothing) 127 | expect(Nothing.ap(Nothing)).toEqual(Nothing) 128 | 129 | expect(Just(5)['fantasy-land/ap'](Nothing)).toEqual(Nothing) 130 | }) 131 | 132 | test('alt', () => { 133 | expect(Just(5).alt(Just(6))).toEqual(Just(5)) 134 | expect(Just(5).alt(Nothing)).toEqual(Just(5)) 135 | expect(Nothing.alt(Just(5 as never))).toEqual(Just(5)) 136 | expect(Nothing.alt(Nothing)).toEqual(Nothing) 137 | 138 | expect(Just(5)['fantasy-land/alt'](Nothing)).toEqual(Just(5)) 139 | }) 140 | 141 | test('altLazy', () => { 142 | const fn = vi.fn(() => Just(5)) 143 | const fn2 = vi.fn(() => Just(6)) 144 | expect(Nothing.altLazy(fn)).toEqual(Just(5)) 145 | expect(Just(5).altLazy(fn2)).toEqual(Just(5)) 146 | 147 | expect(fn).toBeCalledTimes(1) 148 | expect(fn2).not.toHaveBeenCalled() 149 | }) 150 | 151 | test('chain', () => { 152 | expect(Just(5).chain((x) => Just(x + 1))).toEqual(Just(6)) 153 | expect(Nothing.chain((x) => Just(x + 1))).toEqual(Nothing) 154 | 155 | expect(Just(5)['fantasy-land/chain']((x) => Just(x + 1))).toEqual(Just(6)) 156 | }) 157 | 158 | test('chainNullable', () => { 159 | expect(Just(5).chainNullable((x) => x + 1)).toEqual(Just(6)) 160 | expect(Nothing.chainNullable((x) => x + 1)).toEqual(Nothing) 161 | expect(Just({ prop: null }).chainNullable((x) => x.prop)).toEqual(Nothing) 162 | }) 163 | 164 | test('join', () => { 165 | expect(Just(Just(5)).join()).toEqual(Just(5)) 166 | expect(Nothing.join()).toEqual(Nothing) 167 | }) 168 | 169 | test('reduce', () => { 170 | expect(Just(5).reduce((acc, x) => x * acc, 2)).toEqual(10) 171 | expect(Nothing.reduce((acc, x) => x * acc, 0)).toEqual(0) 172 | 173 | expect(Just(5)['fantasy-land/reduce']((acc, x) => x * acc, 2)).toEqual(10) 174 | }) 175 | 176 | test('extend', () => { 177 | expect(Just(5).extend((x) => x.isJust())).toEqual(Just(true)) 178 | expect(Nothing.extend((x) => x.isJust())).toEqual(Nothing) 179 | 180 | expect(Just(5)['fantasy-land/extend']((x) => x.isJust())).toEqual( 181 | Just(true) 182 | ) 183 | }) 184 | 185 | test('unsafeCoerce', () => { 186 | expect(Just(5).unsafeCoerce()).toEqual(5) 187 | expect(() => Nothing.unsafeCoerce()).toThrow() 188 | }) 189 | 190 | test('caseOf', () => { 191 | expect(Just(5).caseOf({ Just: (x) => x + 1, Nothing: () => 0 })).toEqual(6) 192 | expect(Nothing.caseOf({ Just: (x) => x + 1, Nothing: () => 0 })).toEqual(0) 193 | expect(Just(10).caseOf({ _: () => 99 })).toEqual(99) 194 | expect(Nothing.caseOf({ _: () => 99 })).toEqual(99) 195 | }) 196 | 197 | test('orDefault', () => { 198 | expect(Just(5).orDefault(0)).toEqual(5) 199 | expect(Nothing.orDefault(0 as never)).toEqual(0) 200 | }) 201 | 202 | test('orDefaultLazy', () => { 203 | expect(Just(5).orDefaultLazy(() => 0)).toEqual(5) 204 | expect(Nothing.orDefaultLazy(() => 0 as never)).toEqual(0) 205 | }) 206 | 207 | test('toList', () => { 208 | expect(Just(5).toList()).toEqual([5]) 209 | expect(Nothing.toList()).toEqual([]) 210 | }) 211 | 212 | test('mapOrDefault', () => { 213 | expect(Just(5).mapOrDefault((x) => x + 1, 0)).toEqual(6) 214 | expect(Nothing.mapOrDefault((x) => x + 1, 0)).toEqual(0) 215 | }) 216 | 217 | test('extract', () => { 218 | expect(Just(5).extract()).toEqual(5) 219 | expect(Nothing.extract()).toEqual(undefined) 220 | }) 221 | 222 | test('extract types', () => { 223 | const j = Just(5) 224 | if (j.isJust()) { 225 | const value: number = j.extract() 226 | } 227 | // @ts-expect-error 228 | const value2: number = j.extract() 229 | }) 230 | 231 | test('extractNullable', () => { 232 | expect(Just(5).extractNullable()).toEqual(5) 233 | expect(Nothing.extractNullable()).toEqual(null) 234 | }) 235 | 236 | test('toEither', () => { 237 | expect(Just(5).toEither('Error')).toEqual(Right(5)) 238 | expect(Nothing.toEither('Error')).toEqual(Left('Error')) 239 | }) 240 | 241 | test('ifJust', () => { 242 | let a = 0 243 | Just(5).ifJust(() => { 244 | a = 5 245 | }) 246 | expect(a).toEqual(5) 247 | 248 | let b = 0 249 | Nothing.ifJust(() => { 250 | b = 5 251 | }) 252 | expect(b).toEqual(0) 253 | }) 254 | 255 | test('ifNothing', () => { 256 | let a = 0 257 | Just(5).ifNothing(() => { 258 | a = 5 259 | }) 260 | expect(a).toEqual(0) 261 | 262 | let b = 0 263 | Nothing.ifNothing(() => { 264 | b = 5 265 | }) 266 | expect(b).toEqual(5) 267 | }) 268 | 269 | test('filter', () => { 270 | expect(Just(5).filter((x) => x > 10)).toEqual(Nothing) 271 | expect(Just(5).filter((x) => x > 0)).toEqual(Just(5)) 272 | expect(Nothing.filter((x) => x > 0)).toEqual(Nothing) 273 | }) 274 | }) 275 | -------------------------------------------------------------------------------- /src/MaybeAsync.test.ts: -------------------------------------------------------------------------------- 1 | import { MaybeAsync } from './MaybeAsync' 2 | import { Just, Nothing, Maybe } from './Maybe' 3 | import { Left, Right } from './Either' 4 | import { describe, expect, test, it } from 'vitest' 5 | 6 | describe('MaybeAsync', () => { 7 | test('fantasy-land', () => { 8 | expect(MaybeAsync(async () => {}).constructor).toEqual(MaybeAsync) 9 | }) 10 | 11 | test('liftMaybe', () => { 12 | MaybeAsync(async ({ liftMaybe }) => { 13 | const _: 5 = await liftMaybe(Just<5>(5)) 14 | }) 15 | }) 16 | 17 | test('fromPromise', () => { 18 | MaybeAsync(async ({ fromPromise }) => { 19 | const _: 5 = await fromPromise(Promise.resolve(Just<5>(5))) 20 | }) 21 | }) 22 | 23 | test('try/catch', async () => { 24 | const ma = MaybeAsync(async ({ fromPromise }) => { 25 | try { 26 | await fromPromise(Promise.reject('shouldnt show')) 27 | } catch { 28 | throw 5 29 | } 30 | }) 31 | 32 | expect(await ma.run()).toEqual(Nothing) 33 | }) 34 | 35 | test('Promise compatibility', async () => { 36 | const result = await MaybeAsync(() => { 37 | throw 'Err' 38 | }) 39 | 40 | const result2 = await MaybeAsync(async () => { 41 | return 'A' 42 | }) 43 | 44 | expect(result).toEqual(Nothing) 45 | expect(result2).toEqual(Just('A')) 46 | }) 47 | 48 | test('map', async () => { 49 | const newMaybeAsync = MaybeAsync(() => Promise.resolve(5)).map((_) => 'val') 50 | 51 | expect(await newMaybeAsync.run()).toEqual(Just('val')) 52 | }) 53 | 54 | test('chain', async () => { 55 | const newMaybeAsync = MaybeAsync(() => Promise.resolve(5)).chain((_) => 56 | MaybeAsync(() => Promise.resolve('val')) 57 | ) 58 | const newMaybeAsync2 = MaybeAsync(() => Promise.resolve(5))[ 59 | 'fantasy-land/chain' 60 | ]((_) => MaybeAsync(() => Promise.resolve('val'))) 61 | 62 | expect(await newMaybeAsync.run()).toEqual(Just('val')) 63 | expect(await newMaybeAsync2.run()).toEqual(Just('val')) 64 | }) 65 | 66 | test('chain (with PromiseLike)', async () => { 67 | const newMaybeAsync = MaybeAsync(() => Promise.resolve(5)).chain((_) => 68 | Promise.resolve(Just('val')) 69 | ) 70 | const newMaybeAsync2 = MaybeAsync(() => Promise.resolve(5))[ 71 | 'fantasy-land/chain' 72 | ]((_) => Promise.resolve(Just('val'))) 73 | 74 | expect(await newMaybeAsync.run()).toEqual(Just('val')) 75 | expect(await newMaybeAsync2.run()).toEqual(Just('val')) 76 | }) 77 | 78 | test('ap', async () => { 79 | expect( 80 | await MaybeAsync.liftMaybe(Just(5)).ap( 81 | MaybeAsync(async () => (x) => x + 1) 82 | ) 83 | ).toEqual(Just(6)) 84 | expect( 85 | await MaybeAsync.liftMaybe(Just(5)).ap( 86 | MaybeAsync.liftMaybe(Nothing as any) 87 | ) 88 | ).toEqual(Nothing) 89 | expect( 90 | await MaybeAsync.liftMaybe(Nothing).ap( 91 | MaybeAsync(async () => (x: any) => x + 1) 92 | ) 93 | ).toEqual(Nothing) 94 | expect( 95 | await MaybeAsync.liftMaybe(Nothing).ap( 96 | MaybeAsync.liftMaybe(Nothing as any) 97 | ) 98 | ).toEqual(Nothing) 99 | }) 100 | 101 | test('alt', async () => { 102 | expect( 103 | await MaybeAsync.liftMaybe(Just(5)).alt(MaybeAsync.liftMaybe(Just(6))) 104 | ).toEqual(Just(5)) 105 | expect( 106 | await MaybeAsync.liftMaybe(Just(5)).alt( 107 | MaybeAsync.liftMaybe(Nothing as any) 108 | ) 109 | ).toEqual(Just(5)) 110 | expect( 111 | await MaybeAsync.liftMaybe(Nothing).alt(MaybeAsync.liftMaybe(Just(5))) 112 | ).toEqual(Just(5)) 113 | expect( 114 | await MaybeAsync.liftMaybe(Nothing).alt(MaybeAsync.liftMaybe(Nothing)) 115 | ).toEqual(Nothing) 116 | 117 | expect( 118 | await MaybeAsync.liftMaybe(Just(5))['fantasy-land/alt']( 119 | MaybeAsync.liftMaybe(Nothing as any) 120 | ) 121 | ).toEqual(Just(5)) 122 | }) 123 | 124 | test('join', async () => { 125 | const ma = MaybeAsync(async () => 1).map((x) => 126 | MaybeAsync(async () => x + 1) 127 | ) 128 | 129 | expect(await ma.join()).toEqual(Just(2)) 130 | 131 | const ma2 = MaybeAsync(async () => 1).map(() => 132 | MaybeAsync(() => Promise.reject()) 133 | ) 134 | 135 | expect(await ma2.join()).toEqual(Nothing) 136 | 137 | const ma3 = MaybeAsync(() => Promise.reject()) 138 | 139 | expect(await ma3.join()).toEqual(Nothing) 140 | }) 141 | 142 | test('extend', async () => { 143 | expect( 144 | await MaybeAsync.liftMaybe(Just(5)).extend((x) => x.orDefault(10)) 145 | ).toEqual(Just(5)) 146 | expect( 147 | await MaybeAsync.liftMaybe(Nothing).extend((x) => x.orDefault(5)) 148 | ).toEqual(Nothing) 149 | }) 150 | 151 | test('filter', async () => { 152 | expect(await MaybeAsync.liftMaybe(Just(5)).filter((x) => x > 10)).toEqual( 153 | Nothing 154 | ) 155 | expect(await MaybeAsync.liftMaybe(Just(5)).filter((x) => x > 0)).toEqual( 156 | Just(5) 157 | ) 158 | expect( 159 | await MaybeAsync.liftMaybe(Nothing).filter((x) => x > 0) 160 | ).toEqual(Nothing) 161 | }) 162 | 163 | test('toEitherAsync', async () => { 164 | const ma = MaybeAsync(({ liftMaybe }) => liftMaybe(Nothing)) 165 | 166 | expect(await ma.toEitherAsync('Error').run()).toEqual(Left('Error')) 167 | 168 | const ma2 = MaybeAsync(({ liftMaybe }) => liftMaybe(Just(5))) 169 | 170 | expect(await ma2.toEitherAsync('Error').run()).toEqual(Right(5)) 171 | }) 172 | 173 | test('ifJust', async () => { 174 | let a = 0 175 | await MaybeAsync.liftMaybe(Just(5)).ifJust(() => { 176 | a = 5 177 | }) 178 | expect(a).toEqual(5) 179 | 180 | let b = 0 181 | await MaybeAsync.liftMaybe(Nothing).ifJust(() => { 182 | b = 5 183 | }) 184 | expect(b).toEqual(0) 185 | }) 186 | 187 | test('ifNothing', async () => { 188 | let a = 0 189 | await MaybeAsync.liftMaybe(Just(5)).ifNothing(() => { 190 | a = 5 191 | }) 192 | expect(a).toEqual(0) 193 | 194 | let b = 0 195 | await MaybeAsync.liftMaybe(Nothing).ifNothing(() => { 196 | b = 5 197 | }) 198 | expect(b).toEqual(5) 199 | }) 200 | 201 | describe('run', () => { 202 | it('resolves to Nothing if any of the async Maybes are Nothing', async () => { 203 | expect( 204 | await MaybeAsync(({ liftMaybe }) => liftMaybe(Nothing)).run() 205 | ).toEqual(Nothing) 206 | }) 207 | 208 | it('resolves to Nothing if there is a rejected promise', async () => { 209 | expect(await MaybeAsync(() => Promise.reject()).run()).toEqual(Nothing) 210 | }) 211 | 212 | it('resolves to Nothing if there is an exception thrown', async () => { 213 | expect( 214 | await MaybeAsync(() => { 215 | throw new Error('!') 216 | }).run() 217 | ).toEqual(Nothing) 218 | }) 219 | 220 | it('resolve to Just if the promise resolves successfully', async () => { 221 | expect( 222 | await MaybeAsync(({ fromPromise }) => 223 | fromPromise(Promise.resolve(Just(5))) 224 | ).run() 225 | ).toEqual(Just(5)) 226 | }) 227 | }) 228 | 229 | test('fromPromise static', async () => { 230 | expect( 231 | await MaybeAsync.fromPromise(() => Promise.resolve(Just(5))).run() 232 | ).toEqual(Just(5)) 233 | expect(await MaybeAsync.fromPromise(() => Promise.reject()).run()).toEqual( 234 | Nothing 235 | ) 236 | }) 237 | 238 | test('liftEither static', async () => { 239 | expect(await MaybeAsync.liftMaybe(Just(5)).run()).toEqual(Just(5)) 240 | expect(await MaybeAsync.liftMaybe(Nothing).run()).toEqual(Nothing) 241 | }) 242 | 243 | test('catMaybes', async () => { 244 | expect( 245 | await MaybeAsync.catMaybes([ 246 | MaybeAsync.liftMaybe(Just(5)), 247 | MaybeAsync.liftMaybe(Nothing), 248 | MaybeAsync.liftMaybe(Just(10)) 249 | ]) 250 | ).toEqual([5, 10]) 251 | expect(await MaybeAsync.catMaybes([MaybeAsync.liftMaybe(Nothing)])).toEqual( 252 | [] 253 | ) 254 | }) 255 | 256 | test('void', async () => { 257 | const ea: MaybeAsync = MaybeAsync(async () => 5).void() 258 | 259 | expect(await ea).toEqual(Just(undefined)) 260 | }) 261 | 262 | test('caseOf', async () => { 263 | expect( 264 | await MaybeAsync.liftMaybe(Nothing).caseOf({ 265 | Nothing: () => 'Error', 266 | Just: () => 'No error' 267 | }) 268 | ).toEqual('Error') 269 | expect( 270 | await MaybeAsync.liftMaybe(Just(6)).caseOf({ 271 | Nothing: () => 0, 272 | Just: (x) => x + 1 273 | }) 274 | ).toEqual(7) 275 | expect(await MaybeAsync.liftMaybe(Just(6)).caseOf({ _: () => 0 })).toEqual( 276 | 0 277 | ) 278 | expect(await MaybeAsync.liftMaybe(Nothing).caseOf({ _: () => 0 })).toEqual( 279 | 0 280 | ) 281 | }) 282 | 283 | test('finally', async () => { 284 | let a = 0 285 | await MaybeAsync.liftMaybe(Nothing).finally(() => { 286 | a = 5 287 | }) 288 | expect(a).toEqual(5) 289 | 290 | let b = 0 291 | await MaybeAsync.liftMaybe(Just(5)).finally(() => { 292 | b = 5 293 | }) 294 | expect(b).toEqual(5) 295 | }) 296 | }) 297 | -------------------------------------------------------------------------------- /src/MaybeAsync.ts: -------------------------------------------------------------------------------- 1 | import { Maybe, Just, Nothing, MaybePatterns } from './Maybe.js' 2 | import { EitherAsync } from './EitherAsync.js' 3 | 4 | /** You can use this to extract the type of the `Just` value out of an `MaybeAsync`. */ 5 | export type ExtractJust = T extends PromiseLike> ? U : never 6 | 7 | export interface MaybeAsyncTypeRef { 8 | /** Constructs a MaybeAsync object from a function that takes an object full of helpers that let you lift things into the MaybeAsync context and returns a Promise */ 9 | (runPromise: (helpers: MaybeAsyncHelpers) => PromiseLike): MaybeAsync 10 | /** Constructs an MaybeAsync object from a function that returns a Maybe wrapped in a Promise */ 11 | fromPromise(f: () => Promise>): MaybeAsync 12 | /** Constructs an MaybeAsync object from a Maybe */ 13 | liftMaybe(maybe: Maybe): MaybeAsync 14 | /** Takes a list of `MaybeAsync`s and returns a Promise that will resolve with all `Just` values. Internally it uses `Promise.all` to wait for all results */ 15 | catMaybes(list: readonly MaybeAsync[]): Promise 16 | } 17 | 18 | export interface MaybeAsync extends PromiseLike> { 19 | /** 20 | * It's important to remember how `run` will behave because in an 21 | * async context there are other ways for a function to fail other 22 | * than to return a Nothing, for example: 23 | * If any of the computations inside MaybeAsync resolved to Nothing, 24 | * `run` will return a Promise resolved to Nothing. 25 | * If any of the promises were to be rejected then `run` will return 26 | * a Promise resolved to Nothing. 27 | * If an exception is thrown then `run` will return a Promise 28 | * resolved to Nothing. 29 | * If none of the above happen then a promise resolved to the 30 | * returned value wrapped in a Just will be returned. 31 | */ 32 | run(): Promise> 33 | /** Transforms the value inside `this` with a given function. If the MaybeAsync that is being mapped resolves to Nothing then the mapping function won't be called and `run` will resolve the whole thing to Nothing, just like the regular Maybe#map */ 34 | map(f: (value: T) => U): MaybeAsync> 35 | /** Transforms `this` with a function that returns a `MaybeAsync`. Behaviour is the same as the regular Maybe#chain */ 36 | chain(f: (value: T) => PromiseLike>): MaybeAsync 37 | /** Converts `this` to a EitherAsync with a default error value */ 38 | toEitherAsync(error: L): EitherAsync 39 | /** Runs an effect if `this` is `Just`, returns `this` to make chaining other methods possible */ 40 | ifJust(effect: (value: T) => any): MaybeAsync 41 | /** Runs an effect if `this` is `Nothing`, returns `this` to make chaining other methods possible */ 42 | ifNothing(effect: () => any): MaybeAsync 43 | /** Returns the default value if `this` is `Nothing`, otherwise it returns a Promise that will resolve to the value inside `this` */ 44 | orDefault(defaultValue: T): Promise 45 | /** Maps the future value of `this` with another future `Maybe` function */ 46 | ap(maybeF: PromiseLike U>>): MaybeAsync> 47 | /** Returns the first `Just` between the future value of `this` and another future `Maybe` or future `Nothing` if both `this` and the argument are `Nothing` */ 48 | alt(other: MaybeAsync): MaybeAsync 49 | /** Returns `this` if it resolves to `Nothing`, otherwise it returns the result of applying the function argument to the value of `this` and wrapping it in a `Just` */ 50 | extend(f: (value: MaybeAsync) => U): MaybeAsync> 51 | /** Takes a predicate function and returns `this` if the predicate, applied to the resolved value, is true or Nothing if it's false */ 52 | filter(pred: (value: T) => value is U): MaybeAsync 53 | /** Takes a predicate function and returns `this` if the predicate, applied to the resolved value, is true or Nothing if it's false */ 54 | filter(pred: (value: T) => boolean): MaybeAsync 55 | /** Flattens a `Maybe` nested inside a `MaybeAsync`. `m.join()` is equivalent to `m.chain(async x => x)` */ 56 | join(this: MaybeAsync>): MaybeAsync 57 | /** Useful if you are not interested in the result of an operation */ 58 | void(): MaybeAsync 59 | /** Structural pattern matching for `MaybeAsync` in the form of a function */ 60 | caseOf(patterns: MaybePatterns): Promise 61 | /* Similar to the Promise method of the same name, the provided function is called when the `MaybeAsync` is executed regardless of whether the `Maybe` result is `Nothing` or `Just` */ 62 | finally(effect: () => any): MaybeAsync 63 | 64 | 'fantasy-land/chain'(f: (value: T) => PromiseLike>): MaybeAsync 65 | 'fantasy-land/alt'(other: MaybeAsync): MaybeAsync 66 | 'fantasy-land/filter'( 67 | pred: (value: T) => value is U 68 | ): MaybeAsync 69 | 'fantasy-land/filter'(pred: (value: T) => boolean): MaybeAsync 70 | 71 | /** WARNING: This is implemented only for Promise compatibility. Please use `chain` instead. */ 72 | then: PromiseLike>['then'] 73 | } 74 | 75 | export interface MaybeAsyncValue extends PromiseLike {} 76 | 77 | export interface MaybeAsyncHelpers { 78 | /** Allows you to take a regular Maybe value and lift it to the MaybeAsync context. Awaiting a lifted Maybe will give you the value inside. If the Maybe is Nothing then the function will exit immediately and MaybeAsync will resolve to Nothing after running it */ 79 | liftMaybe(maybe: Maybe): MaybeAsyncValue 80 | /** Allows you to take a Maybe inside a Promise and lift it to the MaybeAsync context. Awaiting a lifted Promise will give you the value inside the Maybe. If the Maybe is Nothing or the Promise is rejected then the function will exit immediately and MaybeAsync will resolve to Nothing after running it */ 81 | fromPromise(promise: PromiseLike>): MaybeAsyncValue 82 | } 83 | 84 | const helpers: MaybeAsyncHelpers = { 85 | liftMaybe(maybe: Maybe): MaybeAsyncValue { 86 | if (maybe.isJust()) { 87 | return Promise.resolve(maybe.extract()) 88 | } 89 | 90 | throw Nothing 91 | }, 92 | fromPromise(promise: PromiseLike>): MaybeAsyncValue { 93 | return promise.then(helpers.liftMaybe) as MaybeAsyncValue 94 | } 95 | } 96 | 97 | class MaybeAsyncImpl implements MaybeAsync { 98 | [Symbol.toStringTag]: 'MaybeAsync' = 'MaybeAsync' 99 | 100 | constructor( 101 | private runPromise: (helpers: MaybeAsyncHelpers) => PromiseLike 102 | ) {} 103 | 104 | orDefault(defaultValue: T): Promise { 105 | return this.run().then((x) => x.orDefault(defaultValue)) 106 | } 107 | 108 | join(this: MaybeAsync>): MaybeAsync { 109 | return MaybeAsync(async (helpers) => { 110 | const maybe = await this.run() 111 | if (maybe.isJust()) { 112 | const nestedMaybe = await maybe.extract() 113 | return helpers.liftMaybe(nestedMaybe) 114 | } 115 | return helpers.liftMaybe(Nothing as Maybe) 116 | }) 117 | } 118 | 119 | ap(maybeF: MaybeAsync<(value: T) => U>): MaybeAsync> { 120 | return MaybeAsync(async (helpers) => { 121 | const otherValue = await maybeF 122 | 123 | if (otherValue.isJust()) { 124 | const thisValue = await this.run() 125 | 126 | if (thisValue.isJust()) { 127 | return otherValue.extract()(thisValue.extract()) 128 | } else { 129 | return helpers.liftMaybe(Nothing) as any 130 | } 131 | } 132 | 133 | return helpers.liftMaybe(Nothing) 134 | }) 135 | } 136 | 137 | alt(other: MaybeAsync): MaybeAsync { 138 | return MaybeAsync(async (helpers) => { 139 | const thisValue = await this.run() 140 | 141 | if (thisValue.isJust()) { 142 | return thisValue.extract() 143 | } else { 144 | const otherValue = await other 145 | return helpers.liftMaybe(otherValue) 146 | } 147 | }) 148 | } 149 | 150 | extend(f: (value: MaybeAsync) => U): MaybeAsync> { 151 | return MaybeAsync(async (helpers) => { 152 | const maybe = await this.run() 153 | if (maybe.isJust()) { 154 | const v = MaybeAsync.liftMaybe(maybe as Maybe) 155 | return helpers.liftMaybe(Just(f(v))) 156 | } 157 | return helpers.liftMaybe(Nothing) as any 158 | }) 159 | } 160 | 161 | filter(pred: (value: T) => value is U): MaybeAsync 162 | filter(pred: (value: T) => boolean): MaybeAsync 163 | filter(pred: (value: T) => boolean) { 164 | return MaybeAsync(async (helpers) => { 165 | const value = await this.run() 166 | return helpers.liftMaybe(value.filter(pred)) 167 | }) 168 | } 169 | 170 | async run(): Promise> { 171 | try { 172 | return Just(await this.runPromise(helpers)) 173 | } catch { 174 | return Nothing 175 | } 176 | } 177 | 178 | map(f: (value: T) => U): MaybeAsync> { 179 | return MaybeAsync((helpers) => this.runPromise(helpers).then(f as any)) 180 | } 181 | 182 | chain(f: (value: T) => PromiseLike>): MaybeAsync { 183 | return MaybeAsync(async (helpers) => { 184 | const value = await this.runPromise(helpers) 185 | return helpers.fromPromise(f(value)) 186 | }) 187 | } 188 | 189 | toEitherAsync(error: L): EitherAsync { 190 | return EitherAsync(async ({ liftEither }) => { 191 | const maybe = await this.run() 192 | return liftEither(maybe.toEither(error)) 193 | }) 194 | } 195 | 196 | ifJust(effect: (value: T) => any): MaybeAsync { 197 | return MaybeAsync(async (helpers) => { 198 | const maybe = await this.run() 199 | maybe.ifJust(effect) 200 | return helpers.liftMaybe(maybe) 201 | }) 202 | } 203 | 204 | ifNothing(effect: () => any): MaybeAsync { 205 | return MaybeAsync(async (helpers) => { 206 | const maybe = await this.run() 207 | maybe.ifNothing(effect) 208 | return helpers.liftMaybe(maybe) 209 | }) 210 | } 211 | 212 | void(): MaybeAsync { 213 | return this.map((_) => {}) 214 | } 215 | 216 | caseOf(patterns: MaybePatterns): Promise { 217 | return this.run().then((x) => x.caseOf(patterns)) 218 | } 219 | 220 | finally(effect: () => any): MaybeAsync { 221 | return MaybeAsync(({ fromPromise }) => 222 | fromPromise(this.run().finally(effect)) 223 | ) 224 | } 225 | 226 | then, TResult2 = never>( 227 | onfulfilled?: 228 | | ((value: Maybe) => TResult1 | PromiseLike) 229 | | undefined 230 | | null, 231 | onrejected?: 232 | | ((reason: any) => TResult2 | PromiseLike) 233 | | undefined 234 | | null 235 | ): PromiseLike { 236 | return this.run().then(onfulfilled, onrejected) 237 | } 238 | 239 | declare 'fantasy-land/chain': typeof this.chain 240 | declare 'fantasy-land/filter': typeof this.filter 241 | declare 'fantasy-land/alt': typeof this.alt 242 | } 243 | 244 | MaybeAsyncImpl.prototype['fantasy-land/chain'] = MaybeAsyncImpl.prototype.chain 245 | MaybeAsyncImpl.prototype['fantasy-land/filter'] = 246 | MaybeAsyncImpl.prototype.filter 247 | MaybeAsyncImpl.prototype['fantasy-land/alt'] = MaybeAsyncImpl.prototype.alt 248 | 249 | export const MaybeAsync: MaybeAsyncTypeRef = Object.assign( 250 | ( 251 | runPromise: (helpers: MaybeAsyncHelpers) => PromiseLike 252 | ): MaybeAsync => new MaybeAsyncImpl(runPromise), 253 | { 254 | catMaybes: (list: readonly MaybeAsync[]): Promise => 255 | Promise.all(list).then(Maybe.catMaybes), 256 | fromPromise: (f: () => Promise>): MaybeAsync => 257 | MaybeAsync(({ fromPromise: fP }) => fP(f())), 258 | liftMaybe: (maybe: Maybe): MaybeAsync => 259 | MaybeAsync(({ liftMaybe }) => liftMaybe(maybe)) 260 | } 261 | ) 262 | 263 | MaybeAsyncImpl.prototype.constructor = MaybeAsync 264 | -------------------------------------------------------------------------------- /src/NonEmptyList.test.ts: -------------------------------------------------------------------------------- 1 | import { Just, Nothing } from './Maybe' 2 | import { Tuple } from './Tuple' 3 | import { NonEmptyList } from './NonEmptyList' 4 | import { describe, expect, test, it } from 'vitest' 5 | 6 | describe('NonEmptyList', () => { 7 | test('NonEmptyList', () => { 8 | expect(NonEmptyList([5])).toEqual([5]) 9 | }) 10 | 11 | test('isNonEmpty', () => { 12 | expect(NonEmptyList.isNonEmpty([])).toEqual(false) 13 | expect(NonEmptyList.isNonEmpty([1])).toEqual(true) 14 | }) 15 | 16 | test('fromArray', () => { 17 | expect(NonEmptyList.fromArray([])).toEqual(Nothing) 18 | expect(NonEmptyList.fromArray([1])).toEqual(Just(NonEmptyList([1]))) 19 | }) 20 | 21 | test('fromTuple', () => { 22 | expect(NonEmptyList.fromTuple(Tuple(1, 'test'))).toEqual( 23 | NonEmptyList([1, 'test']) 24 | ) 25 | }) 26 | 27 | test('head', () => { 28 | expect(NonEmptyList.head(NonEmptyList([1]))).toEqual(1) 29 | }) 30 | 31 | test('last', () => { 32 | expect(NonEmptyList.last(NonEmptyList([1]))).toEqual(1) 33 | }) 34 | 35 | test('tail', () => { 36 | expect(NonEmptyList.tail(NonEmptyList([1, 2, 3]))).toEqual([2, 3]) 37 | expect(NonEmptyList.tail(NonEmptyList([1]))).toEqual([]) 38 | }) 39 | 40 | test('unsafeCoerce', () => { 41 | expect(() => NonEmptyList.unsafeCoerce([])).toThrow() 42 | expect(NonEmptyList.unsafeCoerce([1])).toEqual(NonEmptyList([1])) 43 | }) 44 | 45 | it('Should handle all Array.prototype methods', () => { 46 | expect(NonEmptyList([1]).map((_) => 'always string')).toEqual( 47 | NonEmptyList(['always string']) 48 | ) 49 | expect(NonEmptyList([1]).filter((_) => true)).toEqual(NonEmptyList([1])) 50 | }) 51 | 52 | it('Should not lose type info when using Array.prototype methoids', () => { 53 | const a: NonEmptyList = NonEmptyList([1]).map((_) => '') 54 | const b: NonEmptyList = NonEmptyList([1]).reverse() 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/NonEmptyList.ts: -------------------------------------------------------------------------------- 1 | import { Maybe, Just, Nothing } from './Maybe.js' 2 | import { Tuple } from './Tuple.js' 3 | 4 | export type NonEmptyListCompliant = T[] & { 0: T } 5 | 6 | export interface NonEmptyList extends NonEmptyListCompliant { 7 | map( 8 | this: NonEmptyList, 9 | callbackfn: (value: T, index: number, array: NonEmptyList) => U, 10 | thisArg?: any 11 | ): NonEmptyList 12 | reverse(this: NonEmptyList): NonEmptyList 13 | concat(...items: ConcatArray[]): NonEmptyList 14 | concat(...items: (T | ConcatArray)[]): NonEmptyList 15 | } 16 | 17 | export interface NonEmptyListTypeRef { 18 | /** Typecasts an array with at least one element into a `NonEmptyList`. Works only if the compiler can confirm that the array has one or more elements */ 19 | >(list: T): NonEmptyList 20 | /** Returns a `Just NonEmptyList` if the parameter has one or more elements, otherwise it returns `Nothing` */ 21 | fromArray(source: readonly T[]): Maybe> 22 | /** Converts a `Tuple` to a `NonEmptyList` */ 23 | fromTuple(source: Tuple): NonEmptyList 24 | /** Typecasts any array into a `NonEmptyList`, but throws an exception if the array is empty. Use `fromArray` as a safe alternative */ 25 | unsafeCoerce(source: readonly T[]): NonEmptyList 26 | /** Returns true and narrows the type if the passed array has one or more elements */ 27 | isNonEmpty(list: readonly T[]): list is NonEmptyList 28 | /** The same function as \`List#head\`, but it doesn't return a Maybe as a NonEmptyList will always have a head */ 29 | head(list: NonEmptyList): T 30 | /** The same function as \`List#last\`, but it doesn't return a Maybe as a NonEmptyList will always have a last element */ 31 | last(list: NonEmptyList): T 32 | /** The same function as \`List#tail\`, but it doesn't return a Maybe as a NonEmptyList will always have a tail (although it may be of length 0) */ 33 | tail(list: NonEmptyList): T[] 34 | } 35 | 36 | const NonEmptyListConstructor = >( 37 | list: T 38 | ): NonEmptyList => list as any 39 | 40 | export const NonEmptyList: NonEmptyListTypeRef = Object.assign( 41 | NonEmptyListConstructor, 42 | { 43 | fromArray: (source: readonly T[]): Maybe> => 44 | NonEmptyList.isNonEmpty(source) ? Just(source) : Nothing, 45 | unsafeCoerce: (source: readonly T[]): NonEmptyList => { 46 | if (NonEmptyList.isNonEmpty(source)) { 47 | return source 48 | } 49 | 50 | throw new Error('NonEmptyList#unsafeCoerce was ran on an empty array') 51 | }, 52 | fromTuple: (source: Tuple): NonEmptyList => 53 | NonEmptyList(source.toArray()), 54 | head: (list: NonEmptyList): T => list[0], 55 | last: (list: NonEmptyList): T => list[list.length - 1]!, 56 | isNonEmpty: (list: readonly T[]): list is NonEmptyList => 57 | list.length > 0, 58 | tail: (list: NonEmptyList): T[] => list.slice(1) 59 | } 60 | ) 61 | -------------------------------------------------------------------------------- /src/Tuple.test.ts: -------------------------------------------------------------------------------- 1 | import { Tuple } from './Tuple' 2 | import { describe, expect, test, it } from 'vitest' 3 | 4 | describe('Tuple', () => { 5 | it('should be ArrayLike', () => { 6 | const [fst, snd] = Tuple(1, 'test') 7 | 8 | expect(fst).toEqual(1) 9 | expect(snd).toEqual('test') 10 | }) 11 | 12 | test('inspect', () => { 13 | expect(Tuple(1, 'a').inspect()).toEqual('Tuple(1, "a")') 14 | }) 15 | 16 | test('toString', () => { 17 | expect(Tuple(1, 'a').toString()).toEqual('Tuple(1, "a")') 18 | }) 19 | 20 | test('toJSON', () => { 21 | expect(JSON.stringify(Tuple(1, 'a'))).toEqual('[1,"a"]') 22 | }) 23 | 24 | test('fanout', () => { 25 | expect( 26 | Tuple.fanout( 27 | (x) => x[0], 28 | (x) => x.length, 29 | 'sss' 30 | ) 31 | ).toEqual(Tuple('s', 3)) 32 | expect( 33 | Tuple.fanout( 34 | (x: string) => x[0], 35 | (x) => x.length 36 | )('sss') 37 | ).toEqual(Tuple('s', 3)) 38 | expect(Tuple.fanout((x: string) => x[0])((x) => x.length)('sss')).toEqual( 39 | Tuple('s', 3) 40 | ) 41 | }) 42 | 43 | test('fromArray', () => { 44 | expect(Tuple.fromArray([5, 10])).toEqual(Tuple(5, 10)) 45 | }) 46 | 47 | test('fst', () => { 48 | expect(Tuple(5, 10).fst()).toEqual(5) 49 | }) 50 | 51 | test('snd', () => { 52 | expect(Tuple(5, 10).snd()).toEqual(10) 53 | }) 54 | 55 | test('equals', () => { 56 | expect(Tuple(5, 10).equals(Tuple(5, 10))).toEqual(true) 57 | expect(Tuple(5, 5).equals(Tuple(5, 10))).toEqual(false) 58 | expect(Tuple(10, 5).equals(Tuple(10, 10))).toEqual(false) 59 | expect(Tuple(0, 5).equals(Tuple(10, 15))).toEqual(false) 60 | 61 | expect(Tuple(5, 5)['fantasy-land/equals'](Tuple(5, 10))).toEqual(false) 62 | }) 63 | 64 | test('bimap', () => { 65 | expect( 66 | Tuple(5, 'Error').bimap( 67 | (x) => x + 1, 68 | (x) => x + '!' 69 | ) 70 | ).toEqual(Tuple(6, 'Error!')) 71 | 72 | expect( 73 | Tuple(5, 'Error')['fantasy-land/bimap']( 74 | (x) => x + 1, 75 | (x) => x + '!' 76 | ) 77 | ).toEqual(Tuple(6, 'Error!')) 78 | }) 79 | 80 | test('mapFirst', () => { 81 | expect(Tuple(5, 5).mapFirst((x) => x + 1)).toEqual(Tuple(6, 5)) 82 | }) 83 | 84 | test('map', () => { 85 | expect(Tuple(5, 5).map((x) => x + 1)).toEqual(Tuple(5, 6)) 86 | 87 | expect(Tuple(5, 5)['fantasy-land/map']((x) => x + 1)).toEqual(Tuple(5, 6)) 88 | }) 89 | 90 | test('reduce', () => { 91 | expect(Tuple(1, 1).reduce((acc, x) => acc + x, 0)).toEqual(1) 92 | 93 | expect(Tuple(1, 1)['fantasy-land/reduce']((acc, x) => acc + x, 0)).toEqual( 94 | 1 95 | ) 96 | }) 97 | 98 | test('toArray', () => { 99 | expect(Tuple(5, 5).toArray()).toEqual([5, 5]) 100 | }) 101 | 102 | test('swap', () => { 103 | expect(Tuple(5, 10).swap()).toEqual(Tuple(10, 5)) 104 | }) 105 | 106 | test('ap', () => { 107 | expect(Tuple(5, 10).ap(Tuple(0, (x) => x + 1))).toEqual(Tuple(5, 11)) 108 | 109 | expect(Tuple(5, 10)['fantasy-land/ap'](Tuple(0, (x) => x + 1))).toEqual( 110 | Tuple(5, 11) 111 | ) 112 | }) 113 | 114 | test('every', () => { 115 | expect(Tuple(5, 10).every((x) => x > 0)).toEqual(true) 116 | expect(Tuple(-5, 10).every((x) => x > 0)).toEqual(false) 117 | }) 118 | 119 | test('some', () => { 120 | expect(Tuple(5, 10).some((x) => x === 10)).toEqual(true) 121 | expect(Tuple(-5, 10).some((x) => x > 0)).toEqual(true) 122 | expect(Tuple('abc', 'bcd').some((x) => x.includes('x'))).toEqual(false) 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /src/Tuple.ts: -------------------------------------------------------------------------------- 1 | export interface TupleTypeRef { 2 | (fst: F, snd: S): Tuple 3 | /** Applies two functions over a single value and constructs a tuple from the results */ 4 | fanout(f: (value: T) => F, g: (value: T) => S, value: T): Tuple 5 | fanout( 6 | f: (value: T) => F, 7 | g: (value: T) => S 8 | ): (value: T) => Tuple 9 | fanout( 10 | f: (value: T) => F 11 | ): (g: (value: T) => S) => (value: T) => Tuple 12 | /** Constructs a tuple from an array with two elements */ 13 | fromArray([fst, snd]: [F, S]): Tuple 14 | } 15 | 16 | export interface Tuple extends Iterable, ArrayLike { 17 | 0: F 18 | 1: S 19 | [index: number]: F | S 20 | length: 2 21 | toJSON(): [F, S] 22 | inspect(): string 23 | toString(): string 24 | /** Returns the first value of `this` */ 25 | fst(): F 26 | /** Returns the second value of `this` */ 27 | snd(): S 28 | /** Compares the values inside `this` and another tuple */ 29 | equals(other: Tuple): boolean 30 | /** Transforms the two values inside `this` with two mapper functions */ 31 | bimap(f: (fst: F) => F2, g: (snd: S) => S2): Tuple 32 | /** Applies a function to the first value of `this` */ 33 | mapFirst(f: (fst: F) => F2): Tuple 34 | /** Applies a function to the second value of `this` */ 35 | map(f: (snd: S) => S2): Tuple 36 | /** A somewhat arbitrary implementation of Foldable for Tuple, the reducer will be passed the initial value and the second value inside `this` as arguments */ 37 | reduce(reducer: (accumulator: T, value: S) => T, initialValue: T): T 38 | /** Returns an array with 2 elements - the values inside `this` */ 39 | toArray(): [F, S] 40 | /** Swaps the values inside `this` */ 41 | swap(): Tuple 42 | /** Applies the second value of a tuple to the second value of `this` */ 43 | ap(f: Tuple S2>): Tuple 44 | /** Tests whether both elements in the tuple pass the test implemented by the provided function */ 45 | every(pred: (value: F | S) => boolean): boolean 46 | /** Tests whether at least one element in the tuple passes the test implemented by the provided function */ 47 | some(pred: (value: F | S) => boolean): boolean 48 | 49 | 'fantasy-land/equals'(other: Tuple): boolean 50 | 'fantasy-land/bimap'( 51 | f: (fst: F) => F2, 52 | g: (snd: S) => S2 53 | ): Tuple 54 | 'fantasy-land/map'(f: (snd: S) => S2): Tuple 55 | 'fantasy-land/reduce'( 56 | reducer: (accumulator: T, value: S) => T, 57 | initialValue: T 58 | ): T 59 | 'fantasy-land/ap'(f: Tuple S2>): Tuple 60 | } 61 | 62 | class TupleImpl implements Tuple { 63 | 0: F 64 | 1: S; 65 | [index: number]: F | S 66 | length: 2 = 2 67 | 68 | constructor( 69 | private first: F, 70 | private second: S 71 | ) { 72 | this[0] = first 73 | this[1] = second 74 | } 75 | 76 | *[Symbol.iterator]() { 77 | yield this.first 78 | yield this.second 79 | } 80 | 81 | toJSON(): [F, S] { 82 | return this.toArray() 83 | } 84 | 85 | inspect(): string { 86 | return `Tuple(${JSON.stringify(this.first)}, ${JSON.stringify( 87 | this.second 88 | )})` 89 | } 90 | 91 | [Symbol.for('nodejs.util.inspect.custom')]( 92 | _depth: number, 93 | opts: unknown, 94 | inspect: Function 95 | ) { 96 | return `Tuple(${inspect(this.first, opts)}, ${inspect(this.second, opts)})` 97 | } 98 | 99 | toString(): string { 100 | return this.inspect() 101 | } 102 | 103 | fst(): F { 104 | return this.first 105 | } 106 | 107 | snd(): S { 108 | return this.second 109 | } 110 | 111 | equals(other: Tuple): boolean { 112 | return this.first === other.fst() && this.second === other.snd() 113 | } 114 | 115 | bimap(f: (fst: F) => F2, g: (snd: S) => S2): Tuple { 116 | return Tuple(f(this.first), g(this.second)) 117 | } 118 | 119 | mapFirst(f: (fst: F) => F2): Tuple { 120 | return Tuple(f(this.first), this.second) 121 | } 122 | 123 | map(f: (snd: S) => S2): Tuple { 124 | return Tuple(this.first, f(this.second)) 125 | } 126 | 127 | reduce(reducer: (accumulator: T, value: S) => T, initialValue: T): T { 128 | return reducer(initialValue, this.second) 129 | } 130 | 131 | toArray(): [F, S] { 132 | return [this.first, this.second] 133 | } 134 | 135 | swap(): Tuple { 136 | return Tuple(this.second, this.first) 137 | } 138 | 139 | ap(f: Tuple S2>): Tuple { 140 | return Tuple(this.first, f.snd()(this.second)) 141 | } 142 | 143 | every(pred: (value: F | S) => boolean): boolean { 144 | return pred(this.first) && pred(this.second) 145 | } 146 | 147 | some(pred: (value: F | S) => boolean): boolean { 148 | return pred(this.first) || pred(this.second) 149 | } 150 | 151 | declare 'fantasy-land/equals': typeof this.equals 152 | declare 'fantasy-land/bimap': typeof this.bimap 153 | declare 'fantasy-land/map': typeof this.map 154 | declare 'fantasy-land/reduce': typeof this.reduce 155 | declare 'fantasy-land/ap': typeof this.ap 156 | } 157 | 158 | TupleImpl.prototype['fantasy-land/equals'] = TupleImpl.prototype.equals 159 | TupleImpl.prototype['fantasy-land/bimap'] = TupleImpl.prototype.bimap 160 | TupleImpl.prototype['fantasy-land/map'] = TupleImpl.prototype.map 161 | TupleImpl.prototype['fantasy-land/reduce'] = TupleImpl.prototype.reduce 162 | TupleImpl.prototype['fantasy-land/ap'] = TupleImpl.prototype.ap 163 | 164 | export const Tuple: TupleTypeRef = Object.assign( 165 | (fst: F, snd: S) => new TupleImpl(fst, snd), 166 | { 167 | fromArray: ([fst, snd]: [F, S]): Tuple => { 168 | return Tuple(fst, snd) 169 | }, 170 | fanout: ( 171 | ...args: [(value: T) => F, ((value: T) => S)?, T?] 172 | ): any => { 173 | const [f, g, value] = args 174 | 175 | switch (args.length) { 176 | case 3: 177 | return Tuple(f(value!), g!(value!)) 178 | case 2: 179 | return (value: T) => Tuple.fanout(f, g!, value) 180 | default: 181 | return (g: (value: T) => S) => 182 | (value: T) => 183 | Tuple.fanout(f, g, value) 184 | } 185 | } 186 | } 187 | ) 188 | 189 | TupleImpl.prototype.constructor = Tuple as any 190 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Codec.js' 2 | export * from './Either.js' 3 | export * from './Function.js' 4 | export * from './List.js' 5 | export * from './Maybe.js' 6 | export { MaybeAsync, type MaybeAsyncTypeRef } from './MaybeAsync.js' 7 | export { EitherAsync, type EitherAsyncTypeRef } from './EitherAsync.js' 8 | export * from './NonEmptyList.js' 9 | export * from './Tuple.js' 10 | -------------------------------------------------------------------------------- /tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "target": "es2020", 5 | "lib": ["es2020"], 6 | "module": "esnext", 7 | "outDir": "lib/esm", 8 | "esModuleInterop": true 9 | }, 10 | "exclude": ["site", "**/*.test.ts", "lib"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "module": "commonjs", 5 | "lib": ["es2015", "es2016", "es2017", "es2018", "dom"], 6 | "declaration": true, 7 | "outDir": "./lib", 8 | "rootDir": "./src/", 9 | "strictNullChecks": true, 10 | "noImplicitThis": true, 11 | "noUnusedParameters": true, 12 | "noImplicitReturns": true, 13 | "alwaysStrict": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "baseUrl": "./src/", 16 | "downlevelIteration": false, 17 | "strict": true, 18 | "isolatedModules": true, 19 | "noUncheckedIndexedAccess": true, 20 | "esModuleInterop": true 21 | }, 22 | "exclude": ["site", "**/*.test.ts", "lib"] 23 | } 24 | --------------------------------------------------------------------------------