├── .eslintrc.js ├── .github └── workflows │ └── check.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.toml ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs-ts.json ├── docs ├── _config.yml ├── functional.md ├── generics.md ├── index.md ├── modules │ ├── index.md │ └── index.ts.md ├── pattern-matching.md └── serialization.md ├── flake.lock ├── flake.nix ├── jest.config.js ├── package.json ├── src └── index.ts ├── test ├── type │ └── index.ts └── unit │ └── index.ts ├── tsconfig.build-cjs.json ├── tsconfig.build-esm.json ├── tsconfig.build-types.json ├── tsconfig.json ├── tsconfig.lint.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | plugins: ["@typescript-eslint", "expect-type", "functional"], 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/eslint-recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 9 | "plugin:expect-type/recommended", 10 | "plugin:functional/all", 11 | ], 12 | parserOptions: { 13 | project: "./tsconfig.lint.json", 14 | }, 15 | rules: { 16 | "functional/prefer-type-literal": 0, 17 | "functional/no-expression-statement": [ 18 | 2, 19 | { ignorePattern: "(describe)|(it)|(expect)|(fc.)" }, 20 | ], 21 | "@typescript-eslint/array-type": [1, { default: "generic" }], 22 | "@typescript-eslint/strict-boolean-expressions": [ 23 | 2, 24 | { 25 | /** Unset default (`true`) */ 26 | allowString: false, 27 | /** Unset default (`true`) */ 28 | allowNumber: false, 29 | /** Unset default (`true`) */ 30 | allowNullableObject: false, 31 | }, 32 | ], 33 | }, 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | typecheck: 10 | name: Typecheck 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-node@v2 15 | with: 16 | node-version: 16.x 17 | cache: yarn 18 | - run: yarn install --frozen-lockfile 19 | - run: yarn typecheck 20 | 21 | lint: 22 | name: Lint 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions/setup-node@v2 27 | with: 28 | node-version: 16.x 29 | cache: yarn 30 | - run: yarn install --frozen-lockfile 31 | - run: yarn lint 32 | 33 | fmt: 34 | name: Formatting 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v2 38 | - uses: actions/setup-node@v2 39 | with: 40 | node-version: 16.x 41 | cache: yarn 42 | - run: yarn install --frozen-lockfile 43 | - run: yarn fmt --check 44 | 45 | unit: 46 | name: Unit test 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v2 50 | - uses: actions/setup-node@v2 51 | with: 52 | node-version: 16.x 53 | cache: yarn 54 | - run: yarn install --frozen-lockfile 55 | - run: yarn unit 56 | 57 | docs: 58 | name: Docs 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v2 62 | - uses: actions/setup-node@v2 63 | with: 64 | node-version: 16.x 65 | cache: yarn 66 | - run: yarn install --frozen-lockfile 67 | - run: yarn docs 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | coverage/ 4 | docs/ 5 | 6 | -------------------------------------------------------------------------------- /.prettierrc.toml: -------------------------------------------------------------------------------- 1 | semi = false 2 | arrowParens = "avoid" 3 | trailingComma = "all" 4 | 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project adheres to semantic versioning. 4 | 5 | ## 0.4.1 (2023-10-04) 6 | 7 | Fixes pattern matching branches which return `undefined` in `matchX` and `matchXW`. 8 | 9 | ## 0.4.0 (2023-05-16) 10 | 11 | Adds supports for convenient "strict" pattern matching without access to member values, denoted by an "X" suffix. 12 | 13 | Adds a first-class, low-level `is` primitive for refining foreign data to a known sum member. 14 | 15 | Fixes reference equality of deserialized nullary sums. 16 | 17 | ## 0.3.2 (2023-03-23) 18 | 19 | Fixes nullary equality checks in Jest and others that compare the reference equality of functions. 20 | 21 | ## 0.3.1 (2022-09-13) 22 | 23 | Exports the types `Match` and `MatchW` to workaround type errors that appear when compiling with the `declaration` compiler option enabled. 24 | 25 | ## 0.3.0 (2022-09-05) 26 | 27 | Nullary constructors are no longer function calls, fixing an edge case unsafety. Where you previously called `mk.Member()`, now simply refer to `mk.Member`. 28 | 29 | ## 0.2.2 (2022-02-22) 30 | 31 | Add ESM support. 32 | 33 | ## 0.2.1 (2022-01-12) 34 | 35 | Fix runtime representation of nullary constructors. 36 | 37 | ## 0.2.0 (2022-01-12) 38 | 39 | Exhaustive checking in pattern matching now reports on missing members. Previously it would report on a missing wildcard. 40 | 41 | The internal representation of nullary constructors has been changed to use `null` instead of `undefined` for easier JSON interop post-serialisation. 42 | 43 | ## 0.1.1 (2021-12-08) 44 | 45 | Expose the `Serialized` type for easier usage of the serialization functions. 46 | 47 | ## 0.1.0 (2021-09-17) 48 | 49 | The initial release of `@unsplash/sum-types` with support for non-generic sum types, pattern matching with or without wildcards, and (de)serialization. 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Unsplash 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @unsplash/sum-types 2 | 3 | Safe, ergonomic, non-generic sum types in TypeScript. 4 | 5 | Documentation: [unsplash.github.io/sum-types](https://unsplash.github.io/sum-types/) 6 | 7 | ```ts 8 | import * as Sum from "@unsplash/sum-types" 9 | 10 | type Weather = Sum.Member<"Sun"> | Sum.Member<"Rain", number> 11 | const Weather = Sum.create() 12 | 13 | const getRainfall = Weather.match({ 14 | Rain: n => `${n}mm`, 15 | Sun: () => "none", 16 | }) 17 | 18 | const todayWeather = Weather.mk.Rain(5) 19 | 20 | getRainfall(todayWeather) // '5mm' 21 | ``` 22 | 23 | ## Installation 24 | 25 | The library is available on the npm registry: [@unsplash/sum-types](https://www.npmjs.com/package/@unsplash/sum-types) 26 | 27 | Note that due to usage of `Proxy` and `Symbol` this library only supports ES2015+. 28 | 29 | The following bindings are also available: 30 | 31 | - [@unsplash/sum-types-fp-ts](https://github.com/unsplash/sum-types-fp-ts) 32 | - [@unsplash/sum-types-io-ts](https://github.com/unsplash/sum-types-io-ts) 33 | - [@unsplash/sum-types-fast-check](https://github.com/unsplash/sum-types-fast-check) 34 | 35 | ## Motivation 36 | 37 | The library solves a number of problems we've experienced at Unsplash with alternative libraries in this space. Specifically: 38 | 39 | - Convenient member constructor functions are provided, unlike [ts-adt](https://github.com/pfgray/ts-adt). 40 | - The API is small, simple, and boilerplate-free, unlike [tagged-ts](https://github.com/joshburgess/tagged-ts). 41 | - Pattern matching is curried for use in pipeline application and function composition, unlike [@practical-fp/union-types](https://github.com/practical-fp/union-types). 42 | - Types are not inlined in compiler output, improving readability and performance at scale, unlike [unionize](https://github.com/pelotom/unionize). 43 | 44 | The compromise we've made to achieve this is to [not support generic sum types](https://unsplash.github.io/sum-types/generics.html). 45 | -------------------------------------------------------------------------------- /docs-ts.json: -------------------------------------------------------------------------------- 1 | { 2 | "enforceDescriptions": true, 3 | "enforceExamples": false, 4 | "enforceVersion": false 5 | } 6 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: pmarsceill/just-the-docs 2 | 3 | # Enable or disable the site search 4 | search_enabled: true 5 | 6 | # Aux links for the upper right navigation 7 | aux_links: 8 | '@unsplash/sum-types on GitHub': 9 | - 'https://github.com/unsplash/sum-types' 10 | -------------------------------------------------------------------------------- /docs/functional.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Functional interop 3 | nav_order: 4 4 | --- 5 | 6 | # Functional interop 7 | 8 | The library is written with the functional programming ecosystem in mind. [Pattern matching](./pattern-matching.html) is curried to faciliate partial application, including in pipelines. 9 | 10 | ```ts 11 | import * as Sum from "@unsplash/sum-types" 12 | import { pipe } from "fp-ts/function" 13 | 14 | type Weather = Sum.Member<"Sun"> | Sum.Member<"Rain", number> 15 | 16 | declare const weather: Weather 17 | const status = pipe( 18 | weather, 19 | Weather.match({ 20 | Sun: () => "Sunny out!", 21 | Rain: () => "Remember your umbrella.", 22 | }), 23 | ) 24 | 25 | const getRainfall: (x: Weather) => string = Weather.match({ 26 | Rain: n => `${n}mm`, 27 | Sun: () => "0mm", 28 | }) 29 | ``` 30 | 31 | [@unsplash/sum-types-fp-ts](https://github.com/unsplash/sum-types-fp-ts) provides [fp-ts](https://github.com/gcanti/fp-ts) bindings, enabling things like equivalence checks via [`Eq`](https://gcanti.github.io/fp-ts/modules/Eq.ts.html). 32 | -------------------------------------------------------------------------------- /docs/generics.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Generics 3 | nav_order: 5 4 | --- 5 | 6 | # Generics 7 | 8 | We've not yet found a safe way to implement generic/polymorphic sum types without sacrificing ergonomics. 9 | 10 | We have however found in practice that monomorphic types are sufficient for the vast majority of domain types. 11 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Home 3 | nav_order: 1 4 | --- 5 | 6 | # @unsplash/sum-types 7 | 8 | Safe, ergonomic, non-generic sum types in TypeScript. 9 | 10 | ## What are sum types? 11 | 12 | From a TypeScript perspective we can think of sum types as superpowered enums. Sum types: 13 | 14 | - Can hold arbitrary data, not only string and number literals. 15 | - Come with exhaustive [pattern matching](./pattern-matching.html) out of the box. 16 | - Are safer than enums with respect to [this issue](https://www.aaron-powell.com/posts/2020-05-27-the-dangers-of-typescript-enums/). 17 | 18 | ### Why the name? 19 | 20 | Sum types are [algebraic data types](https://en.wikipedia.org/wiki/Algebraic_data_type), a well established concept in type theory and functional programming. There are also product types, such as tuples and records. 21 | 22 | ## Why not enums? 23 | 24 | Enums can't hold arbitrary data, so things become complicated as soon as you need something other than string or number literals. 25 | 26 | It's common for enums to implicitly be used to hold special data, such as string literals expected back from an API, making changes in confidence difficult. This also couples your internal domain representation to something external. Sum types must be explicitly [serialized](./serialization.html), bypassing these problems. 27 | 28 | Enums have a habit of being narrowed to specific members by the type system, making unification difficult. Sum types on the other hand, by design, always refer to the entire sum. 29 | 30 | On the plus side for enums, language server support for things like renaming members is superior to what a library like this can offer. 31 | 32 | ## Why not unions? 33 | 34 | Sum types are implemented via discriminated unions, however this should be considered an irrelevant implementation detail. 35 | 36 | Unions don't offer pattern matching. Instead you must write a match function per-union by hand, or use imperative, unergonomic switch statements. 37 | 38 | Non-discriminated unions don't give a distinct identity to each member. Consider a fallible function in which the happy and unhappy path both return a string. `string | string = string`, so it's not possible to determine the outcome. 39 | 40 | Discriminated unions a la fp-ts are implicitly serializable. This can be problematic given it's an implementation detail, hence [serialization](./serialization.html). Additionally these are verbose and error-prone to write by hand. 41 | -------------------------------------------------------------------------------- /docs/modules/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Modules 3 | has_children: true 4 | permalink: /docs/modules 5 | nav_order: 6 6 | --- 7 | -------------------------------------------------------------------------------- /docs/modules/index.ts.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: index.ts 3 | nav_order: 1 4 | parent: Modules 5 | --- 6 | 7 | ## index overview 8 | 9 | The library's only entrypoint. Get started with `Member` and `create`. 10 | 11 | **Example** 12 | 13 | ```ts 14 | import { Member, create } from '@unsplash/sum-types' 15 | 16 | type Weather = Member<'Sun'> | Member<'Rain', number> 17 | 18 | const { 19 | mk: { Sun, Rain }, 20 | match, 21 | } = create() 22 | 23 | const getRainfall = match({ 24 | Rain: (n) => `${n}mm`, 25 | Sun: () => 'none', 26 | }) 27 | 28 | const todayWeather = Rain(5) 29 | 30 | getRainfall(todayWeather) // "5mm" 31 | ``` 32 | 33 | Added in v0.1.0 34 | 35 | --- 36 | 37 |

Table of contents

38 | 39 | - [utils](#utils) 40 | - [Member (interface)](#member-interface) 41 | - [Serialized (type alias)](#serialized-type-alias) 42 | - [Sum (interface)](#sum-interface) 43 | - [\_](#_) 44 | - [create](#create) 45 | - [deserialize](#deserialize) 46 | - [is](#is) 47 | - [serialize](#serialize) 48 | 49 | --- 50 | 51 | # utils 52 | 53 | ## Member (interface) 54 | 55 | The `Member` type represents a single member of a sum type given a name and 56 | an optional monomorphic value. A sum type is formed by unionizing these 57 | members. 58 | 59 | **Signature** 60 | 61 | ```ts 62 | export interface Member { 63 | readonly [tagKey]: K 64 | readonly [valueKey]: A 65 | } 66 | ``` 67 | 68 | **Example** 69 | 70 | ```ts 71 | import { Member } from '@unsplash/sum-types' 72 | 73 | type Weather = Member<'Sun'> | Member<'Rain', number> 74 | ``` 75 | 76 | Added in v0.1.0 77 | 78 | ## Serialized (type alias) 79 | 80 | The serialized representation of a sum type, isomorphic to the sum type 81 | itself. 82 | 83 | **Signature** 84 | 85 | ```ts 86 | export type Serialized = A extends AnyMember ? readonly [Tag, Value] : never 87 | ``` 88 | 89 | Added in v0.1.1 90 | 91 | ## Sum (interface) 92 | 93 | The output of `create`, providing constructors and pattern matching. 94 | 95 | **Signature** 96 | 97 | ```ts 98 | export interface Sum { 99 | /** 100 | * An object of constructors for the sum type's members. 101 | * 102 | * @since 0.1.0 103 | */ 104 | readonly mk: Constructors 105 | /** 106 | * Pattern match against each member of a sum type. All members must 107 | * exhaustively be covered unless a wildcard (@link \_) is present. 108 | * 109 | * @example 110 | * match({ 111 | * Rain: (n) => `It's rained ${n} today!`, 112 | * [_]: () => "Nice weather today.", 113 | * }) 114 | * 115 | * @since 0.1.0 116 | */ 117 | readonly match: Match 118 | /** 119 | * Pattern match against each member of a sum type. All members must 120 | * exhaustively be covered unless a wildcard (@link \_) is present. Unionises 121 | * the return types of the branches, hence the "W" suffix ("widen"). 122 | * 123 | * @example 124 | * matchW({ 125 | * Sun: () => 123, 126 | * [_]: () => "the return types can be different", 127 | * }) 128 | * 129 | * @since 0.1.0 130 | */ 131 | readonly matchW: MatchW 132 | /** 133 | * Pattern match against each member of a sum type strictly, hence the "X" 134 | * suffix ("strict"). All members must exhaustively be covered unless a 135 | * wildcard (@link \_) is present. 136 | * 137 | * @example 138 | * matchX({ 139 | * Sun: 123, 140 | * [_]: 456, 141 | * }) 142 | * 143 | * @since 0.4.0 144 | */ 145 | readonly matchX: MatchX 146 | /** 147 | * Pattern match against each member of a sum type strictly, hence the "X" 148 | * suffix ("strict"). All members must exhaustively be covered unless a 149 | * wildcard (@link \_) is present. Unionises the return types of the branches, 150 | * hence the "W" suffix ("widen"). 151 | * 152 | * @example 153 | * matchXW({ 154 | * Sun: 123, 155 | * [_]: "the return types can be different", 156 | * }) 157 | * 158 | * @since 0.4.0 159 | */ 160 | readonly matchXW: MatchXW 161 | } 162 | ``` 163 | 164 | ## \_ 165 | 166 | Symbol for declaring a wildcard case in a {@link match} expression. 167 | 168 | **Signature** 169 | 170 | ```ts 171 | export declare const _: typeof _ 172 | ``` 173 | 174 | **Example** 175 | 176 | ```ts 177 | import { Member, create, _ } from '@unsplash/sum-types' 178 | 179 | type Weather = Member<'Sun'> | Member<'Rain', number> | Member<'Clouds'> | Member<'Overcast', string> 180 | 181 | const Weather = create() 182 | 183 | const getSun = Weather.match({ 184 | Sun: () => 'sun', 185 | Overcast: () => 'partial sun', 186 | [_]: () => 'no sun', 187 | }) 188 | 189 | assert.strictEqual(getSun(Weather.mk.Sun), 'sun') 190 | assert.strictEqual(getSun(Weather.mk.Clouds), 'no sun') 191 | ``` 192 | 193 | Added in v0.1.0 194 | 195 | ## create 196 | 197 | Create runtime constructors and pattern matching functions for a given sum 198 | type. 199 | 200 | **Signature** 201 | 202 | ```ts 203 | export declare const create: () => Sum 204 | ``` 205 | 206 | **Example** 207 | 208 | ```ts 209 | import { Member, create } from '@unsplash/sum-types' 210 | 211 | type Weather = Member<'Sun'> | Member<'Rain', number> 212 | 213 | // Depending upon your preferences you may prefer to destructure the 214 | // returned object or effectively namespace it: 215 | const { 216 | mk: { Sun, Rain }, 217 | match, 218 | } = create() 219 | const Weather = create() 220 | ``` 221 | 222 | Added in v0.1.0 223 | 224 | ## deserialize 225 | 226 | Deserialize any prospective sum type member, represented by a tuple of its 227 | discriminant tag and its value (if any), into its programmatic data 228 | structure. Reversible by `serialize`. 229 | 230 | **Signature** 231 | 232 | ```ts 233 | export declare const deserialize: (x: Sum) => (y: Serialized) => A 234 | ``` 235 | 236 | Added in v0.1.0 237 | 238 | ## is 239 | 240 | Refine a foreign value to a sum type member given its key and a refinement to 241 | its value. 242 | 243 | This is a low-level primitive. Instead consider `@unsplash/sum-types-io-ts`. 244 | 245 | **Signature** 246 | 247 | ```ts 248 | export declare const is: () => >( 249 | k: B 250 | ) => (f: (mv: unknown) => mv is Value>>) => (x: unknown) => x is A 251 | ``` 252 | 253 | **Example** 254 | 255 | ```ts 256 | import { Member, create, is } from '@unsplash/sum-types' 257 | 258 | type Weather = Member<'Sun'> | Member<'Rain', number> 259 | const Weather = create() 260 | 261 | assert.strictEqual(is()('Rain')((x): x is number => typeof x === 'number')(Weather.mk.Rain(123)), true) 262 | ``` 263 | 264 | Added in v0.4.0 265 | 266 | ## serialize 267 | 268 | Serialize any sum type member into a tuple of its discriminant tag and its 269 | value (if any). It is recommended that you do this for any persistent data 270 | storage instead of relying upon how the library internally structures this 271 | data, which is an implementation detail. Reversible by `deserialize`. 272 | 273 | **Signature** 274 | 275 | ```ts 276 | export declare const serialize: (x: A) => Serialized 277 | ``` 278 | 279 | Added in v0.1.0 280 | -------------------------------------------------------------------------------- /docs/pattern-matching.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Pattern matching 3 | nav_order: 2 4 | --- 5 | 6 | # Pattern matching 7 | 8 | Pattern matching is exposed via the `match` method returned by `create`. This function is monomorphic to the sum type that's been created; this bypasses some type unsafety we observed with a polymorphic solution. It's curried out of the box. 9 | 10 | Two forms of pattern matching are supported by the same function, exhaustive and wildcard. Pattern matching is always _total_, requiring an output for every possible input, making it fully typesafe. 11 | 12 | ## Exhaustive 13 | 14 | By default pattern matching is exhaustive. This means that the compiler will flag up any constructors unaccounted for. 15 | 16 | ```ts 17 | MySum.match({ 18 | X: f, 19 | Y: g, 20 | // If there are other members we haven't accounted for, the compiler will 21 | // flag this for us here. 22 | }) 23 | ``` 24 | 25 | ## Wildcard 26 | 27 | By adding the `_` wildcard symbol case, pattern matching ceases to be exhaustive and instead any cases unaccounted for are handed to the wildcard callback. 28 | 29 | ```ts 30 | MySum.match({ 31 | X: f, 32 | Y: g, 33 | // Any remaining members will be handled by the wildcard callback. Note that 34 | // whilst it's idiomatic to place the wildcard at the end, its position does 35 | // not matter. 36 | [_]: h, 37 | }) 38 | ``` 39 | 40 | ## Branch widening 41 | 42 | In addition to `match` there's also `matchW`. The "W" denotes widening, [as in fp-ts](https://gcanti.github.io/fp-ts/guides/code-conventions.html#what-a-w-suffix-means-eg-chainw-or-chaineitherkw). Where `match` requires the same output type on all branches, `matchW` tolerates differences and unionises them instead. This can be useful when outputting to a union type such as `ReactNode`. 43 | 44 | ```ts 45 | MySum.matchW({ 46 | X: () => 'foo', 47 | Y: () => 123, 48 | }) 49 | ``` 50 | -------------------------------------------------------------------------------- /docs/serialization.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Serialization 3 | nav_order: 3 4 | --- 5 | 6 | # Serialization 7 | 8 | The library makes use of tagged/disjoint/discriminated unions under the hood, however this is deemed an implementation detail. If you need to transfer or store a sum type, you should make use of the provided `serialize` and `deserialize` functions. 9 | 10 | The two functions are purely reversible. `Serialized` is a tuple of the discriminant string (the sum type member name) and the value, if any. For example, given a sum type member `Member<'Rain', number>` with value `123`, its serialized form is `['Rain', 123]`. 11 | 12 | ## io-ts 13 | 14 | For something more powerful and plugging into a broader ecosystem, consider [@unsplash/sum-types-io-ts](https://github.com/unsplash/sum-types-io-ts). 15 | 16 | ```ts 17 | import * as Sum from '@unsplash/sum-types' 18 | import * as t from 'io-ts' 19 | import { getCodecFromStringlyMappedNullaryTag } from '@unsplash/sum-types-io-ts' 20 | 21 | type Weather = Sum.Member<'Sun'> | Sum.Member<'Rain'> 22 | const Weather = Sum.create() 23 | 24 | const Response = t.type({ 25 | weather: getCodecFromStringlyMappedNullaryTag()({ 26 | Sun: 'sun', 27 | Rain: 'rain', 28 | }), 29 | }) 30 | 31 | Response.decode({ weather: 'rain' }) // Right({ weather: Weather.mk.Rain }) 32 | 33 | Response.encode({ weather: Weather.mk.Rain }) // { weather: 'rain' } 34 | ``` 35 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1705309234, 9 | "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1708001613, 24 | "narHash": "sha256-woOmAXW05XnqlLn7dKzCkRAEOSOdA/Z2ndVvKcjid94=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "085589047343aad800c4d305cf7b98e8a3d51ae2", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | }; 6 | 7 | outputs = { self, nixpkgs, flake-utils }: 8 | flake-utils.lib.eachDefaultSystem (system: 9 | let pkgs = import nixpkgs { inherit system; }; 10 | in 11 | { 12 | devShells.default = pkgs.mkShell { 13 | nativeBuildInputs = with pkgs; [ 14 | nodejs 15 | yarn 16 | ]; 17 | }; 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: "ts-jest", 3 | testEnvironment: "node", 4 | testRegex: "/test/unit/", 5 | globals: { 6 | "ts-jest": { 7 | diagnostics: false, 8 | }, 9 | }, 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@unsplash/sum-types", 3 | "description": "Safe, ergonomic, non-generic sum types in TypeScript.", 4 | "version": "0.4.1", 5 | "license": "MIT", 6 | "author": "Unsplash", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/unsplash/sum-types" 10 | }, 11 | "homepage": "https://github.com/unsplash/sum-types", 12 | "bugs": "https://github.com/unsplash/sum-types/issues", 13 | "keywords": [ 14 | "functional-programming", 15 | "typescript", 16 | "adt", 17 | "tagged", 18 | "pattern", 19 | "matching" 20 | ], 21 | "main": "./dist/cjs/index.js", 22 | "module": "./dist/esm/index.js", 23 | "types": "./dist/types/index.d.ts", 24 | "files": [ 25 | "dist/" 26 | ], 27 | "sideEffects": false, 28 | "scripts": { 29 | "build": "rm -rf ./dist/ && mkdir -p ./dist/esm/ ./dist/cjs/ && tsc -p ./tsconfig.build-esm.json && tsc -p ./tsconfig.build-cjs.json && tsc -p ./tsconfig.build-types.json", 30 | "typecheck": "tsc --noEmit", 31 | "lint": "eslint ./src/ ./test/ --ext ts", 32 | "fmt": "prettier .", 33 | "unit": "jest", 34 | "docs": "docs-ts", 35 | "prepublish": "yarn run build" 36 | }, 37 | "devDependencies": { 38 | "@types/eslint": "^7.0.0", 39 | "@types/jest": "^26.0.20", 40 | "@typescript-eslint/eslint-plugin": "^4.3.0", 41 | "@typescript-eslint/parser": "^4.3.0", 42 | "docs-ts": "^0.6.7", 43 | "eslint": "^7.0.0", 44 | "eslint-plugin-expect-type": "^0.0.6", 45 | "eslint-plugin-functional": "^3.5.0", 46 | "fast-check": "^2.5.0", 47 | "jest": "^26.6.0", 48 | "prettier": "^2.1.2", 49 | "ts-jest": "^26.4.1", 50 | "typescript": "^4.0.3" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The library's only entrypoint. Get started with `Member` and `create`. 3 | * 4 | * @example 5 | * import { Member, create } from "@unsplash/sum-types" 6 | * 7 | * type Weather 8 | * = Member<"Sun"> 9 | * | Member<"Rain", number> 10 | * 11 | * const { mk: { Sun, Rain }, match } = create() 12 | * 13 | * const getRainfall = match({ 14 | * Rain: n => `${n}mm`, 15 | * Sun: () => "none", 16 | * }) 17 | * 18 | * const todayWeather = Rain(5) 19 | * 20 | * getRainfall(todayWeather) // "5mm" 21 | * 22 | * @since 0.1.0 23 | */ 24 | 25 | // Our internal structure is keyed with symbols to try and prevent anyone tying 26 | // their code to it, with the intention being that they instead make use of 27 | // (de)serialization. 28 | /** 29 | * Symbol used to index a sum type and access its tag. Not exposed with the 30 | * intention that consumers make use of (de)serialization. 31 | */ 32 | const tagKey = Symbol("@unsplash/sum-types internal tag key") 33 | type TagKey = typeof tagKey 34 | 35 | /** 36 | * Symbol used to index a sum type and access its tag. Not exposed with the 37 | * intention that consumers make use of (de)serialization. 38 | */ 39 | const valueKey = Symbol("@unsplash/sum-types internal value key") 40 | type ValueKey = typeof valueKey 41 | 42 | /** 43 | * The `Member` type represents a single member of a sum type given a name and 44 | * an optional monomorphic value. A sum type is formed by unionizing these 45 | * members. 46 | * 47 | * @example 48 | * import { Member } from "@unsplash/sum-types" 49 | * 50 | * type Weather 51 | * = Member<"Sun"> 52 | * | Member<"Rain", number> 53 | * 54 | * @since 0.1.0 55 | */ 56 | export interface Member { 57 | readonly [tagKey]: K 58 | readonly [valueKey]: A 59 | } 60 | 61 | /** 62 | * @internal 63 | */ 64 | export type AnyMember = Member 65 | 66 | type Tag = A[TagKey] 67 | type Value = A[ValueKey] 68 | 69 | type ValueByTag> = Value< 70 | Extract> 71 | > 72 | 73 | /** 74 | * A constructor is either `A -> B` or, if it's nullary, directly `B`. 75 | * 76 | * Indexes a sum union by the member tag to determine the constructor shape. 77 | * The third type argument can be inferred via its default. 78 | * 79 | * @internal 80 | */ 81 | // eslint-disable-next-line functional/prefer-readonly-type 82 | export type Constructor< 83 | A extends AnyMember, 84 | K extends Tag, 85 | V = ValueByTag, 86 | // eslint-disable-next-line functional/prefer-readonly-type 87 | > = [V] extends [null] ? A : (x: V) => A 88 | 89 | /** 90 | * Build a constructor for a member. If the member is nullary it won't be a 91 | * function but a plain value. 92 | * 93 | * @internal 94 | */ 95 | export const mkConstructor = 96 | () => // eslint-disable-line functional/functional-parameters 97 | >(k: T): Constructor => { 98 | const nonNullary = (v => ({ 99 | [tagKey]: k, 100 | [valueKey]: v, 101 | })) as Exclude, A> 102 | 103 | const nullary = nonNullary(null) as unknown as A 104 | 105 | // We don't know at runtime if the member is nullary or not, so we'll 106 | // return an object which can act as both. Types will guarantee visibility 107 | // and access only upon the appropriate one of the two possibilities. 108 | // 109 | // NB the function needs to come first. 110 | return Object.assign(nonNullary, nullary) // eslint-disable-line functional/immutable-data 111 | } 112 | 113 | type Constructors = { 114 | readonly // eslint-disable-next-line functional/prefer-readonly-type 115 | [V in A as Tag]: Constructor> 116 | } 117 | 118 | // eslint-disable-next-line functional/functional-parameters 119 | const mkConstructors = (): Constructors => { 120 | // Reuse constructors to preserve referential equality. This improves interop 121 | // with the likes of Jest. 122 | const xs = new Map, Constructor>>() 123 | 124 | return new Proxy({} as Constructors, { 125 | get: (__: Constructors, tag: Tag) => { 126 | const f = xs.get(tag) 127 | // eslint-disable-next-line functional/no-conditional-statement 128 | if (f !== undefined) return f 129 | 130 | const g = mkConstructor()(tag) 131 | // eslint-disable-next-line functional/no-expression-statement 132 | xs.set(tag, g) 133 | 134 | return g 135 | }, 136 | }) 137 | } 138 | 139 | /** 140 | * Symbol for declaring a wildcard case in a {@link match} expression. 141 | * 142 | * @example 143 | * import { Member, create, _ } from "@unsplash/sum-types" 144 | * 145 | * type Weather 146 | * = Member<"Sun"> 147 | * | Member<"Rain", number> 148 | * | Member<"Clouds"> 149 | * | Member<"Overcast", string> 150 | * 151 | * const Weather = create() 152 | * 153 | * const getSun = Weather.match({ 154 | * Sun: () => "sun", 155 | * Overcast: () => "partial sun", 156 | * [_]: () => "no sun", 157 | * }) 158 | * 159 | * assert.strictEqual(getSun(Weather.mk.Sun), "sun") 160 | * assert.strictEqual(getSun(Weather.mk.Clouds), "no sun") 161 | * 162 | * @since 0.1.0 163 | */ 164 | export const _ = Symbol("@unsplash/sum-types pattern matching wildcard") 165 | 166 | /** 167 | * Ensures that a {@link match} expression covers all cases. 168 | */ 169 | type CasesExhaustive = { 170 | readonly [V in A as Tag]: (val: Value) => B 171 | } 172 | 173 | type CasesXExhaustive = { 174 | readonly [V in A as Tag]: B 175 | } 176 | 177 | /** 178 | * Enables a {@link match} expression to cover only some cases provided a 179 | * wildcard case is declared with which to match the remaining cases. 180 | */ 181 | // Don't directly reuse `CasesExhaustive` here (via `Partial`), it causes 182 | // exhaustive errors to always point to the absence of a wildcard. 183 | type CasesWildcard = { 184 | readonly [K in keyof CasesExhaustive]?: CasesExhaustive[K] 185 | } & { 186 | readonly [_]: () => B 187 | } 188 | 189 | type CasesXWildcard = { 190 | readonly [K in keyof CasesXExhaustive]?: CasesXExhaustive[K] 191 | } & { 192 | readonly [_]: B 193 | } 194 | 195 | /** 196 | * Ensures that a {@link match} expression either covers all cases or contains 197 | * a wildcard for matching the remaining cases. 198 | */ 199 | // The order of this union impacts exhaustive error reporting. 200 | type Cases = CasesWildcard | CasesExhaustive 201 | 202 | type CasesX = 203 | | CasesXWildcard 204 | | CasesXExhaustive 205 | 206 | type ReturnTypes< 207 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 208 | A extends Record) => unknown>, 209 | > = ReturnType 210 | 211 | /** 212 | * @internal 213 | */ 214 | export type MatchW = >( 215 | fs: B, 216 | ) => (x: A) => ReturnTypes 217 | 218 | /** 219 | * @internal 220 | */ 221 | export type MatchXW = >( 222 | fs: B, 223 | ) => (x: A) => B[keyof B] 224 | 225 | /** 226 | * @internal 227 | */ 228 | export type Match = (fs: Cases) => (x: A) => B 229 | 230 | /** 231 | * @internal 232 | */ 233 | export type MatchX = (fs: CasesX) => (x: A) => B 234 | 235 | const mkMatchW = 236 | (): MatchW => // eslint-disable-line functional/functional-parameters 237 | >(fs: B) => 238 | >(x: A): C => { 239 | const tag = x[tagKey] as Tag 240 | 241 | const g = fs[tag] 242 | // eslint-disable-next-line functional/no-conditional-statement, @typescript-eslint/no-unsafe-return 243 | if (g !== undefined) return g(x[valueKey]) as C 244 | 245 | const h = (fs as CasesWildcard)[_] 246 | // eslint-disable-next-line functional/no-conditional-statement, @typescript-eslint/no-unsafe-return 247 | if (h !== undefined) return h() as C 248 | 249 | // eslint-disable-next-line functional/no-throw-statement 250 | throw new Error(`Failed to pattern match against tag "${tag}".`) 251 | } 252 | 253 | const mkMatchXW = 254 | (): MatchXW => // eslint-disable-line functional/functional-parameters 255 | >(fs: B) => 256 | (x: A): C => { 257 | const tag = x[tagKey] as Tag 258 | 259 | // eslint-disable-next-line functional/no-conditional-statement, @typescript-eslint/no-unsafe-return 260 | if (Object.prototype.hasOwnProperty.call(fs, tag)) return fs[tag] as C 261 | 262 | // eslint-disable-next-line functional/no-conditional-statement, @typescript-eslint/no-unsafe-return 263 | if (Object.prototype.hasOwnProperty.call(fs, _)) 264 | return (fs as CasesXWildcard)[_] as C 265 | 266 | // eslint-disable-next-line functional/no-throw-statement 267 | throw new Error(`Failed to pattern match against tag "${tag}".`) 268 | } 269 | 270 | const mkMatch = 271 | (): Match => // eslint-disable-line functional/functional-parameters 272 | (fs: Cases) => 273 | (x: A): B => 274 | mkMatchW()(fs)(x) as B 275 | 276 | const mkMatchX = 277 | (): MatchX => // eslint-disable-line functional/functional-parameters 278 | (fs: CasesX) => 279 | (x: A): B => 280 | mkMatchXW()(fs)(x) as B 281 | 282 | /** 283 | * The output of `create`, providing constructors and pattern matching. 284 | * 285 | * @internal 286 | */ 287 | export interface Sum { 288 | /** 289 | * An object of constructors for the sum type's members. 290 | * 291 | * @since 0.1.0 292 | */ 293 | readonly mk: Constructors 294 | /** 295 | * Pattern match against each member of a sum type. All members must 296 | * exhaustively be covered unless a wildcard (@link \_) is present. 297 | * 298 | * @example 299 | * match({ 300 | * Rain: (n) => `It's rained ${n} today!`, 301 | * [_]: () => "Nice weather today.", 302 | * }) 303 | * 304 | * @since 0.1.0 305 | */ 306 | readonly match: Match 307 | /** 308 | * Pattern match against each member of a sum type. All members must 309 | * exhaustively be covered unless a wildcard (@link \_) is present. Unionises 310 | * the return types of the branches, hence the "W" suffix ("widen"). 311 | * 312 | * @example 313 | * matchW({ 314 | * Sun: () => 123, 315 | * [_]: () => "the return types can be different", 316 | * }) 317 | * 318 | * @since 0.1.0 319 | */ 320 | readonly matchW: MatchW 321 | /** 322 | * Pattern match against each member of a sum type strictly, hence the "X" 323 | * suffix ("strict"). All members must exhaustively be covered unless a 324 | * wildcard (@link \_) is present. 325 | * 326 | * @example 327 | * matchX({ 328 | * Sun: 123, 329 | * [_]: 456, 330 | * }) 331 | * 332 | * @since 0.4.0 333 | */ 334 | readonly matchX: MatchX 335 | /** 336 | * Pattern match against each member of a sum type strictly, hence the "X" 337 | * suffix ("strict"). All members must exhaustively be covered unless a 338 | * wildcard (@link \_) is present. Unionises the return types of the branches, 339 | * hence the "W" suffix ("widen"). 340 | * 341 | * @example 342 | * matchXW({ 343 | * Sun: 123, 344 | * [_]: "the return types can be different", 345 | * }) 346 | * 347 | * @since 0.4.0 348 | */ 349 | readonly matchXW: MatchXW 350 | } 351 | 352 | /** 353 | * Create runtime constructors and pattern matching functions for a given sum 354 | * type. 355 | * 356 | * @example 357 | * import { Member, create } from "@unsplash/sum-types" 358 | * 359 | * type Weather 360 | * = Member<"Sun"> 361 | * | Member<"Rain", number> 362 | * 363 | * // Depending upon your preferences you may prefer to destructure the 364 | * // returned object or effectively namespace it: 365 | * const { mk: { Sun, Rain }, match } = create() 366 | * const Weather = create() 367 | * 368 | * @since 0.1.0 369 | */ 370 | // eslint-disable-next-line functional/functional-parameters 371 | export const create = (): Sum => ({ 372 | mk: mkConstructors(), 373 | match: mkMatch(), 374 | matchW: mkMatchW(), 375 | matchX: mkMatchX(), 376 | matchXW: mkMatchXW(), 377 | }) 378 | 379 | /** 380 | * The serialized representation of a sum type, isomorphic to the sum type 381 | * itself. 382 | * 383 | * @since 0.1.1 384 | */ 385 | // The conditional type distributes over the union members. 386 | export type Serialized = A extends AnyMember 387 | ? readonly [Tag, Value] 388 | : never 389 | 390 | /** 391 | * Serialize any sum type member into a tuple of its discriminant tag and its 392 | * value (if any). It is recommended that you do this for any persistent data 393 | * storage instead of relying upon how the library internally structures this 394 | * data, which is an implementation detail. Reversible by `deserialize`. 395 | * 396 | * @since 0.1.0 397 | */ 398 | export const serialize = (x: A): Serialized => 399 | [x[tagKey], x[valueKey]] as unknown as Serialized 400 | 401 | /** 402 | * Deserialize any prospective sum type member, represented by a tuple of its 403 | * discriminant tag and its value (if any), into its programmatic data 404 | * structure. Reversible by `serialize`. 405 | * 406 | * @since 0.1.0 407 | */ 408 | export const deserialize = 409 | (x: Sum) => 410 | (y: Serialized): A => { 411 | const k: Tag = y[0] 412 | const v: Value = y[1] 413 | 414 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 415 | // @ts-ignore This is unit tested. 416 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 417 | return v === null ? x.mk[k] : x.mk[k](v) 418 | } 419 | 420 | /** 421 | * Refine a foreign value to a sum type member given its key and a refinement to 422 | * its value. 423 | * 424 | * This is a low-level primitive. Instead consider `@unsplash/sum-types-io-ts`. 425 | * 426 | * @example 427 | * import { Member, create, is } from "@unsplash/sum-types" 428 | * 429 | * type Weather 430 | * = Member<"Sun"> 431 | * | Member<"Rain", number> 432 | * const Weather = create() 433 | * 434 | * assert.strictEqual( 435 | * is()("Rain")((x): x is number => typeof x === 'number')(Weather.mk.Rain(123)), 436 | * true, 437 | * ) 438 | * 439 | * @since 0.4.0 440 | */ 441 | // This needs to be thunked because: 442 | // 1. We want to enforce that `A` is provided, which to do with multiple type 443 | // arguments would require `= never`. 444 | // 2. We want `B` to be inferred from the argument, which necessitates not 445 | // being provided a default type (`=`). However, it would have to have a 446 | // default type in order to follow `A`, which would itself have one. 447 | export const is = 448 | () => // eslint-disable-line functional/functional-parameters 449 | >(k: B) => 450 | (f: (mv: unknown) => mv is ValueByTag) => 451 | (x: unknown): x is A => { 452 | // eslint-disable-next-line functional/no-conditional-statement 453 | if (x === null || !["object", "function"].includes(typeof x)) return false 454 | 455 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 456 | const xx: any = x 457 | 458 | // eslint-disable-next-line functional/no-conditional-statement, @typescript-eslint/no-unsafe-member-access 459 | if (!(tagKey in xx) || xx[tagKey] !== k) return false 460 | 461 | // eslint-disable-next-line functional/no-conditional-statement, @typescript-eslint/no-unsafe-member-access 462 | if (!(valueKey in xx) || !f(xx[valueKey])) return false 463 | 464 | return true 465 | } 466 | -------------------------------------------------------------------------------- /test/type/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable functional/functional-parameters, functional/no-expression-statement, @typescript-eslint/no-unused-vars */ 2 | 3 | import { create, Member, mkConstructor } from "../../src/index" 4 | 5 | //# constructors don't distribute over union input 6 | type A = Member<"A1", string | number> 7 | const { 8 | mk: { A1 }, 9 | } = create() 10 | A1 // $ExpectType (x: string | number) => A 11 | 12 | //# match does not unionise match branch return types 13 | type B = Member<"B1", string> | Member<"B2", number> 14 | const { match } = create() 15 | match({ B1: () => 123, B2: () => "hello" }) // $ExpectError 16 | 17 | //# matchX does not unionise match branch return types 18 | const { matchX } = create() 19 | matchX({ B1: 123, B2: "hello" }) // $ExpectError 20 | 21 | //# matchW unionises branch return types 22 | type C = Member<"C1", string> | Member<"C2", number> 23 | const { matchW } = create() 24 | // $ExpectType (x: C) => string | number 25 | matchW({ C1: () => 123, C2: () => "hello" }) 26 | 27 | //# matchXW unionises branch return types 28 | const { matchXW } = create() 29 | // $ExpectType (x: C) => string | number 30 | matchXW({ C1: 123, C2: "hello" }) 31 | 32 | type D = Member<"C1", string> | Member<"C2", number> | Member<"C3"> 33 | 34 | // $ExpectType (x: string) => D 35 | const constructor1 = mkConstructor()("C1") 36 | 37 | // $ExpectType (x: number) => D 38 | const constructor2 = mkConstructor()("C2") 39 | 40 | // $ExpectType D 41 | const constructor3 = mkConstructor()("C3") 42 | -------------------------------------------------------------------------------- /test/unit/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable functional/functional-parameters */ 2 | 3 | import { create, _, is, serialize, deserialize, Member } from "../../src/index" 4 | import fc from "fast-check" 5 | 6 | describe("index", () => { 7 | describe("create", () => { 8 | describe("constructors", () => { 9 | it("are reference-equal", () => { 10 | type Weather = Member<"Sun"> | Member<"Rain", number> | Member<"Snow"> 11 | const Weather = create() 12 | 13 | expect(Weather.mk.Sun).toBe(Weather.mk.Sun) 14 | expect(Weather.mk.Sun).not.toBe(Weather.mk.Snow) 15 | expect(Weather.mk.Sun).not.toBe(Weather.mk.Rain) 16 | expect(Weather.mk.Rain).toBe(Weather.mk.Rain) 17 | 18 | expect({ foo: Weather.mk.Sun }).toEqual({ foo: Weather.mk.Sun }) 19 | expect({ foo: Weather.mk.Sun }).not.toEqual({ foo: Weather.mk.Snow }) 20 | }) 21 | }) 22 | 23 | describe("members", () => { 24 | it("are value-equal", () => { 25 | type Weather = Member<"Sun"> | Member<"Rain", number> 26 | const Weather = create() 27 | 28 | expect(Weather.mk.Sun).toEqual(Weather.mk.Sun) 29 | expect(Weather.mk.Sun).not.toEqual(Weather.mk.Rain(123)) 30 | expect(Weather.mk.Rain(123)).toEqual(Weather.mk.Rain(123)) 31 | expect(Weather.mk.Rain(123)).not.toEqual(Weather.mk.Rain(456)) 32 | }) 33 | }) 34 | 35 | describe("supports pattern matching via", () => { 36 | type Weather = 37 | | Member<"Rain", number> 38 | | Member<"Sun"> 39 | | Member<"Overcast", string> 40 | const Weather = create() 41 | 42 | it("match", () => { 43 | const f = Weather.match({ 44 | Rain: n => `${n}mm`, 45 | [_]: () => "not rain", 46 | }) 47 | 48 | fc.assert( 49 | fc.property(fc.integer(), n => 50 | expect(f(Weather.mk.Rain(n))).toBe(`${n}mm`), 51 | ), 52 | ) 53 | 54 | expect(f(Weather.mk.Sun)).toBe("not rain") 55 | }) 56 | 57 | it("matchW", () => { 58 | const f = Weather.matchW({ 59 | Rain: n => `${n}mm`, 60 | [_]: () => null, 61 | }) 62 | 63 | fc.assert( 64 | fc.property(fc.integer(), n => 65 | expect(f(Weather.mk.Rain(n))).toBe(`${n}mm`), 66 | ), 67 | ) 68 | 69 | expect(f(Weather.mk.Sun)).toBeNull() 70 | }) 71 | 72 | it("matchX", () => { 73 | const f = Weather.matchX({ 74 | Rain: "rained", 75 | [_]: "didn't rain", 76 | }) 77 | 78 | fc.assert( 79 | fc.property(fc.integer(), n => 80 | expect(f(Weather.mk.Rain(n))).toBe("rained"), 81 | ), 82 | ) 83 | 84 | expect(f(Weather.mk.Sun)).toBe("didn't rain") 85 | }) 86 | 87 | it("matchX allow undefined value", () => { 88 | type T = Member<"A"> | Member<"B"> | Member<"toString"> 89 | const T = create() 90 | 91 | const f = T.matchX({ 92 | A: undefined, 93 | [_]: undefined, 94 | }) 95 | 96 | expect(f(T.mk.A)).toBe(undefined) 97 | expect(f(T.mk.B)).toBe(undefined) 98 | // Check that `toString` does not match an object prototype function 99 | // This check would fail when the `in` operator is used instead of `hasOwnProperty`. 100 | expect(f(T.mk.toString)).toBe(undefined) 101 | }) 102 | 103 | it("matchXW", () => { 104 | const f = Weather.matchXW({ 105 | Rain: "rained", 106 | [_]: null, 107 | }) 108 | 109 | fc.assert( 110 | fc.property(fc.integer(), n => 111 | expect(f(Weather.mk.Rain(n))).toBe("rained"), 112 | ), 113 | ) 114 | 115 | expect(f(Weather.mk.Sun)).toBeNull() 116 | }) 117 | 118 | it("matchXW allow undefined value", () => { 119 | type T = Member<"A"> | Member<"B"> | Member<"toString"> 120 | const T = create() 121 | 122 | const f = T.matchXW({ 123 | A: undefined, 124 | [_]: undefined, 125 | }) 126 | 127 | expect(f(T.mk.A)).toBe(undefined) 128 | expect(f(T.mk.B)).toBe(undefined) 129 | // Check that `toString` does not match an object prototype function. 130 | // This check would fail when the `in` operator is used instead of `hasOwnProperty`. 131 | expect(f(T.mk.toString)).toBe(undefined) 132 | }) 133 | }) 134 | }) 135 | 136 | describe("serialize and deserialize", () => { 137 | it("serialize nullary constructors to null value", () => { 138 | type Sum = Member<"foo"> | Member<"bar", undefined> 139 | const Sum = create() 140 | 141 | expect(serialize(Sum.mk.foo)).toEqual(["foo", null]) 142 | expect(serialize(Sum.mk.bar(undefined))).toEqual(["bar", undefined]) 143 | }) 144 | 145 | it("are reversible", () => { 146 | type Sum = Member 147 | const Sum = create() 148 | 149 | fc.assert( 150 | fc.property(fc.string(), fc.integer(), (k, v) => { 151 | const x = create().mk[k](v) 152 | expect(deserialize(Sum)(serialize(x))).toEqual(x) 153 | }), 154 | ) 155 | }) 156 | 157 | it("deserializations are reference-equal", () => { 158 | type Weather = Member<"Sun"> | Member<"Rain", 123> 159 | const Weather = create() 160 | 161 | const sun = Weather.mk.Sun 162 | expect(deserialize(Weather)(serialize(sun))).toEqual(sun) 163 | 164 | const rain = Weather.mk.Rain(123) 165 | expect(deserialize(Weather)(serialize(rain))).toEqual(rain) 166 | }) 167 | }) 168 | 169 | describe("is", () => { 170 | type T = 171 | | Member<"Foo"> 172 | | Member<"Bar", string> 173 | | Member<"Baz", ReadonlyArray> 174 | const T = create() 175 | const f = is() 176 | 177 | it("parses according to provided refinement", () => { 178 | expect(f("Foo")((x): x is null => x === null)(T.mk.Foo)).toBe(true) 179 | 180 | expect( 181 | f("Bar")((x): x is string => typeof x === "string")(T.mk.Bar("ciao")), 182 | ).toBe(true) 183 | 184 | expect( 185 | f("Baz")( 186 | (x): x is ReadonlyArray => 187 | Array.isArray(x) && x.every(y => typeof y === "number"), 188 | )(T.mk.Baz([1, 2, 3])), 189 | ).toBe(true) 190 | }) 191 | 192 | it("fails if refinement fails", () => { 193 | expect(f("Foo")((x): x is null => false)(T.mk.Foo)).toBe(false) 194 | }) 195 | 196 | it("fails bad input key", () => { 197 | expect( 198 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 199 | f("Food" as any)((x): x is null => x === null)(T.mk.Foo), 200 | ).toBe(false) 201 | }) 202 | 203 | it("fails wholly bad data", () => { 204 | const g = f("Foo")((x): x is null => x === null) 205 | 206 | expect(g(undefined)).toBe(false) 207 | expect(g(null)).toBe(false) 208 | expect(g("Foo")).toBe(false) 209 | expect(g(["Foo", null])).toBe(false) 210 | expect(g({ Foo: "Foo" })).toBe(false) 211 | }) 212 | 213 | it("fails bad parsed key", () => { 214 | type U = Member<"NotFoo"> 215 | const U = create() 216 | 217 | expect(f("Foo")((x): x is null => x === null)(U.mk.NotFoo)).toBe(false) 218 | }) 219 | 220 | it("fails bad parsed value", () => { 221 | type U = Member<"Foo", string> 222 | const U = create() 223 | 224 | expect(f("Foo")((x): x is null => x === null)(U.mk.Foo("not null"))).toBe( 225 | false, 226 | ) 227 | }) 228 | 229 | it("tolerates excess properties", () => { 230 | expect( 231 | f("Foo")((x): x is null => x === null)({ ...T.mk.Foo, excess: true }), 232 | ).toBe(true) 233 | }) 234 | }) 235 | }) 236 | -------------------------------------------------------------------------------- /tsconfig.build-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "./dist/cjs/" 6 | }, 7 | "exclude": ["test/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build-esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "outDir": "./dist/esm/" 6 | }, 7 | "exclude": ["test/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build-types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build-esm.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "declarationMap": true, 6 | "declarationDir": "./dist/types/", 7 | "emitDeclarationOnly": true, 8 | "removeComments": false 9 | }, 10 | "exclude": ["test/**/*"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["esnext", "dom"], 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "declaration": false, 8 | "removeComments": true 9 | }, 10 | "include": ["src/**/*.ts", "test/unit/**/*.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["test/**/*.ts"] 4 | } 5 | --------------------------------------------------------------------------------