├── .eslintrc.js ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── renovate.json ├── src ├── index.test.ts └── index.ts ├── stryker.conf.js ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | parserOptions: { 4 | project: "./tsconfig.json", 5 | ecmaVersion: 2018, 6 | sourceType: "module" 7 | }, 8 | extends: [ 9 | "agile-digital", 10 | ], 11 | env: { 12 | "jest/globals": true, 13 | es6: true, 14 | browser: true 15 | }, 16 | plugins: ["jest", "sonarjs", "functional", "@typescript-eslint", "prettier", "total-functions", "spellcheck", "react", "react-hooks", "jsx-a11y"], 17 | rules: { 18 | // TODO https://github.com/eslint-functional/eslint-plugin-functional/issues/733 19 | "functional/prefer-immutable-types": "off", 20 | "spellcheck/spell-checker": [ 21 | 1, 22 | { 23 | skipWords: [ 24 | "globals", 25 | "readonly", 26 | "Readonly", 27 | "sonarjs", 28 | ], 29 | }, 30 | ], 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | env: 23 | STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} 24 | 25 | # Steps represent a sequence of tasks that will be executed as part of the job 26 | steps: 27 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 28 | - uses: actions/checkout@v4 29 | 30 | - run: yarn install --frozen-lockfile --non-interactive 31 | - run: yarn build 32 | - run: yarn lint 33 | - run: yarn type-coverage 34 | - run: yarn test 35 | - run: yarn stryker run 36 | - run: yarn codecov 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | yarn-error.log 4 | node_modules 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | node -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Daniel Nixon 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 | # Readonly TypeScript Types 2 | 3 | [![Build Status](https://github.com/agiledigital/readonly-types/actions/workflows/main.yml/badge.svg)](https://github.com/agiledigital/readonly-types/actions/workflows/main.yml) 4 | [![type-coverage](https://img.shields.io/badge/dynamic/json.svg?label=type-coverage&prefix=%E2%89%A5&suffix=%&query=$.typeCoverage.atLeast&uri=https%3A%2F%2Fraw.githubusercontent.com%2Fagiledigital%2Freadonly-types%2Fmaster%2Fpackage.json)](https://github.com/plantain-00/type-coverage) 5 | [![codecov](https://codecov.io/gh/agiledigital/readonly-types/branch/master/graph/badge.svg?token=SYO6NY3DF0)](https://codecov.io/gh/agiledigital/readonly-types) 6 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fagiledigital%2Freadonly-types%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/agiledigital/readonly-types/master) 7 | [![Known Vulnerabilities](https://snyk.io/test/github/agiledigital/readonly-types/badge.svg?targetFile=package.json)](https://snyk.io/test/github/agiledigital/readonly-types?targetFile=package.json) 8 | [![npm](https://img.shields.io/npm/v/readonly-types.svg)](https://www.npmjs.com/package/readonly-types) 9 | 10 | A collection of readonly TypeScript types inspired by TypeScript's built-in readonly types (`ReadonlyArray`, `ReadonlyMap`, etc) and by [is-immutable-type](https://github.com/RebeccaStevens/is-immutable-type). 11 | 12 | The types here are all fully `Immutable` following [is-immutable-type#definitions](https://github.com/RebeccaStevens/is-immutable-type#definitions). 13 | 14 | This package assumes you have TypeScript's [strict mode](https://www.typescriptlang.org/tsconfig#strict) and [noUncheckedIndexedAccess](https://www.typescriptlang.org/tsconfig#noUncheckedIndexedAccess) option turned on. [eslint-plugin-total-functions](https://github.com/danielnixon/eslint-plugin-total-functions/) provides an ESLint rule to ensure they're both on. 15 | 16 | ## Installation 17 | 18 | ```sh 19 | # yarn 20 | yarn add readonly-types 21 | 22 | # npm 23 | npm install readonly-types 24 | ``` 25 | 26 | ## Usage 27 | 28 | ```TypeScript 29 | // Here's an example using ReadonlyURL. 30 | import { ReadonlyURL } from "readonly-types"; 31 | 32 | // This is fine. 33 | const hasFooSearchParam = (url: ReadonlyURL) => url.searchParams.has("foo"); 34 | 35 | // But this won't compile. 36 | const setFooSearchParam = (url: ReadonlyURL) => url.searchParams.set("foo", "bar"); 37 | ``` 38 | 39 | ## The Types 40 | 41 | The second column contains the types provided by this library (which are all `Immutable`). The columns to the right of it show the types being replaced and what level of immutability they achieve by default. 42 | 43 | The first column ("Even Better 🚀") contains types that are more than just immutable versions of the types in the later columns. These "even better" options require more effort to adopt than those in the second column (or may not even be generally available yet), but they're worth considering if you want something that is more closely aligned with a pure typeful functional programming approach. 44 | 45 | | Even Better 🚀 | Immutable | ReadonlyDeep | ReadonlyShallow | Mutable | 46 | |----------------|-----------|--------------|-----------------|---------| 47 | | A dedicated `Map` type (good options below), see [Objects vs. Maps](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map#objects_vs._maps) for why | `ReadonlyRecord` | | | [`Record`](https://www.typescriptlang.org/docs/handbook/utility-types.html#recordkt) | 48 | | | `ReadonlyURL` | | | [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) | 49 | | | `ReadonlyURLSearchParams` | | | [`URLSearchParams`](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) | 50 | | [Temporal](https://tc39.es/proposal-temporal/) (stage 3 proposal, aims to solve various problems in `Date`, including its mutability) | `ReadonlyDate` | | | [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) | 51 | | [Chunk](https://effect.website/docs/data-types/chunk/), [PrincipledArray](https://github.com/agiledigital/readonly-types/issues/7) (does not return mutable arrays from methods like `map`), purpose-built immutable data structures | `ImmutableArray` | `ReadonlyArray` | | `Array` | 52 | | purpose-built immutable data structures | `ImmutableSet` | `ReadonlySet` | | `Set` | 53 | | purpose-built immutable data structures | `ImmutableMap` | `ReadonlyMap` | | `Map` | 54 | | | `ReadonlyWeakSet` | | | [`WeakSet`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakSet) | 55 | | | `ReadonlyWeakMap` | | | [`WeakMap`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) | 56 | | [Effect's `Either`](https://effect.website/docs/data-types/either/), [fp-ts's `Either`](https://gcanti.github.io/fp-ts/modules/Either.ts.html) | `ReadonlyError` (and friends) | | | [`Error` and friends](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects#error_objects) | 57 | | | `ReadonlyRegExp` | | | `RegExp` | 58 | | [Effect](https://effect.website/docs/getting-started/the-effect-type/), [fp-ts's `TaskEither`](https://gcanti.github.io/fp-ts/modules/TaskEither.ts.html) | `ReadonlyPromise` | `Promise` | | | 59 | | | `DeepImmutable` | | | [`DeepReadonly` from ts-essentials](https://github.com/ts-essentials/ts-essentials/blob/master/lib/types.ts#L156-L181), which when used will produce a mix of `Mutable` and `ReadonlyDeep` types | 60 | * PRs welcome! 61 | 62 | ## Linting 63 | 64 | You can ban the mutable counterparts to these readonly types using [eslint-plugin-functional](https://github.com/eslint-functional/eslint-plugin-functional/)'s [prefer-immutable-types](https://github.com/eslint-functional/eslint-plugin-functional/blob/main/docs/rules/prefer-immutable-types.md) rule. 65 | 66 | ## `ImmutableArray` and `PrincipledArray` 67 | 68 | TypeScript's built-in `ReadonlyArray` isn't truly immutable. Observe: 69 | 70 | ```typescript 71 | const foo: ReadonlyArray = [""] as const; 72 | 73 | // This compiles 74 | foo.every = () => false; 75 | // So does this 76 | foo.at = () => undefined; 77 | ``` 78 | 79 | is-immutable-type provides the answer in [Making ReadonlyDeep types Immutable](https://github.com/RebeccaStevens/is-immutable-type#making-readonlydeep-types-immutable). We've reused that here to provide an `ImmutableArray` type. 80 | 81 | ```typescript 82 | import { ImmutableArray } from "readonly-types"; 83 | 84 | const foo: ImmutableArray = [""] as const; 85 | 86 | // These no longer compile 87 | foo.every = () => false; // Cannot assign to 'every' because it is a read-only property. ts(2540) 88 | foo.at = () => undefined; // Cannot assign to 'at' because it is a read-only property. ts(2540) 89 | ``` 90 | 91 | `ReadonlyArray` achieves the `ReadonlyDeep` level of immutability, `ImmutableArray` achieves the `Immutable` level. 92 | 93 | It turns out that even `ImmutableArray` has cracks in its immutable armour. Here's a subtle one: 94 | 95 | ```typescript 96 | // This doesn't compile... 97 | foo.at = () => undefined; 98 | 99 | foo.map((value, index, array) => { 100 | // ... but this does! 101 | array.at = () => undefined; 102 | 103 | return value; 104 | }); 105 | ``` 106 | 107 | The `array` passed as the third argument to the `map` callback is typed as `ReadonlyArray`. Our `ImmutableArray` trick doesn't change that method's callback's argument's types. The same applies to `filter`, `flatMap`, `find` and so on. 108 | 109 | To fix that issue we provide a type called `PrincipledArray`: 110 | 111 | ```typescript 112 | const foo: PrincipledArray = [""] as const; 113 | 114 | // This doesn't compile... 115 | foo.at = () => undefined; 116 | 117 | foo.map((value, index, array) => { 118 | // ... and neither does this! 119 | array.at = () => undefined; 120 | 121 | return value; 122 | }); 123 | ``` 124 | 125 | `PrincipledArray` makes a few other (type-incompatible) improvements while its at it, including: 126 | 127 | * Removes `forEach` entirely (use `map` or another non-side-effecting alternative instead). 128 | * Requires a true boolean return type from predicates passed to `filter` and other methods (by default, TypeScript allows these predicates to return `unknown`). 129 | * Removes the partial versions of `reduce` and `reduceRight` that throw at runtime if the array is empty (i.e. those that don't require the caller to specify an initial value). See also https://github.com/eslint-functional/eslint-plugin-functional/issues/527 130 | 131 | ```typescript 132 | import { principledArray } from "readonly-types"; 133 | 134 | // Given a principled array. 135 | const foo = principledArray([]); 136 | 137 | // This does not compile. 138 | // Property 'forEach' does not exist on type 'PrincipledArray'. ts(2339) 139 | foo.forEach(() => {}); 140 | 141 | // This would normally throw at runtime, but with PrincipledArray it does not compile 142 | // Expected 2 arguments, but got 1. ts(2554) 143 | // An argument for 'initialValue' was not provided. 144 | const result = foo.reduce((p) => p); 145 | ``` 146 | 147 | The downside to `PrincipledArray` is that -- precisely because it changes the type in these ways -- you cannot assign it to a value of type `ReadonlyArray`. `ImmutableArray` doesn't have this downside. Choose whichever is most appropriate for you. 148 | 149 | ## `ImmutableNonEmptyArray` and `PrincipledNonEmptyArray` 150 | 151 | An array type that is verifiably non-empty (i.e. known to have at least one entry at compile time) is a useful type to have. 152 | 153 | You can make such a type based on `ReadonlyArray` like this: 154 | 155 | ```typescript 156 | type ReadonlyNonEmptyArray = readonly [T, ...(readonly T[])]; 157 | ``` 158 | 159 | Like `ReadonlyArray` that type is only `ReadonlyDeep`, not truly `Immutable`. 160 | 161 | We provide a truly immutable version in the form of `ImmutableNonEmptyArray`. 162 | 163 | With `PrincipledArray` having removed the versions of `reduce` and `reduceRight` that do not require an `initialValue`, there becomes a need for another type that is verifiably non-empty (at compile time) which puts them back again. 164 | 165 | We provide that type in the form of `PrincipledNonEmptyArray`, which you can think of as a mix between `ImmutableNonEmptyArray` and `PrincipledArray`: 166 | 167 | ```typescript 168 | // Given a principled non-empty array. 169 | const foo = principledNonEmptyArray(["a"]); 170 | 171 | // This compiles, whereas it wouldn't have compiled for a regular principled array. 172 | const result = foo.reduce((p) => p); 173 | ``` 174 | 175 | ## Array type compatibility 176 | 177 | | ⬇️ can be assigned to ➡️ | `Array` | `ReadonlyArray` | `ImmutableArray` | `PrincipledArray` | `PrincipledNonEmptyArray` | 178 | |---------------------------|--------|-------------------|-------------------|-------------------|---------------------------| 179 | | `Array` | ✅ | ✅ ⚠️ | ✅ ⚠️ | ❌ | ❌ | 180 | | `ReadonlyArray` | ❌ | ✅ | ✅ ⚠️ | ❌ | ❌ | 181 | | `ImmutableArray` | ❌ | ✅ ⚠️ | ✅ | ❌ | ❌ | 182 | | `PrincipledArray` | ❌ | ❌ | ❌ | ✅ | ❌ | 183 | | `PrincipledNonEmptyArray` | ❌ | ❌ | ❌ | ✅ | ✅ | 184 | 185 | Assignments marked ⚠️ can lead to surprising mutation in whichever side of the assignment appears to have "more" immutability, via mutations made to the side that has "less". [eslint-plugin-total-functions](https://github.com/danielnixon/eslint-plugin-total-functions/) includes an ESLint rule to flag these unsafe assignments. https://github.com/eslint-functional/eslint-plugin-functional/issues/526 may play a part too. See https://github.com/Microsoft/TypeScript/issues/13347 for more. 186 | 187 | ## Purpose-built immutable data structures 188 | 189 | Types like `ImmutableArray` and `PrincipledArray` (and even the humble built-in `ReadonlyArray`) can help a lot with correctness but the underlying runtime type remains a mutable `Array`. The same goes for our immutable `Set` and `Map` types. In essence the data structures are the same, we're just constraining ourselves to an immutable subset of their mutable APIs. 190 | 191 | One consequence of this is that if someone could get their hands on a mutable handle to one of our values, they could edit it as if it were mutable (e.g. via an `as` type assertion or via an `Array.isArray` check). This forces us to put a little asterisk next to any immutability guarantees we make. You might reach for [Object.freeze](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze) in response to that risk, but that comes with its own issues (performance, compatibility, doesn't show up in the type system, ...). 192 | 193 | Another consequence of this is that updating and copying values of these types is needlessly expensive (in terms of compute and memory). A copy of the _entire_ structure must be taken to preserve correctness, even if all we want to do for example is update a single element. 194 | 195 | There exist purpose-built immutable data structures that give us an immutable API without the associated performance cost of copying an underlying mutable structure (look for terms like 'structural sharing' and 'copy on write'). If performance is a factor for you, these can be a better choice than the immutable types provided by this package. 196 | 197 | To get you started, check out the following: 198 | 199 | * https://github.com/immerjs/immer 200 | * https://github.com/immutable-js/immutable-js 201 | * https://github.com/rtfeldman/seamless-immutable 202 | 203 | A surprising irony of these types is that they typically aren't truly immutable, for the same reason that `ReadonlyArray` isn't truly immutable. Here's an example: 204 | 205 | ```typescript 206 | import { Map as ImmutableJsMap } from "immutable"; 207 | const foo = ImmutableJsMap([["key", "value"]]); 208 | // This compiles 209 | foo.delete = () => foo; 210 | ``` 211 | 212 | Because `delete` is implemented using method syntax it is necessarily mutable (TypeScript methods defined using method syntax cannot be readonly for "reasons"). This is so common that [is-immutable-type#definitions](https://github.com/RebeccaStevens/is-immutable-type#definitions) defines a level of "readonly-ness" called `ReadonlyDeep` that sits below truly `Immutable` but above the mutable levels `ReadonlyShallow` and `Mutable`. 213 | 214 | Depending on how strictly you wish to enforce immutability, `ReadonlyDeep` may or may not be acceptable to you. If it isn't, you can fix it like this: 215 | 216 | ```typescript 217 | import { Map as ImmutableJsMap } from "immutable"; 218 | 219 | type TrulyImmutableMap = Readonly>; 220 | 221 | const foo: TrulyImmutableMap = ImmutableJsMap([ 222 | ["key", "value"], 223 | ]); 224 | 225 | // No longer compiles 226 | foo.delete = () => foo; // Cannot assign to 'delete' because it is a read-only property. ts(2540) 227 | ``` 228 | 229 | See [Making ReadonlyDeep types Immutable](https://github.com/RebeccaStevens/is-immutable-type#making-readonlydeep-types-immutable) for more on this. 230 | 231 | ## See Also 232 | * https://github.com/danielnixon/eslint-config-typed-fp 233 | * https://github.com/jonaskello/eslint-plugin-functional 234 | * https://github.com/danielnixon/eslint-plugin-total-functions 235 | * https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#readonly-and-const 236 | * To see ReadonlyDate adoption grow, upvote this: https://github.com/date-fns/date-fns/issues/1944 237 | * https://github.com/Microsoft/TypeScript/issues/13347 238 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | roots: ["/src"], 4 | transform: { 5 | "^.+\\.tsx?$": "ts-jest", 6 | }, 7 | collectCoverage: true, 8 | coverageThreshold: { 9 | global: { 10 | branches: 100, 11 | functions: 100, 12 | lines: 100, 13 | statements: 100, 14 | }, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "readonly-types", 3 | "version": "4.5.0", 4 | "description": "A collection of readonly TypeScript types inspired by the built-in ReadonlyArray, ReadonlyMap, etc.", 5 | "main": "dist", 6 | "repository": "https://github.com/agiledigital/readonly-types.git", 7 | "author": "Daniel Nixon ", 8 | "license": "MIT", 9 | "devDependencies": { 10 | "@stryker-mutator/core": "7.3.0", 11 | "@stryker-mutator/jest-runner": "7.3.0", 12 | "@stryker-mutator/typescript-checker": "7.3.0", 13 | "@types/jest": "^29.5.4", 14 | "@typescript-eslint/eslint-plugin": "^6.6.0", 15 | "@typescript-eslint/parser": "^6.6.0", 16 | "@typescript-eslint/type-utils": "^6.6.0", 17 | "codecov": "^3.8.3", 18 | "eslint": "^8.48.0", 19 | "eslint-config-agile-digital": "^3.3.1", 20 | "eslint-config-prettier": "^9.0.0", 21 | "eslint-config-typed-fp": "^5.3.0", 22 | "eslint-plugin-functional": "^6.0.0", 23 | "eslint-plugin-import": "^2.28.1", 24 | "eslint-plugin-jest": "^27.2.3", 25 | "eslint-plugin-jsx-a11y": "^6.7.1", 26 | "eslint-plugin-prettier": "^5.0.0", 27 | "eslint-plugin-react": "^7.33.2", 28 | "eslint-plugin-react-hooks": "^4.6.0", 29 | "eslint-plugin-sonarjs": "^0.23.0", 30 | "eslint-plugin-spellcheck": "^0.0.20", 31 | "eslint-plugin-total-functions": "^7.1.0", 32 | "jest": "^29.6.4", 33 | "prettier": "^3.0.3", 34 | "ts-jest": "^29.1.1", 35 | "type-coverage": "^2.26.2", 36 | "typescript": "^5.2.2" 37 | }, 38 | "scripts": { 39 | "build": "tsc", 40 | "lint": "eslint src --ext .ts,.tsx --report-unused-disable-directives", 41 | "format": "prettier --write 'src/**/*.{ts,tsx}'", 42 | "test": "jest", 43 | "release": "yarn build && yarn lint && yarn type-coverage && yarn publish" 44 | }, 45 | "typeCoverage": { 46 | "atLeast": 99, 47 | "ignoreCatch": false, 48 | "strict": true, 49 | "detail": true 50 | }, 51 | "dependencies": { 52 | "ts-essentials": "^9.4.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":preserveSemverRanges" 5 | ], 6 | "transitiveRemediation": true, 7 | "packageRules": [ 8 | { 9 | "matchUpdateTypes": ["minor", "patch"], 10 | "matchCurrentVersion": "!/^0/", 11 | "automerge": true 12 | }, 13 | { 14 | "matchDatasources": ["npm"], 15 | "stabilityDays": 3 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { 3 | readonlyURL, 4 | readonlyDate, 5 | readonlyNow, 6 | readonlyMap, 7 | readonlyWeakMap, 8 | validReadonlyDate, 9 | readonlyURLSearchParams, 10 | readonlySet, 11 | readonlyWeakSet, 12 | principledArray, 13 | PrincipledArray, 14 | ReadonlyPromise, 15 | principledNonEmptyArray, 16 | PrincipledNonEmptyArray, 17 | } from "."; 18 | 19 | describe("ReadonlyURL", () => { 20 | it("iterates through URL search params using for..of", () => { 21 | const url = readonlyURL("http://example.com?foo=a&bar=b"); 22 | 23 | if (url === undefined) { 24 | throw new Error("url was undefined"); 25 | } 26 | 27 | // eslint-disable-next-line functional/no-loop-statements 28 | for (const [k, v] of url.searchParams) { 29 | if (k === "foo") { 30 | // eslint-disable-next-line jest/no-conditional-expect 31 | expect(v).toEqual("a"); 32 | } else { 33 | // eslint-disable-next-line jest/no-conditional-expect 34 | expect(k).toEqual("bar"); 35 | // eslint-disable-next-line jest/no-conditional-expect 36 | expect(v).toEqual("b"); 37 | } 38 | } 39 | }); 40 | 41 | it("doesn't throw on invalid input", () => { 42 | const url = readonlyURL("asdf"); 43 | expect(url).toBeUndefined(); 44 | }); 45 | 46 | it("doesn't allow mutation via search params", () => { 47 | const url = readonlyURL("http://example.com"); 48 | expect(url).toBeDefined(); 49 | 50 | if (url === undefined) { 51 | throw new Error("url was undefined"); 52 | } 53 | 54 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 55 | // @ts-expect-error 56 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 57 | url.searchParams.append("", ""); 58 | }); 59 | }); 60 | 61 | describe("ReadonlyURLSearchParams", () => { 62 | // eslint-disable-next-line sonarjs/no-duplicate-string 63 | it("doesn't throw", () => { 64 | const x = readonlyURLSearchParams(""); 65 | expect(x).toBeDefined(); 66 | }); 67 | 68 | // eslint-disable-next-line sonarjs/no-duplicate-string 69 | it("allows iteration using for..of", () => { 70 | const params = readonlyURLSearchParams({ a: "b" }); 71 | 72 | expect(params[Symbol.iterator]).toBeDefined(); 73 | 74 | // eslint-disable-next-line functional/no-loop-statements 75 | for (const [k, v] of params) { 76 | expect(k).toBe("a"); 77 | expect(v).toBe("b"); 78 | } 79 | 80 | /* eslint-disable functional/immutable-data, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/consistent-type-assertions, @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any */ 81 | // @ts-expect-error 82 | params[Symbol.iterator] = null as any; 83 | /* eslint-enable functional/immutable-data, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/consistent-type-assertions, @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any */ 84 | }); 85 | 86 | // eslint-disable-next-line jest/expect-expect 87 | it("does not expose forEach", () => { 88 | const params = readonlyURLSearchParams({ a: "b" }); 89 | 90 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 91 | // @ts-expect-error 92 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 93 | params.forEach(() => {}); 94 | }); 95 | }); 96 | 97 | describe("ReadonlyDate", () => { 98 | it("doesn't throw on invalid input", () => { 99 | const date = readonlyDate("asdf"); 100 | expect(date.getMilliseconds()).toBeNaN(); 101 | }); 102 | 103 | it("doesn't throw when getting current date (now)", () => { 104 | const date = readonlyDate(readonlyNow()); 105 | expect(date.getMilliseconds()).toBeGreaterThan(0); 106 | }); 107 | }); 108 | 109 | describe("ValidReadonlyDate", () => { 110 | it("returns undefined on invalid input", () => { 111 | const date = validReadonlyDate("asdf"); 112 | expect(date).toBeUndefined(); 113 | }); 114 | 115 | it("returns a date for valid input", () => { 116 | const date = validReadonlyDate(Date.now()); 117 | expect(date).toBeDefined(); 118 | }); 119 | }); 120 | 121 | describe("ReadonlyMap", () => { 122 | it("allows iteration using for..of", () => { 123 | const map = readonlyMap([ 124 | [1, "one"], 125 | [2, "two"], 126 | [3, "three"], 127 | ]); 128 | 129 | // eslint-disable-next-line functional/no-loop-statements 130 | for (const [k, v] of map) { 131 | expect(k).toBeTruthy(); 132 | expect(v).toBeTruthy(); 133 | } 134 | }); 135 | }); 136 | 137 | describe("ReadonlyWeakMap", () => { 138 | it("doesn't throw", () => { 139 | const map = readonlyWeakMap([ 140 | [{}, "one"], 141 | [{}, "two"], 142 | [{}, "three"], 143 | ]); 144 | 145 | expect(map).toBeDefined(); 146 | }); 147 | }); 148 | 149 | describe("ReadonlySet", () => { 150 | it("allows iteration using for..of", () => { 151 | const set = readonlySet([ 152 | [1, "one"], 153 | [2, "two"], 154 | [3, "three"], 155 | ]); 156 | 157 | // eslint-disable-next-line functional/no-loop-statements 158 | for (const [k, v] of set) { 159 | expect(k).toBeTruthy(); 160 | expect(v).toBeTruthy(); 161 | } 162 | }); 163 | }); 164 | 165 | describe("ReadonlyWeakSet", () => { 166 | it("doesn't throw", () => { 167 | const set = readonlyWeakSet([ 168 | [{}, "one"], 169 | [{}, "two"], 170 | [{}, "three"], 171 | ]); 172 | 173 | expect(set).toBeDefined(); 174 | }); 175 | }); 176 | 177 | describe("ReadonlyPromise", () => { 178 | it("can be awaited", async () => { 179 | const p: ReadonlyPromise = Promise.resolve("a"); 180 | const result = await p; 181 | expect(result).toStrictEqual("a"); 182 | return undefined; 183 | }); 184 | }); 185 | 186 | describe("PrincipledArray", () => { 187 | it("can be mapped", () => { 188 | const foo = principledArray(["a"]); 189 | 190 | expect( 191 | foo.map((s, _i, _array: PrincipledArray) => s.toUpperCase()), 192 | ).toStrictEqual(["A"]); 193 | }); 194 | 195 | it("can be flatMapped", () => { 196 | const foo = principledArray(["a"]); 197 | 198 | expect( 199 | foo.flatMap((s, _i, _array: PrincipledArray) => 200 | principledArray([s.toUpperCase()]), 201 | ), 202 | ).toStrictEqual(["A"]); 203 | }); 204 | 205 | it("can be flattened", () => { 206 | const foo = principledArray([ 207 | principledArray(["a"]), 208 | principledArray(["b"]), 209 | ]); 210 | 211 | const flattened: PrincipledArray = foo.flat(); 212 | 213 | expect(flattened).toStrictEqual(["a", "b"]); 214 | }); 215 | 216 | it("can be flattened when nested level is regular array", () => { 217 | const foo = principledArray([["a"], ["b"]]); 218 | 219 | const flattened: PrincipledArray = foo.flat(); 220 | 221 | expect(flattened).toStrictEqual(["a", "b"]); 222 | }); 223 | 224 | it("can be flattened when nested level is mixed", () => { 225 | const foo = principledArray([["a"], principledArray(["b"]), "c"]); 226 | 227 | const flattened: PrincipledArray = foo.flat(); 228 | 229 | expect(flattened).toStrictEqual(["a", "b", "c"]); 230 | }); 231 | 232 | it("can be filtered with a boolean predicate", () => { 233 | const foo = principledArray(["a", "b"]); 234 | 235 | expect( 236 | foo.filter((s, _index, _array: PrincipledArray) => s !== "b"), 237 | ).toStrictEqual(["a"]); 238 | }); 239 | 240 | it("can be filtered with a type guard predicate", () => { 241 | const foo = principledArray(["a", "b"]); 242 | 243 | const isLetterA = (s: string): s is "a" => s === "a"; 244 | 245 | const aArray: PrincipledArray<"a"> = foo.filter(isLetterA); 246 | 247 | expect(aArray).toStrictEqual(["a"]); 248 | }); 249 | 250 | it("can use find with a boolean predicate", () => { 251 | const foo = principledArray(["a", "b"]); 252 | 253 | expect( 254 | foo.find((s, _index, _array: PrincipledArray) => s !== "b"), 255 | ).toStrictEqual("a"); 256 | }); 257 | 258 | it("can use find with a type guard predicate", () => { 259 | const foo = principledArray(["a", "b"]); 260 | 261 | const isLetterA = (s: string): s is "a" => s === "a"; 262 | 263 | const result: "a" | undefined = foo.find(isLetterA); 264 | 265 | expect(result).toStrictEqual("a"); 266 | }); 267 | 268 | it("can use findIndex", () => { 269 | const foo = principledArray(["a", "b"]); 270 | 271 | expect( 272 | foo.findIndex((s, _index, _array: PrincipledArray) => s !== "b"), 273 | ).toStrictEqual(0); 274 | }); 275 | 276 | // eslint-disable-next-line jest/expect-expect 277 | it("does not expose forEach", () => { 278 | const foo = principledArray(["a", "b"]); 279 | 280 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 281 | // @ts-expect-error 282 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call 283 | foo.forEach(() => {}); 284 | }); 285 | 286 | it("can be concatenated with a single value", () => { 287 | const foo = principledArray(["a"]); 288 | 289 | const bar: PrincipledArray = foo.concat("b"); 290 | 291 | expect(bar).toStrictEqual(["a", "b"]); 292 | }); 293 | 294 | it("can be concatenated with another array", () => { 295 | const foo = principledArray(["a"]); 296 | 297 | const bar: PrincipledArray = foo.concat(["b"]); 298 | 299 | expect(bar).toStrictEqual(["a", "b"]); 300 | }); 301 | 302 | it("can be concatenated with a spread array", () => { 303 | const foo = principledArray(["a"]); 304 | 305 | const bar: PrincipledArray = foo.concat(...["b"]); 306 | 307 | expect(bar).toStrictEqual(["a", "b"]); 308 | }); 309 | 310 | it("can be concatenated with a principled array", () => { 311 | const foo = principledArray(["a"]); 312 | 313 | const bar: PrincipledArray = foo.concat(principledArray(["b"])); 314 | 315 | expect(bar).toStrictEqual(["a", "b"]); 316 | }); 317 | 318 | it("can be sliced", () => { 319 | const foo = principledArray(["a"]); 320 | 321 | const bar = foo.slice(0, 1); 322 | 323 | expect(bar).toStrictEqual(["a"]); 324 | }); 325 | 326 | it("can use every", () => { 327 | const foo = principledArray(["a"]); 328 | 329 | const bar = foo.every((s, _index, _array: PrincipledArray) => { 330 | return s === "a"; 331 | }); 332 | 333 | expect(bar).toBe(true); 334 | }); 335 | 336 | it("can use some", () => { 337 | const foo = principledArray(["a"]); 338 | 339 | const bar = foo.some((s, _index, _array: PrincipledArray) => { 340 | return s === "a"; 341 | }); 342 | 343 | expect(bar).toBe(true); 344 | }); 345 | 346 | it("can use reduce", () => { 347 | const foo = principledArray(["a", "b"]); 348 | 349 | const bar = foo.reduce( 350 | (p, c, _index, _array: PrincipledArray) => `${p}${c}`, 351 | "", 352 | ); 353 | 354 | expect(bar).toBe("ab"); 355 | }); 356 | 357 | it("can use reduceRight", () => { 358 | const foo = principledArray(["a", "b"]); 359 | 360 | const bar = foo.reduceRight( 361 | (p, c, _index, _array: PrincipledArray) => `${p}${c}`, 362 | "", 363 | ); 364 | 365 | expect(bar).toBe("ba"); 366 | }); 367 | 368 | it("cannot use reduce to cause a runtime error with an empty array", () => { 369 | const foo = principledArray([]); 370 | 371 | const unsafe = () => 372 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 373 | // @ts-expect-error 374 | foo.reduce((_p, c, _index, _array: PrincipledArray) => c); 375 | 376 | expect(unsafe).toThrow(); 377 | }); 378 | 379 | it("cannot use reduceRight to cause a runtime error with an empty array", () => { 380 | const foo = principledArray([]); 381 | 382 | const unsafe = () => 383 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 384 | // @ts-expect-error 385 | foo.reduceRight((_p, c, _index, _array: PrincipledArray) => c); 386 | 387 | expect(unsafe).toThrow(); 388 | }); 389 | 390 | it("can use reduce without an initialValue if verifiably nonempty", () => { 391 | const foo = principledNonEmptyArray(["a"]); 392 | 393 | const head: string = foo[0]; 394 | expect(head).toStrictEqual("a"); 395 | 396 | expect( 397 | foo.reduce((_p, c, _index, _array: PrincipledArray) => c), 398 | ).toStrictEqual("a"); 399 | }); 400 | 401 | it("can use reduceRight without an initialValue if verifiably nonempty", () => { 402 | const foo = principledNonEmptyArray(["a"]); 403 | 404 | const head: string = foo[0]; 405 | expect(head).toStrictEqual("a"); 406 | 407 | expect( 408 | foo.reduceRight( 409 | (_p, c, _index, _array: PrincipledNonEmptyArray) => c, 410 | ), 411 | ).toStrictEqual("a"); 412 | }); 413 | }); 414 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-types */ 2 | 3 | import { IsTuple, IsUnknown } from "ts-essentials"; 4 | 5 | /** 6 | * as per DeepReadonly from ts-essentials but additionally accounts for the readonly/immutable types from this package. 7 | */ 8 | export type DeepImmutable = T extends Date 9 | ? ReadonlyDate 10 | : T extends URL 11 | ? ReadonlyURL 12 | : T extends URLSearchParams 13 | ? ReadonlyURLSearchParams 14 | : T extends AnyMap 15 | ? ImmutableMap, DeepImmutable> 16 | : T extends WeakMap 17 | ? ReadonlyWeakMap, DeepImmutable> 18 | : T extends ReadonlyWeakMap 19 | ? ReadonlyWeakMap, DeepImmutable> 20 | : T extends AnySet 21 | ? ImmutableSet> 22 | : T extends WeakSet 23 | ? ReadonlyWeakSet> 24 | : T extends ReadonlyWeakSet 25 | ? ReadonlyWeakSet> 26 | : T extends Promise 27 | ? ReadonlyPromise> 28 | : T extends AnyArray 29 | ? T extends IsTuple 30 | ? { readonly [K in keyof T]: DeepImmutable } 31 | : ImmutableArray> 32 | : T extends {} 33 | ? { readonly [K in keyof T]: DeepImmutable } 34 | : IsUnknown extends true 35 | ? unknown 36 | : Readonly; 37 | 38 | export type ReadonlyPartial = Readonly>; 39 | 40 | export type ReadonlyRequired = Readonly>; 41 | 42 | export type ReadonlyPick = Readonly>; 43 | 44 | export type ReadonlyRecord = Readonly< 45 | Record 46 | >; 47 | 48 | // From https://github.com/RebeccaStevens/is-immutable-type/#making-readonlydeep-types-immutable (what am amazing lib) 49 | export type ImmutableShallow = { 50 | readonly [P in keyof T & {}]: T[P]; 51 | }; 52 | 53 | // https://github.com/agiledigital/readonly-types/issues/518 54 | export type ImmutableArray = ImmutableShallow>; 55 | 56 | export type ImmutableNonEmptyArray = ImmutableShallow< 57 | readonly [T, ...(readonly T[])] 58 | >; 59 | 60 | export type ReadonlyPromise = Readonly>; 61 | 62 | /** 63 | * as per AnyArray from ts-essentials but includes our PrincipledArray and ImmutableArray types as well. 64 | */ 65 | // eslint-disable-next-line functional/type-declaration-immutability 66 | export type AnyArray = 67 | | PrincipledArray 68 | | ImmutableArray 69 | | ReadonlyArray 70 | | Array; 71 | 72 | // eslint-disable-next-line functional/type-declaration-immutability 73 | export type AnyMap = Map | ReadonlyMap | ImmutableMap; 74 | 75 | // eslint-disable-next-line functional/type-declaration-immutability 76 | export type AnySet = Set | ReadonlySet | ImmutableSet; 77 | 78 | /** 79 | * Recursive machinery to implement PrincipledArray's flat method. Copied from TypeScript standard lib 80 | * with necessary changes to accommodate our array types. 81 | */ 82 | type FlatArray = { 83 | readonly done: Arr; 84 | readonly recur: Arr extends AnyArray 85 | ? FlatArray< 86 | InnerArr, 87 | [ 88 | -1, 89 | 0, 90 | 1, 91 | 2, 92 | 3, 93 | 4, 94 | 5, 95 | 6, 96 | 7, 97 | 8, 98 | 9, 99 | 10, 100 | 11, 101 | 12, 102 | 13, 103 | 14, 104 | 15, 105 | 16, 106 | 17, 107 | 18, 108 | 19, 109 | 20, 110 | ][Depth] 111 | > 112 | : Arr; 113 | }[Depth extends -1 ? "done" : "recur"]; 114 | 115 | /** 116 | * Concat machinery, copied from TypeScript standard lib 117 | * with necessary changes to accommodate our array types. 118 | */ 119 | export type ConcatArray = { 120 | readonly length: number; 121 | readonly [n: number]: T; 122 | readonly join: (separator?: string) => string; 123 | readonly slice: ( 124 | start?: number, 125 | end?: number, 126 | ) => PrincipledArray | ImmutableArray; 127 | }; 128 | 129 | /** 130 | * A principled immutable array type. 131 | * @see https://github.com/agiledigital/readonly-types/issues/518 132 | */ 133 | export type PrincipledArray = ImmutableShallow< 134 | OmitStrict< 135 | ImmutableArray, 136 | | "map" 137 | | "filter" 138 | | "forEach" 139 | | "flatMap" 140 | | "flat" 141 | | "concat" 142 | | "slice" 143 | | "every" 144 | | "some" 145 | | "find" 146 | | "findIndex" 147 | | "reduce" 148 | | "reduceRight" 149 | > 150 | > & { 151 | readonly map: ( 152 | callback: (value: T, index: number, array: PrincipledArray) => U, 153 | thisArg?: This, 154 | ) => PrincipledArray; 155 | 156 | readonly filter: ( 157 | predicate: 158 | | ((value: T, index: number, array: PrincipledArray) => value is S) 159 | | ((value: T, index: number, array: PrincipledArray) => boolean), 160 | thisArg?: This, 161 | ) => PrincipledArray; 162 | 163 | readonly find: ( 164 | predicate: 165 | | ((value: T, index: number, obj: PrincipledArray) => value is S) 166 | | ((value: T, index: number, obj: PrincipledArray) => boolean), 167 | thisArg?: This, 168 | ) => S | undefined; 169 | 170 | readonly findIndex: ( 171 | predicate: (value: T, index: number, obj: PrincipledArray) => boolean, 172 | thisArg?: This, 173 | ) => number; 174 | 175 | readonly flatMap: ( 176 | callback: ( 177 | value: T, 178 | index: number, 179 | array: PrincipledArray, 180 | ) => U | PrincipledArray | ImmutableArray, 181 | thisArg?: This, 182 | ) => PrincipledArray; 183 | 184 | readonly flat: ( 185 | this: A, 186 | depth?: D, 187 | ) => PrincipledArray>; 188 | 189 | readonly concat: ( 190 | ...items: readonly (T | ConcatArray)[] 191 | ) => PrincipledArray; 192 | 193 | readonly slice: (start?: number, end?: number) => PrincipledArray; 194 | 195 | readonly every: ( 196 | predicate: (value: T, index: number, array: PrincipledArray) => boolean, 197 | thisArg?: This, 198 | ) => boolean; 199 | 200 | readonly some: ( 201 | predicate: (value: T, index: number, array: PrincipledArray) => boolean, 202 | thisArg?: This, 203 | ) => boolean; 204 | 205 | readonly reduce: ( 206 | callback: ( 207 | previousValue: U, 208 | currentValue: T, 209 | currentIndex: number, 210 | array: PrincipledArray, 211 | ) => U, 212 | initialValue: U, 213 | ) => U; 214 | 215 | readonly reduceRight: ( 216 | callback: ( 217 | previousValue: U, 218 | currentValue: T, 219 | currentIndex: number, 220 | array: PrincipledArray, 221 | ) => U, 222 | initialValue: U, 223 | ) => U; 224 | }; 225 | 226 | /** 227 | * Mixes `NonEmptyImmutableArray` into our standard PrincipledArray to 228 | * prove to the compiler that it has a head element. This allows us to make 229 | * `initialValue` optional in `reduce` and `reduceRight` without risk of runtime errors. 230 | */ 231 | export type PrincipledNonEmptyArray = ImmutableShallow< 232 | OmitStrict< 233 | ImmutableNonEmptyArray & PrincipledArray, 234 | "reduce" | "reduceRight" 235 | > 236 | > & 237 | Readonly<{ 238 | /* eslint-disable @typescript-eslint/method-signature-style, prettier/prettier, @typescript-eslint/unified-signatures */ 239 | reduce(callback: (previousValue: T, currentValue: T, currentIndex: number, array: PrincipledNonEmptyArray) => T): T; 240 | reduce(callback: (previousValue: T, currentValue: T, currentIndex: number, array: PrincipledNonEmptyArray) => T, initialValue: T): T; 241 | reduce(callback: (previousValue: U, currentValue: T, currentIndex: number, array: PrincipledNonEmptyArray) => U, initialValue: U): U; 242 | reduceRight(callback: (previousValue: T, currentValue: T, currentIndex: number, array: PrincipledNonEmptyArray) => T): T; 243 | reduceRight(callback: (previousValue: T, currentValue: T, currentIndex: number, array: PrincipledNonEmptyArray) => T, initialValue: T): T; 244 | reduceRight(callback: (previousValue: U, currentValue: T, currentIndex: number, array: PrincipledNonEmptyArray) => U, initialValue: U): U; 245 | /* eslint-enable @typescript-eslint/method-signature-style, prettier/prettier, @typescript-eslint/unified-signatures */ 246 | }>; 247 | 248 | /** 249 | * Copies the provided immutable array and returns the result as a principled array. 250 | */ 251 | export const principledArray = ( 252 | immutableArray: ImmutableArray, 253 | ): PrincipledArray => { 254 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 255 | return [...immutableArray] as PrincipledArray; 256 | }; 257 | 258 | export const principledNonEmptyArray = ( 259 | immutableArray: ImmutableNonEmptyArray, 260 | ): PrincipledNonEmptyArray => { 261 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 262 | return [...immutableArray] as unknown as PrincipledNonEmptyArray; 263 | }; 264 | 265 | // Methods are technically mutable in TypeScript. There is no way to use method syntax and retain immutability. (OO strikes again) 266 | // Annoyingly, this includes methods on the built-in ReadonlySet type. 267 | // 268 | // eslint-plugin-functional, with its prefer-immutable-types rule, draws a distinction between (truly) `Immutable` types and those 269 | // types that would be fully immutable if not for pesky methods (which it calls `ReadonlyDeep`). 270 | // This is a useful distinction for practical reasons, so we reuse it here to create a (truly) ImmutableSet as distinct 271 | // from the (flawed) built-in ReadonlySet. 272 | // 273 | // The Readonly type takes care of mutable methods for us, replacing them with readonly function properties. 274 | // 275 | // For example, the mutable method `has`: 276 | // 277 | // ``` 278 | // has(value: T): boolean; 279 | // ``` 280 | // 281 | // becomes a readonly property: 282 | // 283 | // ``` 284 | // readonly has: (value: T) => boolean; 285 | // ``` 286 | // 287 | // Note that this compiles: 288 | // ``` 289 | // export const foo = (set: ReadonlySet): void => { 290 | // set.has = () => true; // YOLO 291 | // }; 292 | // ``` 293 | // 294 | // But this doesn't: 295 | // ``` 296 | // export const foo = (set: ImmutableSet): void => { 297 | // set.has = () => true; // doesn't compile 298 | // }; 299 | // ``` 300 | // 301 | // TODO: Suggest a prefer-immutable-types fixer from ReadonlySet to ImmutableSet. 302 | // 303 | // @see https://github.com/eslint-functional/eslint-plugin-functional/blob/main/docs/rules/prefer-immutable-types.md#enforcement 304 | export type ImmutableSet = Readonly>; 305 | 306 | // As above. 307 | export type ImmutableMap = Readonly>; 308 | 309 | /** 310 | * Drop keys `K` from `T`, where `K` must exist in `T`. 311 | * 312 | * @see https://github.com/pelotom/type-zoo 313 | * @see https://github.com/Microsoft/TypeScript/issues/12215#issuecomment-377567046 314 | */ 315 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 316 | export type OmitStrict = T extends any 317 | ? Pick> 318 | : never; 319 | 320 | export type ReadonlyURLSearchParams = Readonly< 321 | OmitStrict 322 | >; 323 | 324 | export const readonlyURLSearchParams = ( 325 | init?: 326 | | readonly (readonly string[])[] 327 | | string[][] 328 | | Record 329 | | string 330 | | URLSearchParams 331 | | ReadonlyURLSearchParams, 332 | ): ReadonlyURLSearchParams => 333 | new URLSearchParams( 334 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 335 | init as string[][] | Record | string | URLSearchParams, 336 | ); 337 | 338 | export type ReadonlyURL = Readonly> & { 339 | readonly searchParams: ReadonlyURLSearchParams; 340 | }; 341 | 342 | export const readonlyURL = ( 343 | url: string, 344 | 345 | base?: string | URL | ReadonlyURL, 346 | ): ReadonlyURL | undefined => { 347 | // eslint-disable-next-line functional/no-try-statements 348 | try { 349 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions, total-functions/no-partial-url-constructor 350 | return new URL(url, base as string | URL); 351 | } catch { 352 | /* returning undefined below results in a better mutation score */ 353 | } 354 | return undefined; 355 | }; 356 | 357 | export type ReadonlyDate = Readonly< 358 | OmitStrict< 359 | Date, 360 | | "setTime" 361 | | "setMilliseconds" 362 | | "setUTCMilliseconds" 363 | | "setSeconds" 364 | | "setUTCSeconds" 365 | | "setMinutes" 366 | | "setUTCMinutes" 367 | | "setHours" 368 | | "setUTCHours" 369 | | "setDate" 370 | | "setUTCDate" 371 | | "setMonth" 372 | | "setUTCMonth" 373 | | "setFullYear" 374 | | "setUTCFullYear" 375 | > 376 | >; 377 | 378 | export const readonlyDate = ( 379 | value: number | string | Date | ReadonlyDate, 380 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 381 | ): ReadonlyDate => new Date(value as number | string | Date); 382 | 383 | export const validReadonlyDate = ( 384 | value: number | string | Date | ReadonlyDate, 385 | ): ReadonlyDate | undefined => { 386 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 387 | const d: ReadonlyDate = new Date(value as number | string | Date); 388 | return isNaN(d.getMilliseconds()) ? undefined : d; 389 | }; 390 | 391 | // eslint-disable-next-line functional/functional-parameters 392 | export const readonlyNow: () => number = () => Date.now(); 393 | 394 | export type ReadonlyWeakSet = Readonly< 395 | OmitStrict, "add" | "delete"> 396 | >; 397 | 398 | export type ReadonlyWeakMap = Readonly< 399 | OmitStrict, "delete" | "set"> 400 | >; 401 | 402 | export const readonlySet = (values: Iterable): ImmutableSet => 403 | new Set(values); 404 | 405 | export const readonlyWeakSet = ( 406 | values: Iterable, 407 | ): ReadonlyWeakSet => new WeakSet(values); 408 | 409 | export const readonlyMap = ( 410 | values: Iterable, 411 | ): ImmutableMap => new Map(values); 412 | 413 | export const readonlyWeakMap = ( 414 | values: Iterable, 415 | ): ReadonlyWeakMap => new WeakMap(values); 416 | 417 | export type ReadonlyError = Readonly; 418 | export type ReadonlyEvalError = Readonly; 419 | export type ReadonlyRangeError = Readonly; 420 | export type ReadonlyReferenceError = Readonly; 421 | export type ReadonlySyntaxError = Readonly; 422 | export type ReadonlyTypeError = Readonly; 423 | export type ReadonlyURIError = Readonly; 424 | // TODO AggregateError 425 | 426 | export type ReadonlyRegExp = Readonly; 427 | -------------------------------------------------------------------------------- /stryker.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | packageManager: "yarn", 3 | reporters: ["clear-text", "progress"], 4 | testRunner: "jest", 5 | coverageAnalysis: "perTest", 6 | tsconfigFile: "tsconfig.json", 7 | thresholds: { high: 100, low: 100, break: 100 } 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | "lib": ["DOM", "DOM.Iterable"], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "dist", /* Redirect output structure to the directory. */ 16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true, /* Enable all strict type-checking options. */ 27 | "noUncheckedIndexedAccess": true, 28 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 29 | "strictNullChecks": true, /* Enable strict null checks. */ 30 | "strictFunctionTypes": true, /* Enable strict checking of function types. */ 31 | "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 32 | "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | 36 | /* Additional Checks */ 37 | "noUnusedLocals": true, /* Report errors on unused locals. */ 38 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 39 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 40 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 41 | 42 | /* Module Resolution Options */ 43 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 44 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 45 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 46 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 47 | // "typeRoots": [], /* List of folders to include type definitions from. */ 48 | // "types": [], /* Type declaration files to be included in compilation. */ 49 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 50 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 51 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 52 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 53 | 54 | /* Source Map Options */ 55 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 56 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 57 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 58 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 59 | 60 | /* Experimental Options */ 61 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 62 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 63 | } 64 | } 65 | --------------------------------------------------------------------------------