├── .npmrc ├── .gitignore ├── .prettierrc.json ├── tsconfig.build-es6.json ├── tsconfig.build.json ├── tsconfig.json ├── .github └── workflows │ ├── main.yml │ └── publish.yml ├── .eslintrc.json ├── LICENSE ├── package.json ├── test ├── helpers │ └── vitest-fp-ts.ts └── index.test.ts ├── src └── index.ts └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | lockfile-version = 1 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | es6/ 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.build-es6.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "compilerOptions": { 4 | "outDir": "./es6", 5 | "declaration": false, 6 | "module": "es6" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "outDir": "./lib", 6 | "declaration": true, 7 | "module": "commonjs" 8 | }, 9 | "include": ["./src/**/*"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "esModuleInterop": true, 5 | "module": "commonjs", 6 | "noImplicitReturns": false, 7 | "noUnusedLocals": true, 8 | "noUnusedParameters": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "strict": true, 11 | "target": "es5", 12 | "moduleResolution": "node", 13 | "forceConsistentCasingInFileNames": true, 14 | "stripInternal": true, 15 | "lib": ["es2015"], 16 | "skipLibCheck": true 17 | }, 18 | "include": ["./src/**/*", "./test/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # npm test workflow 2 | name: Node.js CI 3 | 4 | on: 5 | push: 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [16.17.1, 20.x, 22.x, 24.x] 14 | io-ts-version: [~2.0.0, ~2.1.0, ~2.2.0] 15 | 16 | steps: 17 | - uses: actions/checkout@v5 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v5 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: 'npm' 23 | - run: npm install 24 | - run: npm install --no-save io-ts@${{ matrix.io-ts-version }} 25 | - run: npm run build --if-present 26 | - run: npm test 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | id-token: write # Required for OIDC authentication 10 | contents: read 11 | 12 | jobs: 13 | publish: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v5 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v5 20 | with: 21 | node-version: '20' 22 | registry-url: 'https://registry.npmjs.org' 23 | 24 | # Ensure npm 11.5.1 or later for trusted publishing 25 | - run: npm install -g npm@latest 26 | 27 | - run: npm ci 28 | - run: npm run build 29 | - run: npm test 30 | - run: npm publish 31 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "project": "./tsconfig.json" 4 | }, 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "rules": { 11 | "@typescript-eslint/array-type": [ 12 | "warn", 13 | { 14 | "default": "generic", 15 | "readonly": "generic" 16 | } 17 | ], 18 | "@typescript-eslint/prefer-readonly": "warn", 19 | "@typescript-eslint/member-delimiter-style": 0, 20 | "@typescript-eslint/no-non-null-assertion": "off", 21 | "@typescript-eslint/ban-types": "off", 22 | "@typescript-eslint/no-explicit-any": "off", 23 | "@typescript-eslint/no-empty-interface": "off", 24 | "no-unused-vars": "off", 25 | "@typescript-eslint/no-unused-vars": [ 26 | "error", 27 | { 28 | "argsIgnorePattern": "^_" 29 | } 30 | ], 31 | "@typescript-eslint/prefer-as-const": "off", 32 | "@typescript-eslint/ban-ts-comment": "off", 33 | "prefer-rest-params": "off", 34 | "prefer-spread": "off" 35 | } 36 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Axel Havukangas 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "io-ts-promise", 3 | "version": "3.0.0", 4 | "description": "io-ts for developers who like Promises", 5 | "keywords": [ 6 | "io-ts", 7 | "promise" 8 | ], 9 | "files": [ 10 | "lib", 11 | "es6" 12 | ], 13 | "main": "lib/index.js", 14 | "module": "es6/index.js", 15 | "typings": "lib/index.d.ts", 16 | "sideEffects": false, 17 | "scripts": { 18 | "test": "npm run lint && npm run test:js", 19 | "lint": "npm run lint:prettier && npm run lint:eslint && npm run lint:typescript", 20 | "lint:prettier": "prettier --list-different 'src/*.ts' 'test/*.ts'", 21 | "lint:eslint": "eslint 'src/*.ts' 'test/*.ts'", 22 | "lint:typescript": "tsc --noEmit", 23 | "test:js": "vitest run", 24 | "build": "npm run build:cjs && npm run build:es6", 25 | "build:cjs": "tsc -p ./tsconfig.build.json", 26 | "build:es6": "tsc -p ./tsconfig.build-es6.json", 27 | "installPeerDependencies": "npm install --no-save io-ts@2.x fp-ts@2.x", 28 | "prepublish": "npm run installPeerDependencies && npm run build" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/aeirola/io-ts-promise.git" 33 | }, 34 | "author": "Axel Havukangas ", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/aeirola/io-ts-promise/issues" 38 | }, 39 | "homepage": "https://github.com/aeirola/io-ts-promise", 40 | "peerDependencies": { 41 | "fp-ts": "2.x", 42 | "io-ts": "2.x" 43 | }, 44 | "devDependencies": { 45 | "@types/deep-equal": "1.0.1", 46 | "@typescript-eslint/eslint-plugin": "^5.59.0", 47 | "@typescript-eslint/parser": "^5.59.0", 48 | "eslint": "^8.38.0", 49 | "prettier": "^2.7.1", 50 | "typescript": "~5.1.3", 51 | "vite": "^4.3.3", 52 | "vitest": "^0.31.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /test/helpers/vitest-fp-ts.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'vitest'; 2 | import * as Either from 'fp-ts/lib/Either'; 3 | 4 | interface FpTsMatchers { 5 | toEqualLeft(expected: unknown): R; 6 | toEqualRight(expected: unknown): R; 7 | } 8 | 9 | declare module 'vitest' { 10 | interface Assertion extends FpTsMatchers {} 11 | interface AsymmetricMatchersContaining extends FpTsMatchers {} 12 | } 13 | 14 | function isObject(value: unknown): value is object { 15 | return typeof value === 'object' && value !== null; 16 | } 17 | 18 | function isEither(value: unknown): value is Either.Either { 19 | if (!isObject(value)) { 20 | return false; 21 | } 22 | return Either.isLeft(value as any) || Either.isRight(value as any); 23 | } 24 | 25 | expect.extend({ 26 | toEqualLeft(received: unknown, expected: unknown) { 27 | const { equals } = this; 28 | 29 | if (!isEither(received)) { 30 | return { 31 | pass: false, 32 | message: () => 'expected value to be an instance of Either', 33 | actual: received, 34 | expected: Either.left(expected), 35 | }; 36 | } 37 | 38 | if (Either.isRight(received)) { 39 | return { 40 | pass: false, 41 | message: () => 'expected value to be Left, but got Right', 42 | actual: received, 43 | expected: Either.left(expected), 44 | }; 45 | } 46 | 47 | const pass = equals(received.left, expected); 48 | 49 | return { 50 | pass, 51 | message: () => 'expected Left value to equal', 52 | actual: received.left, 53 | expected, 54 | }; 55 | }, 56 | 57 | toEqualRight(received: unknown, expected: unknown) { 58 | const { equals } = this; 59 | 60 | if (!isEither(received)) { 61 | return { 62 | pass: false, 63 | message: () => 'expected value to be an instance of Either', 64 | actual: received, 65 | expected: Either.right(expected), 66 | }; 67 | } 68 | 69 | if (Either.isLeft(received)) { 70 | return { 71 | pass: false, 72 | message: () => 'expected value to be Right, but got Left', 73 | actual: received, 74 | expected: Either.right(expected), 75 | }; 76 | } 77 | 78 | const pass = equals(received.right, expected); 79 | 80 | return { 81 | pass, 82 | message: () => 'expected Right value to equal', 83 | actual: received.right, 84 | expected, 85 | }; 86 | }, 87 | }); 88 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Either from 'fp-ts/lib/Either'; 2 | import * as t from 'io-ts'; 3 | import { PathReporter } from 'io-ts/lib/PathReporter'; 4 | 5 | /** 6 | * Creates a function which takes incoming values and decodes them with the given io-ts type, 7 | * returning a promise reflecting the result. 8 | * 9 | * @param type io-ts type to use for decoding incoming values. 10 | */ 11 | export function decode( 12 | type: t.Decoder, 13 | ): (value: Input) => Promise; 14 | /** 15 | * Decodes values using io-ts types, returning a promise reflecting the result. 16 | * 17 | * @param type io-ts type to use for decoding the value. 18 | * @param value Value to decode using the given io-ts type. 19 | */ 20 | export function decode( 21 | type: t.Decoder, 22 | value: Input, 23 | ): Promise; 24 | export function decode( 25 | type: t.Decoder, 26 | value?: Input, 27 | ): ((value: Input) => Promise) | Promise { 28 | switch (arguments.length) { 29 | case 0: 30 | throw new Error('Function called with no arguments'); 31 | case 1: 32 | return decode.bind< 33 | null, 34 | [t.Decoder], 35 | [Input], 36 | Promise 37 | >(null, type); 38 | default: 39 | return Either.fold>( 40 | (errors) => Promise.reject(new DecodeError(errors)), 41 | (decodedValue) => Promise.resolve(decodedValue), 42 | )(type.decode(value || arguments[1])); 43 | } 44 | } 45 | 46 | /** 47 | * Checks whether error was produced by @see decode due to invalid data. 48 | */ 49 | export function isDecodeError(error: unknown): error is DecodeError { 50 | return error instanceof DecodeError; 51 | } 52 | 53 | /** 54 | * Custom error class which is rejected by the @see decode function 55 | * when decoding fails due to invalid data. 56 | */ 57 | export class DecodeError extends Error { 58 | public name = 'DecodeError'; 59 | public errors: t.Errors; 60 | 61 | constructor(errors: t.Errors) { 62 | super(PathReporter.report(t.failures(errors)).join('\n')); 63 | this.errors = errors; 64 | Object.setPrototypeOf(this, DecodeError.prototype); 65 | } 66 | } 67 | 68 | /** 69 | * Creates a new io-ts type from given decode and encode functions. 70 | * 71 | * @param decode Function that transforms unknown values to desired type, 72 | * or throws an error if the tranformation is not supported. 73 | * @param encode Function that transforms decoded values back to the original encoded format. 74 | * @param is Type guard function that checks if a value is of the decoded type. 75 | * @param name Optional name of the type, making decoding errors more informative. 76 | */ 77 | export function createType( 78 | decode: (encodedValue: unknown) => Output, 79 | encode: (decodedValue: Output) => unknown, 80 | is: t.Is, 81 | name?: string, 82 | ): t.Type { 83 | return extendType(t.unknown, decode, encode, is, name); 84 | } 85 | 86 | /** 87 | * Extends an existing io-ts type, mapping the output value using the decode and encode functions. 88 | * 89 | * @param baseType The io-ts type to extend. 90 | * @param decode Function to transform output of `baseType` to desired value, 91 | * or throws an error if the transformation is not supported. 92 | * @param encode Function to transform decoded type to back to `baseType` output. 93 | * @param is Type guard function that checks if a value is of the decoded type. 94 | * @param name Optional name of the type, making decoding errors more informative. 95 | */ 96 | export function extendType( 97 | baseType: t.Type, 98 | decode: (encodedValue: Input) => Output, 99 | encode: (decodedValue: Output) => Input, 100 | is: t.Is, 101 | name?: string, 102 | ): t.Type { 103 | const extendedDecoder = extendDecoder(baseType, decode); 104 | 105 | const typeEncode = (outputValue: Output): unknown => { 106 | return baseType.encode(encode(outputValue)); 107 | }; 108 | 109 | return new t.Type( 110 | name || extendedDecoder.name, 111 | is, 112 | extendedDecoder.validate, 113 | typeEncode, 114 | ); 115 | } 116 | 117 | /** 118 | * Creates a new decoder from decode and function. 119 | * 120 | * @param decode Function that transforms unknown values to desired type, 121 | * or throws an error if the tranformation is not supported. 122 | * @param name Optional name of the type, making decoding errors more informative. 123 | */ 124 | export function createDecoder( 125 | decode: (value: unknown) => Output, 126 | name?: string, 127 | ): t.Decoder { 128 | return extendDecoder(t.unknown, decode, name); 129 | } 130 | 131 | /** 132 | * Extends an existing decoder, or io-ts type, mapping the output value using the decode function. 133 | * 134 | * @param baseDecoder The decoder, or io-ts type, to extend. 135 | * @param decode Function to transform output of `baseDecoder` to desired value, 136 | * or throws an error if the transformation is not supported. 137 | * @param name Optional name of the type, making decoding errors more informative. 138 | */ 139 | export function extendDecoder( 140 | baseDecoder: t.Decoder, 141 | decode: (value: Input) => Output, 142 | name?: string, 143 | ): t.Decoder { 144 | const validate: t.Validate = ( 145 | value: unknown, 146 | context: t.Context, 147 | ) => { 148 | return Either.flatMap( 149 | baseDecoder.validate(value, context), 150 | (chainedValue) => { 151 | try { 152 | return t.success(decode(chainedValue)); 153 | } catch (e) { 154 | if (e instanceof Error) { 155 | return t.failure(value, context, e.message || undefined); 156 | } else { 157 | return t.failure(value, context); 158 | } 159 | } 160 | }, 161 | ); 162 | }; 163 | 164 | return new Decoder( 165 | name || `${baseDecoder.name}Extended`, 166 | validate, 167 | ); 168 | } 169 | 170 | /** 171 | * Helper class implementing the Decoder interface defined in io-ts. 172 | */ 173 | class Decoder extends t.Type implements t.Decoder { 174 | private static is(_: unknown): _ is any { 175 | throw new Error('Is is not implemented in a decoder'); 176 | } 177 | 178 | private static encode() { 179 | throw new Error('Encode is not implemented in a decoder'); 180 | } 181 | 182 | constructor(name: string, validate: t.Validate) { 183 | super(name, Decoder.is, validate, Decoder.encode); 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `io-ts-promise` 2 | 3 | > ℹ️ **Important** 4 | > 5 | > This package is no longer actively maintained as the io-ts package maintainer has [moved](https://dev.to/effect/a-bright-future-for-effect-455m) to work on [effect-ts](https://github.com/Effect-TS/effect). If you are looking for type validation for new projects you might want to look elsewhere, such as [effect/Schema](https://effect.website/docs/schema/introduction/) or [zod](https://zod.dev). 6 | 7 | While [`io-ts`](https://github.com/gcanti/io-ts) is a great library, it can be a bit alienating unless you are familiar with functional programming. So if you just want to ensure the runtime types for the data fetched from your API, you might be looking for something simpler. This is where `io-ts-promise` tries to help out. 8 | 9 | It provides the following: 10 | 11 | - [Promise chain decoding](#promise-chain-decoding) using `io-ts` types. 12 | - [Creating custom types](#creating-custom-types) using promise conventions. 13 | 14 | ## Usage 15 | 16 | ### Promise chain decoding 17 | 18 | Decode data from Promise based APIs, without having to worry about how to retrieve data from the [Either](https://gcanti.github.io/fp-ts/modules/Either.ts.html) values returned by the `io-ts` types. 19 | 20 | ```typescript 21 | import * as t from 'io-ts'; 22 | import * as tPromise from 'io-ts-promise'; 23 | 24 | const Person = t.type({ 25 | name: t.string, 26 | age: t.number, 27 | }); 28 | 29 | fetch('http://example.com/api/person') 30 | .then((response) => tPromise.decode(Person, response.json())) 31 | .then((typeSafeData) => 32 | console.log(`${typeSafeData.name} is ${typeSafeData.age} years old`), 33 | ); 34 | ``` 35 | 36 | `tPromise.decode` also supports currying, so we can simplify the code with 37 | 38 | ```typescript 39 | fetch('http://example.com/api/person') 40 | .then((response) => response.json()) 41 | .then(tPromise.decode(Person)) 42 | .then((typeSafeData) => 43 | console.log(`${typeSafeData.name} is ${typeSafeData.age} years old`), 44 | ); 45 | ``` 46 | 47 | As with any Promise-based API, you can also use `tPromise.decode` in async code as following 48 | 49 | ```typescript 50 | const response = await fetch('http://example.com/api/person'); 51 | const typeSafeData = await tPromise.decode(Person, response.json()); 52 | console.log(`${typeSafeData.name} is ${typeSafeData.age} years old`); 53 | ``` 54 | 55 | #### Identifying errors 56 | 57 | When building long promise chains, you might handle errors somewhere else than directly next to the function producing the error. In these cases you might want to identify the errors in order to act accordingly. Errors produced by the `decode` function due to incompatible data can be identified by either using the type guard `tPromise.isDecodeError(error)`, or checking the error type with `error instanceof tPromise.DecodeError`. For example: 58 | 59 | ```typescript 60 | fetch('http://example.com/api/not-a-person') 61 | .then((response) => response.json()) 62 | .then(tPromise.decode(Person)) 63 | .then((typeSafeData) => 64 | console.log(`${typeSafeData.name} is ${typeSafeData.age} years old`), 65 | ) 66 | .catch((error) => { 67 | if (tPromise.isDecodeError(error)) { 68 | console.error('Request failed due to invalid data.'); 69 | } else { 70 | console.error('Request failed due to network issues.'); 71 | } 72 | }); 73 | ``` 74 | 75 | ### Creating custom types 76 | 77 | Writing custom `io-ts` types is a bit cryptic, so this library provides a simpler way of extending existing `io-ts` types, or creating your own from scratch. All you need are the functions for transforming values to and from the base type, as well as a type guard function to check whether a value adheres to the type. 78 | 79 | ```typescript 80 | import * as t from 'io-ts'; 81 | import * as tPromise from 'io-ts-promise'; 82 | 83 | // New type extending from existing type 84 | const Price = tPromise.extendType( 85 | t.number, 86 | // Decode function takes in number and produces wanted type 87 | (value: number) => ({ 88 | currency: 'EUR', 89 | amount: value, 90 | }), 91 | // Encode function does the reverse 92 | (price) => price.amount, 93 | // Type guard function 94 | t.type({ currency: t.string, amount: t.number }).is, 95 | ); 96 | 97 | // And use them as part of other types 98 | const Product = t.type({ 99 | name: t.string, 100 | price: Price, 101 | }); 102 | 103 | fetch('http://example.com/api/product') 104 | .then((response) => response.json()) 105 | .then(tPromise.decode(Product)) 106 | .then((typeSafeData) => 107 | console.log( 108 | `${typeSafeData.name} costs ${typeSafeData.price.amount} ${typeSafeData.price.currency}`, 109 | ), 110 | ); 111 | ``` 112 | 113 | Or if you are working with classes instead of objects: 114 | 115 | ```typescript 116 | // New type extending from existing type 117 | const RegExpType = tPromise.extendType( 118 | t.string, 119 | // Decode function takes in string and produces class 120 | (value: string) => new RegExp(value), 121 | // Encode function does the reverse 122 | (regexp) => regexp.source, 123 | // Type guard function 124 | (value): value is RegExp => value instanceof RegExp, 125 | ); 126 | ``` 127 | 128 | Alternatively, you can define the type from scratch, in which case the decoder will receive a value of `unknown` type to decode into desired runtime type. 129 | 130 | ```typescript 131 | // Custom type from scratch 132 | const Price = tPromise.createType( 133 | // Decode function takes in unknown and produces wanted type 134 | (value: unknown) => { 135 | if (typeof value === 'number') { 136 | return { 137 | currency: 'EUR', 138 | amount: value, 139 | }; 140 | } else { 141 | throw new Error('Input is not a number'); 142 | } 143 | }, 144 | // Encode function does the reverse 145 | (price) => price.amount, 146 | // Type guard function 147 | t.type({ currency: t.string, amount: t.number }).is, 148 | ); 149 | ``` 150 | 151 | #### Decoders 152 | 153 | In case you only need to read data into your application, you can use decoders which only convert data in one way. 154 | 155 | **Note:** `io-ts` stable features doesn't support decoders in its nested types such as `t.array` or `t.type`. So only use decoders for top level data structures. The `decode` function of this library supports both types and decoders. io-ts-promise is not compatible with the new experimental features in io-ts like [Decoder](https://github.com/gcanti/io-ts/blob/master/Decoder.md). 156 | 157 | The easiest way to create a decoder is to extend an existing `io-ts` type, and only perform the desired additional modification on top of that. 158 | 159 | ```typescript 160 | import * as tPromise from 'io-ts-promise'; 161 | 162 | const Person = t.type({ 163 | name: t.string, 164 | age: t.number, 165 | }); 166 | 167 | const ExplicitPerson = tPromise.extendDecoder(Person, (person) => ({ 168 | firstName: person.name, 169 | ageInYears: person.age, 170 | })); 171 | 172 | fetch('http://example.com/api/person') 173 | .then((response) => tPromise.decode(ExplicitPerson, response.json())) 174 | .then((typeSafeData) => 175 | console.log( 176 | `${typeSafeData.firstName} is ${typeSafeData.ageInYears} years old`, 177 | ), 178 | ); 179 | ``` 180 | 181 | You can also create decoders from scratch using `createDecoder`, but I have yet to find a good example of where this would be convenient. 182 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import * as t from 'io-ts'; 4 | 5 | import './helpers/vitest-fp-ts'; 6 | 7 | import * as tPromise from '../src'; 8 | 9 | describe('io-ts-promise', () => { 10 | describe('readme examples', () => { 11 | const fetch = (url: string): Promise<{ json: () => unknown }> => { 12 | switch (url) { 13 | case 'http://example.com/api/person': 14 | return Promise.resolve({ 15 | json: () => ({ 16 | name: 'Tester', 17 | age: 24, 18 | }), 19 | }); 20 | case 'http://example.com/api/not-a-person': 21 | return Promise.resolve({ 22 | json: () => ({}), 23 | }); 24 | case 'http://example.com/api/product': 25 | return Promise.resolve({ 26 | json: () => ({ 27 | name: 'Product', 28 | price: 10, 29 | }), 30 | }); 31 | default: 32 | return Promise.reject('404'); 33 | } 34 | }; 35 | 36 | it('provides promise chain decoding', () => { 37 | const Person = t.type({ 38 | name: t.string, 39 | age: t.number, 40 | }); 41 | 42 | const result = fetch('http://example.com/api/person') 43 | .then((response) => tPromise.decode(Person, response.json())) 44 | .then( 45 | (typeSafeData) => 46 | `${typeSafeData.name} is ${typeSafeData.age} years old`, 47 | ); 48 | 49 | return expect(result).resolves.toEqual('Tester is 24 years old'); 50 | }); 51 | 52 | it('provides promise chain decoding with carrying', () => { 53 | const Person = t.type({ 54 | name: t.string, 55 | age: t.number, 56 | }); 57 | 58 | const result = fetch('http://example.com/api/person') 59 | .then((response) => response.json()) 60 | .then(tPromise.decode(Person)) 61 | .then( 62 | (typeSafeData) => 63 | `${typeSafeData.name} is ${typeSafeData.age} years old`, 64 | ); 65 | 66 | return expect(result).resolves.toEqual('Tester is 24 years old'); 67 | }); 68 | 69 | it('provides async based decoding', async () => { 70 | const Person = t.type({ 71 | name: t.string, 72 | age: t.number, 73 | }); 74 | 75 | const response = await fetch('http://example.com/api/person'); 76 | const typeSafeData = await tPromise.decode(Person, response.json()); 77 | const result = `${typeSafeData.name} is ${typeSafeData.age} years old`; 78 | 79 | expect(result).toEqual('Tester is 24 years old'); 80 | }); 81 | 82 | it('provides identification of decode errors', () => { 83 | const Person = t.type({ 84 | name: t.string, 85 | age: t.number, 86 | }); 87 | 88 | const result = fetch('http://example.com/api/not-a-person') 89 | .then((response) => response.json()) 90 | .then(tPromise.decode(Person)) 91 | .then( 92 | (typeSafeData) => 93 | `${typeSafeData.name} is ${typeSafeData.age} years old`, 94 | ) 95 | .catch((error) => { 96 | if (tPromise.isDecodeError(error)) { 97 | return 'Request failed due to invalid data.'; 98 | } else { 99 | return 'Request failed due to network issues.'; 100 | } 101 | }); 102 | 103 | return expect(result).resolves.toEqual( 104 | 'Request failed due to invalid data.', 105 | ); 106 | }); 107 | 108 | it('provides creating custom types by extending existing types', () => { 109 | // New type extending from existing type 110 | const Price = tPromise.extendType( 111 | t.number, 112 | // Decode function takes in number and produces wanted type 113 | (value: number) => ({ 114 | currency: 'EUR', 115 | amount: value, 116 | }), 117 | // Encode function does the reverse 118 | (price) => price.amount, 119 | // Type guard function 120 | t.type({ currency: t.string, amount: t.number }).is, 121 | ); 122 | 123 | // And use them as part of other types 124 | const Product = t.type({ 125 | name: t.string, 126 | price: Price, 127 | }); 128 | 129 | const result = fetch('http://example.com/api/product') 130 | .then((response) => response.json()) 131 | .then(tPromise.decode(Product)) 132 | .then( 133 | (typeSafeData) => 134 | `${typeSafeData.name} costs ${typeSafeData.price.amount} ${typeSafeData.price.currency}`, 135 | ); 136 | 137 | return expect(result).resolves.toEqual('Product costs 10 EUR'); 138 | }); 139 | 140 | it('provides creating custom class by extending existing types', () => { 141 | // New type extending from existing type 142 | const RegExpType = tPromise.extendType( 143 | t.string, 144 | // Decode function takes in string and produces class 145 | (value: string) => new RegExp(value), 146 | // Encode function does the reverse 147 | (regexp) => regexp.source, 148 | // Type guard function 149 | (value): value is RegExp => value instanceof RegExp, 150 | ); 151 | 152 | const result = tPromise 153 | .decode(RegExpType, '^test-[0-9]') 154 | .then((regExp) => regExp.exec('test-5: regexps')?.[0]); 155 | 156 | return expect(result).resolves.toEqual('test-5'); 157 | }); 158 | 159 | it('provides creating custom types from scratch', () => { 160 | // Custom type from scratch 161 | const Price = tPromise.createType( 162 | // Decode function takes in unknown and produces wanted type 163 | (value: unknown) => { 164 | if (typeof value === 'number') { 165 | return { 166 | currency: 'EUR', 167 | amount: value, 168 | }; 169 | } else { 170 | throw new Error('Input is not a number'); 171 | } 172 | }, 173 | // Encode function does the reverse 174 | (price) => price.amount, 175 | // Type guard function 176 | t.type({ currency: t.string, amount: t.number }).is, 177 | ); 178 | 179 | // And use them as part of other types 180 | const Product = t.type({ 181 | name: t.string, 182 | price: Price, 183 | }); 184 | 185 | const result = fetch('http://example.com/api/product') 186 | .then((response) => response.json()) 187 | .then(tPromise.decode(Product)) 188 | .then( 189 | (typeSafeData) => 190 | `${typeSafeData.name} costs ${typeSafeData.price.amount} ${typeSafeData.price.currency}`, 191 | ); 192 | 193 | return expect(result).resolves.toEqual('Product costs 10 EUR'); 194 | }); 195 | 196 | it('provides creating custom decoders by extending existing io-ts types', () => { 197 | const Person = t.type({ 198 | name: t.string, 199 | age: t.number, 200 | }); 201 | 202 | const ExplicitPerson = tPromise.extendDecoder(Person, (person) => ({ 203 | firstName: person.name, 204 | ageInYears: person.age, 205 | })); 206 | 207 | const result = fetch('http://example.com/api/person') 208 | .then((response) => tPromise.decode(ExplicitPerson, response.json())) 209 | .then( 210 | (typeSafeData) => 211 | `${typeSafeData.firstName} is ${typeSafeData.ageInYears} years old`, 212 | ); 213 | 214 | return expect(result).resolves.toEqual('Tester is 24 years old'); 215 | }); 216 | }); 217 | 218 | describe('decode', () => { 219 | it('resolves promise on valid data', () => { 220 | const type = t.string; 221 | const value = 'hello there'; 222 | 223 | return expect(tPromise.decode(type, value)).resolves.toEqual(value); 224 | }); 225 | 226 | it('resolves promise on falsy string', () => { 227 | const type = t.string; 228 | const value = ''; 229 | 230 | return expect(tPromise.decode(type, value)).resolves.toEqual(value); 231 | }); 232 | 233 | it('resolves promise on falsy boolean', () => { 234 | const type = t.boolean; 235 | const value = false; 236 | 237 | return expect(tPromise.decode(type, value)).resolves.toEqual(value); 238 | }); 239 | 240 | it('resolves promise on falsy number', () => { 241 | const type = t.number; 242 | const value = 0; 243 | 244 | return expect(tPromise.decode(type, value)).resolves.toEqual(value); 245 | }); 246 | 247 | it('resolves promise on falsy undefined', () => { 248 | const type = t.undefined; 249 | const value = undefined; 250 | 251 | return expect(tPromise.decode(type, value)).resolves.toEqual(value); 252 | }); 253 | 254 | it('resolves promise on falsy null', () => { 255 | const type = t.null; 256 | const value = null; 257 | 258 | return expect(tPromise.decode(type, value)).resolves.toEqual(value); 259 | }); 260 | 261 | it('rejects promise on invalid data', () => { 262 | const type = t.string; 263 | const value = 10; 264 | 265 | return expect(tPromise.decode(type, value)).rejects.toBeInstanceOf( 266 | tPromise.DecodeError, 267 | ); 268 | }); 269 | }); 270 | 271 | describe('decode with curry', () => { 272 | it('resolves promise on valid data', () => { 273 | const type = t.string; 274 | const value = 'hello there'; 275 | 276 | return expect(tPromise.decode(type)(value)).resolves.toEqual(value); 277 | }); 278 | 279 | it('rejects promise on invalid data', () => { 280 | const type = t.string; 281 | const value = 10; 282 | 283 | return expect(tPromise.decode(type)(value)).rejects.toBeInstanceOf( 284 | tPromise.DecodeError, 285 | ); 286 | }); 287 | }); 288 | 289 | describe('isDecodeError', () => { 290 | it('identifies errors produced by decode', () => { 291 | const failingPromise = tPromise.decode(t.string, 10); 292 | 293 | return expect(failingPromise).rejects.toSatisfy(tPromise.isDecodeError); 294 | }); 295 | 296 | it('identifies other errors', () => { 297 | const nonDecodeError = new Error('test-error'); 298 | 299 | expect(tPromise.isDecodeError(nonDecodeError)).toEqual(false); 300 | }); 301 | }); 302 | 303 | describe('createType', () => { 304 | const price = tPromise.createType( 305 | (value: unknown) => { 306 | if (typeof value === 'number') { 307 | return { 308 | currency: 'EUR', 309 | value, 310 | }; 311 | } else { 312 | throw new Error(); 313 | } 314 | }, 315 | (value) => value.value, 316 | t.type({ currency: t.string, value: t.number }).is, 317 | ); 318 | 319 | runPriceTypeTests(price); 320 | }); 321 | 322 | describe('extendType', () => { 323 | const price = tPromise.extendType( 324 | t.number, 325 | (value: number) => ({ 326 | currency: 'EUR', 327 | value, 328 | }), 329 | (value) => value.value, 330 | t.type({ currency: t.string, value: t.number }).is, 331 | ); 332 | 333 | runPriceTypeTests(price); 334 | }); 335 | 336 | function runPriceTypeTests( 337 | price: t.Type<{ currency: string; value: number }, unknown, unknown>, 338 | ) { 339 | it('produces type which decodes valid values', () => { 340 | const result = price.decode(10); 341 | 342 | expect(result).toEqualRight({ 343 | currency: 'EUR', 344 | value: 10, 345 | }); 346 | }); 347 | 348 | it('produces type which decodes values nested in io-ts types', () => { 349 | const product = t.type({ 350 | name: t.string, 351 | price, 352 | }); 353 | 354 | const result = product.decode({ 355 | name: 'thing', 356 | price: 99, 357 | }); 358 | 359 | expect(result).toEqualRight({ 360 | name: 'thing', 361 | price: { 362 | currency: 'EUR', 363 | value: 99, 364 | }, 365 | }); 366 | }); 367 | 368 | it('fails to decode invalid values', () => { 369 | const result = price.decode('10€'); 370 | 371 | expect(result).toEqualLeft([ 372 | { 373 | context: expect.anything(), 374 | value: '10€', 375 | }, 376 | ]); 377 | }); 378 | 379 | it('produces type which identifies matching values with typeguard', () => { 380 | expect( 381 | price.is({ 382 | currency: 'EUR', 383 | value: 10, 384 | }), 385 | ).toEqual(true); 386 | }); 387 | 388 | it('produces type which identifies nonmatching values with typeguard', () => { 389 | expect(price.is('10€')).toEqual(false); 390 | expect(price.is(10)).toEqual(false); 391 | 392 | expect( 393 | price.is({ 394 | sum: 10, 395 | }), 396 | ).toEqual(false); 397 | 398 | expect( 399 | price.is({ 400 | currency: 'EUR', 401 | value: '10€', 402 | }), 403 | ).toEqual(false); 404 | }); 405 | } 406 | 407 | describe('createDecoder', () => { 408 | const price = tPromise.createDecoder((value: unknown) => { 409 | if (typeof value === 'number') { 410 | if (value < 0) { 411 | throw new Error('Price cannot be negative'); 412 | } 413 | return { 414 | currency: 'EUR', 415 | value, 416 | }; 417 | } else { 418 | throw new Error(); 419 | } 420 | }); 421 | 422 | runPriceDecoderTests(price); 423 | }); 424 | 425 | describe('extendDecoder', () => { 426 | const price = tPromise.extendDecoder(t.number, (value: number) => { 427 | if (value < 0) { 428 | throw new Error('Price cannot be negative'); 429 | } 430 | return { 431 | currency: 'EUR', 432 | value, 433 | }; 434 | }); 435 | 436 | runPriceDecoderTests(price); 437 | }); 438 | 439 | /** 440 | * Test helper for price decoders that verifies basic decoding behavior and error message handling. 441 | * 442 | * @param price A decoder that must successfully decode positive numbers, fail without a custom error 443 | * message when given invalid types (e.g., strings like '10€'), and fail with the error 444 | * message 'Price cannot be negative' when given negative numbers. 445 | */ 446 | function runPriceDecoderTests( 447 | price: t.Decoder< 448 | unknown, 449 | { 450 | currency: string; 451 | value: number; 452 | } 453 | >, 454 | ) { 455 | it('produces decoder which succeeds to decode valid values', () => { 456 | const result = price.decode(10); 457 | 458 | expect(result).toEqualRight({ 459 | currency: 'EUR', 460 | value: 10, 461 | }); 462 | }); 463 | 464 | it('produces decoder which fails to decode invalid values', () => { 465 | const result = price.decode('10€'); 466 | 467 | expect(result).toEqualLeft([ 468 | { 469 | context: expect.anything(), 470 | value: '10€', 471 | }, 472 | ]); 473 | }); 474 | 475 | it('produces decoder which fails with message on negative values', () => { 476 | const result = price.decode(-5); 477 | 478 | expect(result).toEqualLeft([ 479 | { 480 | context: expect.anything(), 481 | value: -5, 482 | message: 'Price cannot be negative', 483 | }, 484 | ]); 485 | }); 486 | } 487 | }); 488 | --------------------------------------------------------------------------------