├── .editorconfig ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src └── index.ts ├── test └── index.spec.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = false 9 | max_line_length = 100 10 | tab_width = 4 11 | 12 | [*.json] 13 | indent_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | dist 3 | node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "quoteProps": "consistent", 4 | "semi": false, 5 | "trailingComma": "all" 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Felix Schorer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Union Types 2 | 3 | [![NPM version badge](https://badgen.net/npm/v/@practical-fp/union-types)](https://npmjs.org/package/@practical-fp/union-types) 4 | [![Bundle size badge](https://badgen.net/bundlephobia/minzip/@practical-fp/union-types)](https://bundlephobia.com/result?p=@practical-fp/union-types) 5 | [![Dependency count badge](https://badgen.net/bundlephobia/dependency-count/@practical-fp/union-types)](https://bundlephobia.com/result?p=@practical-fp/union-types) 6 | [![Tree shaking support badge](https://badgen.net/bundlephobia/tree-shaking/@practical-fp/union-types)](https://bundlephobia.com/result?p=@practical-fp/union-types) 7 | ![License badge](https://img.shields.io/npm/l/@practical-fp/union-types) 8 | 9 | A Typescript library for creating discriminating union types. Requires Typescript 3.5 or higher. 10 | 11 | [Typescript Handbook on discriminating union types](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions) 12 | 13 | ## Example 14 | 15 | ```typescript 16 | import { impl, matchExhaustive, Variant } from "@practical-fp/union-types" 17 | 18 | type Shape = 19 | | Variant<"Circle", { radius: number }> 20 | | Variant<"Square", { sideLength: number }> 21 | 22 | const { Circle, Square } = impl() 23 | 24 | function getArea(shape: Shape) { 25 | return matchExhaustive(shape, { 26 | Circle: ({ radius }) => Math.PI * radius ** 2, 27 | Square: ({ sideLength }) => sideLength ** 2, 28 | }) 29 | } 30 | 31 | const circle = Circle({ radius: 5 }) 32 | const area = getArea(circle) 33 | ``` 34 | 35 | ## Installation 36 | 37 | ```bash 38 | $ npm install @practical-fp/union-types 39 | ``` 40 | 41 | ## Usage 42 | 43 | ### Defining a discriminating union type 44 | 45 | ```typescript 46 | import { Variant } from "@practical-fp/union-types" 47 | 48 | type Shape = 49 | | Variant<"Circle", { radius: number }> 50 | | Variant<"Square", { sideLength: number }> 51 | ``` 52 | 53 | This is equivalent to the following type: 54 | 55 | ```typescript 56 | type Shape = 57 | | { tag: "Circle", value: { radius: number } } 58 | | { tag: "Square", value: { sideLength: number } } 59 | ``` 60 | 61 | ### Creating an implementation 62 | 63 | ```typescript 64 | import { impl } from "@practical-fp/union-types" 65 | 66 | const { Circle, Square } = impl() 67 | ``` 68 | 69 | `impl<>()` can only be used if your environment has full support 70 | for [Proxies](https://caniuse.com/?search=Proxy). Alternatively, use the `constructor<>()` function. 71 | 72 | ```typescript 73 | import { constructor } from "@practical-fp/union-types" 74 | 75 | const Circle = constructor("Circle") 76 | const Square = constructor("Square") 77 | ``` 78 | 79 | `Circle` and `Square` can then be used to wrap values as a `Shape`. 80 | 81 | ```typescript 82 | const circle: Shape = Circle({ radius: 5 }) 83 | const square: Shape = Square({ sideLength: 3 }) 84 | ``` 85 | 86 | `Circle.is` and `Square.is` can be used to check if a shape is a circle or a square. 87 | They also act as a type guard. 88 | 89 | ```typescript 90 | const shapes: Shape[] = [circle, square] 91 | const sideLengths = shapes.filter(Square.is).map(square => square.value.sideLength) 92 | ``` 93 | 94 | You can also create custom implementations using the `tag()` and `predicate()` helper functions. 95 | 96 | ```typescript 97 | import { predicate, tag } from "@practical-fp/union-types" 98 | 99 | const Circle = (radius: number) => tag("Circle", { radius }) 100 | const isCircle = predicate("Circle") 101 | 102 | const Square = (sideLength: number) => tag("Square", { sideLength }) 103 | const isSquare = predicate("Square") 104 | ``` 105 | 106 | ### Matching against a union 107 | 108 | ```typescript 109 | import { matchExhaustive } from "@practical-fp/union-types" 110 | 111 | function getArea(shape: Shape) { 112 | return matchExhaustive(shape, { 113 | Circle: ({ radius }) => Math.PI * radius ** 2, 114 | Square: ({ sideLength }) => sideLength ** 2, 115 | }) 116 | } 117 | ``` 118 | 119 | `matchExhaustive()` is exhaustive, i.e., you need to match against every variant of the union. 120 | Cases can be omitted when using a wildcard case with `matchWildcard()`. 121 | 122 | ```typescript 123 | import { matchWildcard, WILDCARD } from "@practical-fp/union-types" 124 | 125 | function getDiameter(shape: Shape) { 126 | return matchWildcard(shape, { 127 | Circle: ({ radius }) => radius * 2, 128 | [WILDCARD]: () => undefined, 129 | }) 130 | } 131 | ``` 132 | 133 | `switch`-statements can also be used to match against a union. 134 | 135 | ```typescript 136 | import { assertNever } from "@practical-fp/union-types" 137 | 138 | function getArea(shape: Shape) { 139 | switch (shape.tag) { 140 | case "Circle": 141 | return Math.PI * shape.value.radius ** 2 142 | case "Square": 143 | return shape.value.sideLength ** 2 144 | default: 145 | // exhaustiveness check 146 | // compile-time error if a case is missing 147 | assertNever(shape) 148 | } 149 | } 150 | ``` 151 | 152 | ### Generics 153 | `impl<>()` and `constructor<>()` also support generic union types. 154 | 155 | In case the variant type uses unconstrained generics, 156 | `unknown` needs to be passed as its type arguments. 157 | 158 | ```typescript 159 | import { impl, Variant } from "@practical-fp/union-types" 160 | 161 | type Result = 162 | | Variant<"Ok", T> 163 | | Variant<"Err", E> 164 | 165 | const { Ok, Err } = impl>() 166 | ``` 167 | 168 | In case the variant type uses constrained generics, 169 | the constraint type needs to be passed as its type arguments. 170 | 171 | ```typescript 172 | import { impl, Variant } from "@practical-fp/union-types" 173 | 174 | type Result = 175 | | Variant<"Ok", T> 176 | | Variant<"Err", E> 177 | 178 | const { Ok, Err } = impl>() 179 | ``` 180 | 181 | ### `strictImpl<>()` and `strictConstructor<>()` 182 | `impl<>()` and `constructor<>()` generate generic constructor functions. 183 | This may not always be desirable. 184 | 185 | ```typescript 186 | import { impl } from "@practical-fp/union-types" 187 | 188 | const { Circle } = impl() 189 | const circle = Circle({ 190 | radius: 5, 191 | color: "red", 192 | }) 193 | ``` 194 | 195 | Since `Circle` is generic, it's perfectly fine to pass extra properties other than `radius`. 196 | 197 | To prevent that, we can use `strictImpl<>()` or `strictConstructor<>()` to create a strict 198 | implementation which is not generic. 199 | 200 | ```typescript 201 | import { strictImpl } from "@practical-fp/union-types" 202 | 203 | const { Circle } = strictImpl() 204 | const circle = Circle({ 205 | radius: 5, 206 | color: "red", // compile error 207 | }) 208 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@practical-fp/union-types", 3 | "version": "1.5.1", 4 | "description": "A Typescript library for creating discriminating union types.", 5 | "keywords": [ 6 | "adt", 7 | "algebraic", 8 | "discriminating", 9 | "enum", 10 | "ts", 11 | "type", 12 | "typescript", 13 | "union" 14 | ], 15 | "author": "Felix Schorer", 16 | "repository": "https://github.com/practical-fp/union-types.git", 17 | "license": "MIT", 18 | "sideEffects": false, 19 | "main": "dist/cjs/index.js", 20 | "module": "dist/esm/index.js", 21 | "typings": "dist/typings/index.d.ts", 22 | "files": [ 23 | "dist", 24 | "src" 25 | ], 26 | "scripts": { 27 | "tsc:cjs": "tsc --module commonjs --outDir dist/cjs", 28 | "tsc:esm": "tsc --module es6 --outDir dist/esm", 29 | "tsc:typings": "tsc --emitDeclarationOnly --declaration --declarationMap --outDir dist/typings", 30 | "clean": "rimraf dist", 31 | "build": "npm run clean && npm run tsc:cjs && npm run tsc:esm && npm run tsc:typings", 32 | "test": "jest", 33 | "prepublishOnly": "npm run test & npm run build" 34 | }, 35 | "jest": { 36 | "preset": "ts-jest", 37 | "testEnvironment": "node" 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^25.2.3", 41 | "conditional-type-checks": "^1.0.5", 42 | "jest": "^25.5.4", 43 | "prettier": "^2.1.2", 44 | "rimraf": "^3.0.2", 45 | "ts-jest": "^25.5.1", 46 | "typescript": "~3.5.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A type which discriminates on {@link tag} 3 | * when used in a union with other instances of this type. 4 | * 5 | * @example 6 | * type Union = 7 | * | Variant<"1"> 8 | * | Variant<"2", number> 9 | */ 10 | export interface Variant { 11 | readonly tag: Tag 12 | readonly value: Value 13 | } 14 | 15 | /** 16 | * Utility type which allows any {@link Variant} to be assigned to it. 17 | */ 18 | export type AnyVariant = Variant 19 | 20 | /** 21 | * Creates a new {@link Variant} instance whose value is undefined. 22 | * @param tag 23 | */ 24 | export function tag(tag: Tag): Variant 25 | 26 | /** 27 | * Creates a new {@link Variant} instance. 28 | * @param tag 29 | * @param value 30 | */ 31 | export function tag(tag: Tag, value: Value): Variant 32 | export function tag(tag: string, value?: unknown): AnyVariant { 33 | return { 34 | tag, 35 | value, 36 | } 37 | } 38 | 39 | /** 40 | * Extracts the value form a @link Variant} instance. 41 | * @param variant 42 | */ 43 | export function untag(variant: Variant): Value { 44 | return variant.value 45 | } 46 | 47 | /** 48 | * Utility type for extracting the possible values for {@link Variant#tag} 49 | * from a union of {@link Variant}s. 50 | * 51 | * @example 52 | * type Union = 53 | * | Variant<"1"> 54 | * | Variant<"2"> 55 | * 56 | * // Equals: "1" | "2" 57 | * type UnionTags = Tags 58 | */ 59 | export type Tags = Var["tag"] 60 | 61 | /** 62 | * Utility type for extracting the possible types for {@link Variant#value} 63 | * from a union of {@link Variant}s. 64 | * 65 | * @example 66 | * type Union = 67 | * | Variant<"1", string> 68 | * | Variant<"2", number> 69 | * 70 | * // Equals: string | number 71 | * type UnionValues = Values 72 | */ 73 | export type Values = Var["value"] 74 | 75 | /** 76 | * Utility type for narrowing down a union of {@link Variant}s based on their tags. 77 | * 78 | * @example 79 | * type Union = 80 | * | Variant<"1", 1> 81 | * | Variant<"2", 2> 82 | * | Variant<"3", 3> 83 | * 84 | * // Equals: Variant<"1", 1> | Variant<"3", 3> 85 | * type Narrowed = Narrow 86 | */ 87 | export type Narrow> = Extract< 88 | Var, 89 | Variant 90 | > 91 | 92 | /** 93 | * Type guard for narrowing down the type of a {@link Variant}. 94 | * @param variant 95 | * @param tag 96 | * @example 97 | * type Union = 98 | * | Variant<"1", number> 99 | * | Variant<"2", string> 100 | * 101 | * function doSomething(union: Union) { 102 | * // union.value has type number | string 103 | * 104 | * if (hasTag(union, "1")) { 105 | * // union.value has type number now 106 | * } 107 | * } 108 | */ 109 | export function hasTag>( 110 | variant: Var, 111 | tag: Tag, 112 | ): variant is Narrow { 113 | return variant.tag === tag 114 | } 115 | 116 | /** 117 | * Type of a function which narrows down the type of a given {@link Variant}. 118 | */ 119 | export type Predicate = ( 120 | variant: Var, 121 | ) => variant is Narrow 122 | 123 | /** 124 | * Factory function for creating a type guard which narrows down the type of a {@link Variant}. 125 | * @param tag 126 | * @example 127 | * type Union = 128 | * | Variant<"1", number> 129 | * | Variant<"2", string> 130 | * 131 | * function doSomething(list: Union[]) { 132 | * // filtered has type Variant<"1", number>[] 133 | * const filtered = list.filter(predicate("1")) 134 | * } 135 | */ 136 | export function predicate(tag: Tag): Predicate { 137 | return (variant: Var): variant is Narrow => 138 | hasTag(variant, tag) 139 | } 140 | 141 | /** 142 | * Symbol for declaring a wildcard case in a {@link match} expression. 143 | */ 144 | export const WILDCARD = Symbol("Match Wildcard") 145 | 146 | /** 147 | * Utility type for ensuring that a {@link matchExhaustive} expression covers all cases. 148 | */ 149 | export type CasesExhaustive = { 150 | [Tag in Tags]: (value: Values>) => Ret 151 | } 152 | 153 | /** 154 | * Utility type for enabling a {@link matchWildcard} expression to cover only some cases, 155 | * as long as, a wildcard case is declared for matching the remaining cases. 156 | */ 157 | export type CasesWildcard = Partial< 158 | CasesExhaustive 159 | > & { [WILDCARD]: () => Ret } 160 | 161 | /** 162 | * Utility type for ensuring that a {@link match} expression either covers all cases, 163 | * or contains a wildcard for matching the remaining cases. 164 | */ 165 | export type Cases = 166 | | CasesExhaustive 167 | | CasesWildcard 168 | 169 | /** 170 | * Utility type for inferring the return type of a {@link match} expression. 171 | */ 172 | export type CasesReturn> = C extends Cases< 173 | Var, 174 | infer Ret 175 | > 176 | ? Ret 177 | : never 178 | 179 | /** 180 | * Internal helper type which accepts any Cases object. 181 | */ 182 | interface AnyCases { 183 | [tag: string]: ((value: unknown) => unknown) | undefined 184 | [WILDCARD]?: () => unknown 185 | } 186 | 187 | /** 188 | * Function for matching on the tag of a {@link Variant}. 189 | * All possible cases need to be covered, unless a wildcard case is present. 190 | * @param variant 191 | * @param cases 192 | * @example 193 | * type Union = 194 | * | Variant<"Num", number> 195 | * | Variant<"Str", string> 196 | * | Variant<"Bool", boolean> 197 | * 198 | * function doSomething(union: Union) { 199 | * return match(union, { 200 | * Num: number => number * number, 201 | * Str: string => `Hello, ${string}!`, 202 | * Bool: boolean => !boolean, 203 | * }) 204 | * } 205 | * 206 | * function doSomethingElse(union: Union) { 207 | * return match(union, { 208 | * Str: string => `Hello, ${string}!`, 209 | * [WILDCARD]: () => "Hello there!", 210 | * }) 211 | * } 212 | * @deprecated Use {@link matchExhaustive} or {@link matchWildcard} instead. 213 | */ 214 | export function match>( 215 | variant: Var, 216 | cases: C, 217 | ): CasesReturn 218 | export function match(variant: AnyVariant, cases: AnyCases): unknown { 219 | const caseFn = cases[variant.tag] 220 | if (caseFn) { 221 | return caseFn(variant.value) 222 | } 223 | const wildcardFn = cases[WILDCARD] 224 | if (wildcardFn) { 225 | return wildcardFn() 226 | } 227 | throw new Error(`No case matched tag ${tag}.`) 228 | } 229 | 230 | /** 231 | * Helper type to restrict the possible keys of a type. 232 | * 233 | * This is useful for {@link matchExhaustive} and {@link matchWildcard} where the cases argument 234 | * needs to be generic to infer the correct return type. 235 | * However, due to the argument being generic it is allowed to pass extra properties. 236 | * Passing extra arguments is probably a spelling mistake. 237 | * Therefore, we restrict the properties by setting extra properties to never. 238 | * 239 | * Typescript 4.2 will show a nice hint asking whether you've misspelled the property name. 240 | */ 241 | export type ValidateProperties = { 242 | [_ in Exclude]: never 243 | } 244 | 245 | /** 246 | * Function for matching on the tag of a {@link Variant}. 247 | * All possible cases need to be covered. 248 | * @param variant 249 | * @param cases 250 | * @example 251 | * type Union = 252 | * | Variant<"Num", number> 253 | * | Variant<"Str", string> 254 | * | Variant<"Bool", boolean> 255 | * 256 | * function doSomething(union: Union) { 257 | * return matchExhaustive(union, { 258 | * Num: number => number * number, 259 | * Str: string => `Hello, ${string}!`, 260 | * Bool: boolean => !boolean, 261 | * }) 262 | * } 263 | */ 264 | export function matchExhaustive>( 265 | variant: Var, 266 | cases: Cases & ValidateProperties>, 267 | ): CasesReturn { 268 | return match(variant, cases) 269 | } 270 | 271 | /** 272 | * Function for matching on the tag of a {@link Variant}. 273 | * Not all cases need to be covered, a wildcard case needs to be present. 274 | * @param variant 275 | * @param cases 276 | * @example 277 | * type Union = 278 | * | Variant<"Num", number> 279 | * | Variant<"Str", string> 280 | * | Variant<"Bool", boolean> 281 | * 282 | * function doSomething(union: Union) { 283 | * return matchWildcard(union, { 284 | * Str: string => `Hello, ${string}!`, 285 | * [WILDCARD]: () => "Hello there!", 286 | * }) 287 | * } 288 | */ 289 | export function matchWildcard>( 290 | variant: Var, 291 | cases: Cases & ValidateProperties>, 292 | ): CasesReturn { 293 | return match(variant, cases) 294 | } 295 | 296 | /** 297 | * Utility function for asserting that all cases have been covered. 298 | * @param variant 299 | * @example 300 | * type Union = 301 | * | Variant<"1", string> 302 | * | Variant<"2", number> 303 | * 304 | * function doSomething(union: Union) { 305 | * switch(union.tag) { 306 | * case "1": 307 | * alert(union.value) 308 | * break 309 | * case "2": 310 | * alert(union.value.toFixed(0)) 311 | * break 312 | * default: 313 | * // compile error if we've forgotten a case 314 | * assertNever(union) 315 | * } 316 | * } 317 | */ 318 | export function assertNever(variant: never): never { 319 | throw new Error("Unreachable state reached!") 320 | } 321 | 322 | /** 323 | * Type which specifies the constructor for a variant type. 324 | */ 325 | export type Constructor = ( 326 | value: Value extends undefined ? T | void : T, 327 | ) => Variant 328 | 329 | /** 330 | * Type which specifies the strict constructor for a variant type. 331 | * It does not support generics. 332 | */ 333 | export type StrictConstructor = ( 334 | value: Value extends undefined ? Value | void : Value, 335 | ) => Variant 336 | 337 | /** 338 | * Type which specifies the extra properties which are attached to a constructor. 339 | */ 340 | export interface ConstructorExtra { 341 | tag: Tag 342 | is: Predicate 343 | } 344 | 345 | /** 346 | * Type which specifies the constructor for a variant type with attached type guard. 347 | */ 348 | export type ConstructorWithExtra = Constructor & 349 | ConstructorExtra 350 | 351 | /** 352 | * Type which specifies the strict constructor for a variant type with attached type guard. 353 | * It does not support generics. 354 | */ 355 | export type StrictConstructorWithExtra = StrictConstructor & 356 | ConstructorExtra 357 | 358 | /** 359 | * Function for creating a constructor for the given variant. 360 | * 361 | * In case the variant type uses unconstrained generics, 362 | * pass unknown as its type arguments. 363 | * 364 | * In case the variant type uses constrained generics, 365 | * pass the constraint type as its type arguments. 366 | * 367 | * Use {@link impl} instead if your environment has support for {@link Proxy}. 368 | * 369 | * @example 370 | * type Result = 371 | * | Variant<"Ok", T> 372 | * | Variant<"Err", E> 373 | * 374 | * const Ok = constructor, "Ok">("Ok") 375 | * const Err = constructor, "Err">("Err") 376 | * 377 | * let result: Result 378 | * result = Ok(42) 379 | * result = Err("Something went wrong") 380 | * 381 | * Ok.is(result) // false 382 | * Err.is(result) // true 383 | * 384 | * Ok.tag // "Ok" 385 | * Err.tag // "Err" 386 | */ 387 | export function constructor>( 388 | tagName: Tag, 389 | ): ConstructorWithExtra>> { 390 | function constructor(value: T) { 391 | return tag(tagName, value) 392 | } 393 | 394 | constructor.tag = tagName 395 | constructor.is = predicate(tagName) 396 | return constructor 397 | } 398 | 399 | /** 400 | * Same as {@link constructor}, but does not support generics. 401 | * @param tagName 402 | */ 403 | export function strictConstructor>( 404 | tagName: Tag, 405 | ): StrictConstructorWithExtra>> { 406 | return constructor(tagName) 407 | } 408 | 409 | /** 410 | * Type which specifies constructors and type guards for a variant type. 411 | */ 412 | export type Impl = { 413 | [Tag in Tags]: ConstructorWithExtra>> 414 | } 415 | 416 | /** 417 | * Type which specifies strict constructors and type guards for a variant type. 418 | * It does not support generics. 419 | */ 420 | export type StrictImpl = { 421 | [Tag in Tags]: StrictConstructorWithExtra>> 422 | } 423 | 424 | /** 425 | * Function for generating an implementation for the given variants. 426 | * 427 | * In case the variant type uses unconstrained generics, 428 | * pass unknown as its type arguments. 429 | * 430 | * In case the variant type uses constrained generics, 431 | * pass the constraint type as its type arguments. 432 | * 433 | * @example 434 | * type Result = 435 | * | Variant<"Ok", T> 436 | * | Variant<"Err", E> 437 | * 438 | * const {Ok, Err} = impl>() 439 | * 440 | * let result: Result 441 | * result = Ok(42) 442 | * result = Err("Something went wrong") 443 | * 444 | * Ok.is(result) // false 445 | * Err.is(result) // true 446 | * 447 | * Ok.tag // "Ok" 448 | * Err.tag // "Err" 449 | */ 450 | export function impl(): Impl { 451 | return new Proxy({} as Impl, { 452 | get: >(_: Impl, tagName: Tag) => { 453 | return constructor(tagName) 454 | }, 455 | }) 456 | } 457 | 458 | /** 459 | * Same as {@link impl}, but does not support generics. 460 | */ 461 | export function strictImpl(): StrictImpl { 462 | return impl() 463 | } 464 | -------------------------------------------------------------------------------- /test/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, IsExact } from "conditional-type-checks" 2 | import { 3 | assertNever, 4 | constructor, 5 | Constructor, 6 | hasTag, 7 | impl, 8 | match, 9 | matchExhaustive, 10 | matchWildcard, 11 | Narrow, 12 | predicate, 13 | tag, 14 | Tags, 15 | untag, 16 | Values, 17 | Variant, 18 | WILDCARD, 19 | } from "../src" 20 | 21 | test("Tags should extract the tag of a variant", () => { 22 | type Var = Variant<"1"> 23 | type Actual = Tags 24 | type Expected = "1" 25 | assert>(true) 26 | }) 27 | 28 | test("Tags should extract all tags of a union", () => { 29 | type Union = Variant<"1"> | Variant<"2"> 30 | type Actual = Tags 31 | type Expected = "1" | "2" 32 | assert>(true) 33 | }) 34 | 35 | test("Values should extract the value of a variant", () => { 36 | type Var = Variant<"1", number> 37 | type Actual = Values 38 | type Expected = number 39 | assert>(true) 40 | }) 41 | 42 | test("Values should extract all values of a union", () => { 43 | type Union = Variant<"1", number> | Variant<"2"> 44 | 45 | type Actual = Values 46 | type Expected = number | undefined 47 | assert>(true) 48 | }) 49 | 50 | test("Narrow should narrow a union down to a single Variant", () => { 51 | type Var1 = Variant<"1", number> 52 | type Var2 = Variant<"2", string> 53 | type Union = Var1 | Var2 54 | 55 | type Actual = Narrow 56 | type Expected = Var1 57 | assert>(true) 58 | }) 59 | 60 | test("Narrow should work with extended Variants", () => { 61 | interface Var1 extends Variant<"1", number> { 62 | someProp: boolean 63 | } 64 | 65 | type Var2 = Variant<"2", string> 66 | type Union = Var1 | Var2 67 | 68 | type Actual = Narrow 69 | type Expected = Var1 70 | assert>(true) 71 | }) 72 | 73 | test("predicate should be a type guard", () => { 74 | type Var1 = Variant<"1", number> 75 | type Var2 = Variant<"2", string> 76 | type Union = Var1 | Var2 77 | 78 | const result = new Array().filter(predicate("1")) 79 | 80 | type Actual = typeof result 81 | type Expected = Var1[] 82 | assert>(true) 83 | }) 84 | 85 | test("predicate should work with unrelated tags", () => { 86 | type Var1 = Variant<"1", number> 87 | type Var2 = Variant<"2", string> 88 | type Union = Var1 | Var2 89 | 90 | const result = new Array().filter(predicate("Unrelated")) 91 | 92 | type Actual = typeof result 93 | type Expected = never[] 94 | assert>(true) 95 | }) 96 | 97 | test("match should infer the return value", () => { 98 | type Union = Variant<"1", number> | Variant<"2"> 99 | 100 | const result = match({} as Union, { 101 | 1: number => number, 102 | 2: () => undefined, 103 | [WILDCARD]: () => true, 104 | }) 105 | 106 | type Actual = typeof result 107 | type Expected = number | undefined | boolean 108 | assert>(true) 109 | }) 110 | 111 | test("the Constructor function should be unbounded generic", () => { 112 | type Actual = Constructor<"1", unknown> 113 | type Expected = (value: T) => Variant<"1", T> 114 | assert>(true) 115 | }) 116 | 117 | test("the Constructor function should be bounded to number", () => { 118 | type Actual = Constructor<"1", number> 119 | type Expected = (value: T) => Variant<"1", T> 120 | assert>(true) 121 | }) 122 | 123 | test("the Constructor function argument should be optional", () => { 124 | type Actual = Constructor<"1", number | undefined> 125 | type Expected = (value: T | void) => Variant<"1", T> 126 | assert>(true) 127 | }) 128 | 129 | test("tag should tag objects", () => { 130 | const tagged = tag("Test", { number: 42 }) 131 | expect(hasTag(tagged, "Test")).toBe(true) 132 | }) 133 | 134 | test("tag should create tagged objects", () => { 135 | const tagged = tag("Test") 136 | expect(hasTag(tagged, "Test")).toBe(true) 137 | }) 138 | 139 | test("untag should extract the value", () => { 140 | const tagged = tag("Test", { number: 42 }) 141 | expect(untag(tagged)).toEqual({ number: 42 }) 142 | }) 143 | 144 | test("hasTag should test whether a tagged object has a certain tag", () => { 145 | const tagged = tag("Test") 146 | expect(hasTag(tagged, "Test")).toBe(true) 147 | expect(hasTag(tagged, "NotTest")).toBe(false) 148 | }) 149 | 150 | test("predicate should test whether a tagged object has a certain tag", () => { 151 | const isTest = predicate("Test") 152 | const isNotTest = predicate("NotTest") 153 | const tagged = tag("Test") 154 | expect(isTest(tagged)).toBe(true) 155 | expect(isNotTest(tagged)).toBe(false) 156 | }) 157 | 158 | test("match should call the matching handler", () => { 159 | const result = matchWildcard(tag("Test"), { 160 | Test: () => true, 161 | [WILDCARD]: () => false, 162 | }) 163 | expect(result).toBe(true) 164 | }) 165 | 166 | test("match should call the wildcard", () => { 167 | const result = matchWildcard(tag("Test"), { 168 | [WILDCARD]: () => true, 169 | }) 170 | expect(result).toBe(true) 171 | }) 172 | 173 | test("match should throw an error when an unexpected tag is encountered", () => { 174 | const throws = () => { 175 | matchExhaustive(tag("Test"), {} as any) 176 | } 177 | expect(throws).toThrow(Error) 178 | }) 179 | 180 | test("assertNever should throw an error", () => { 181 | const throws = () => { 182 | assertNever(undefined as never) 183 | } 184 | expect(throws).toThrow(Error) 185 | }) 186 | 187 | test("constructor should construct a tagged value", () => { 188 | type Union = Variant<"1", number> | Variant<"2"> 189 | const ctor = constructor("1") 190 | expect(ctor(42)).toEqual({ tag: "1", value: 42 }) 191 | }) 192 | 193 | test("impl should construct a tagged value", () => { 194 | type Union = Variant<"1", number> | Variant<"2"> 195 | const Union = impl() 196 | expect(Union[1](42)).toEqual({ tag: "1", value: 42 }) 197 | }) 198 | 199 | test("impl should construct an empty tagged value", () => { 200 | type Union = Variant<"1", number> | Variant<"2"> 201 | const Union = impl() 202 | expect(Union[2]()).toEqual({ tag: "2", value: undefined }) 203 | }) 204 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "moduleResolution": "node", 5 | "esModuleInterop": true, 6 | "target": "es5", 7 | "sourceMap": true, 8 | "lib": ["ES6"] 9 | }, 10 | "exclude": [ 11 | "node_modules", 12 | "dist", 13 | "test" 14 | ] 15 | } --------------------------------------------------------------------------------