├── .gitignore ├── tslint.json ├── .editorconfig ├── test ├── UnsafeTuple.test.ts ├── readme.test.ts ├── helpers.test.ts ├── Tuple.test.ts ├── memoize.test.ts ├── WeakishMap.test.ts ├── ValueObject.test.ts ├── DeepCompositeSymbol.test.ts └── tuplerone.test.ts ├── .travis.yml ├── src ├── memoize.ts ├── ValueObject.ts ├── DeepCompositeSymbol.ts ├── tuplerone.ts ├── WeakishMap.ts ├── helpers.ts ├── types.ts └── Tuple.ts ├── tsconfig.json ├── CONTRIBUTING.md ├── tools ├── gh-pages-publish.ts └── semantic-release-prepare.ts ├── LICENSE ├── .github └── dependabot.yml ├── rollup.config.ts ├── logo.svg ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | .DS_Store 5 | *.log 6 | .vscode 7 | .idea 8 | dist 9 | compiled 10 | .awcache 11 | .rpt2_cache 12 | docs 13 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-config-slikts", "tslint-config-prettier"], 3 | "rules": { 4 | "function-name": false, 5 | "semicolon": [true, "always"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | #root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | max_line_length = 100 10 | indent_size = 2 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /test/UnsafeTuple.test.ts: -------------------------------------------------------------------------------- 1 | import { UnsafeTuple } from '../src/tuplerone'; 2 | 3 | describe(UnsafeTuple.name, () => { 4 | it('constructs', () => { 5 | expect(UnsafeTuple(1, 2)).toBeInstanceOf(UnsafeTuple.constructor); 6 | }); 7 | 8 | it('iterates', () => { 9 | expect([...UnsafeTuple(1, 2)]).toEqual([1, 2]); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | branches: 3 | only: 4 | - master 5 | - beta 6 | cache: npm 7 | notifications: 8 | email: false 9 | node_js: 10 | - node 11 | - 'lts/*' 12 | script: 13 | - npm run test:prod && npm run build 14 | after_success: 15 | - npm run report-coverage 16 | - npm run deploy-docs 17 | - npm run semantic-release 18 | -------------------------------------------------------------------------------- /src/memoize.ts: -------------------------------------------------------------------------------- 1 | import { getLeaf } from './Tuple'; 2 | import { getDefaultLazy } from './helpers'; 3 | 4 | const defaultCache = new WeakMap(); 5 | 6 | export const memoize = (fn: A, cache = defaultCache): A => { 7 | const memoized: any = function(this: any, ...args: any[]) { 8 | const node = getLeaf([memoized, this, ...args]); 9 | return getDefaultLazy(node, () => fn.apply(this, args), defaultCache); 10 | }; 11 | return memoized as A; 12 | }; 13 | -------------------------------------------------------------------------------- /test/readme.test.ts: -------------------------------------------------------------------------------- 1 | import { Tuple, Tuple0, Tuple1, Tuple2 } from '../src/tuplerone'; 2 | 3 | // Dummy object 4 | const o = {}; 5 | describe('readme examples', () => { 6 | it('work', () => { 7 | expect(Tuple()).toBeInstanceOf(Tuple.constructor); 8 | const tuple0: Tuple0 = Tuple(); // 0-tuple 9 | const tuple1: Tuple1 = Tuple(o); // 1-tuple 10 | const tuple2: Tuple2 = Tuple(o, 1); // 2-tuple 11 | // @ts-ignore 12 | Tuple(o) === Tuple(o, 1); // TS compile error due to different arities 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "moduleResolution": "node", 4 | "target": "es6", 5 | "module":"es2015", 6 | "lib": ["es2015", "es2016", "es2017", "dom"], 7 | "strict": true, 8 | "sourceMap": true, 9 | "declaration": true, 10 | "allowSyntheticDefaultImports": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "declarationDir": "dist/types", 14 | "outDir": "dist/lib", 15 | "typeRoots": [ 16 | "node_modules/@types" 17 | ] 18 | }, 19 | "include": [ 20 | "src" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/ValueObject.ts: -------------------------------------------------------------------------------- 1 | import DeepCompositeSymbol from './DeepCompositeSymbol'; 2 | 3 | /** 4 | * Works somewhat similarly to Record in the Record & Tuple proposal: 5 | * https://github.com/tc39/proposal-record-tuple 6 | */ 7 | // tslint:disable-next-line: variable-name 8 | const ValueObject = ( 9 | object: A, 10 | filter?: (entry: [string, any]) => boolean, 11 | ): A => { 12 | const key = DeepCompositeSymbol(object, filter); 13 | if (cache.has(key)) { 14 | return cache.get(key) as A; 15 | } 16 | cache.set(key, object); 17 | return object; 18 | }; 19 | 20 | const cache = new Map(); 21 | 22 | export default ValueObject; 23 | -------------------------------------------------------------------------------- /test/helpers.test.ts: -------------------------------------------------------------------------------- 1 | import { isObject, getDefault, getDefaultLazy } from '../src/helpers'; 2 | 3 | describe('helpers', () => { 4 | it('isNotPrimitive', () => { 5 | expect(isObject({})).toBe(true); 6 | expect(isObject('a')).toBe(false); 7 | expect(isObject(() => {})).toBe(true); 8 | }); 9 | 10 | it('getDefault', () => { 11 | const m = new Map(); 12 | expect(getDefault(1, 2, m)).toBe(2); 13 | }); 14 | 15 | it('getDefault existing', () => { 16 | const m = new Map().set(1, 2); 17 | expect(getDefault(1, 3, m)).toBe(2); 18 | }); 19 | 20 | it('getDefaultLazy', () => { 21 | const m = new Map(); 22 | expect(getDefaultLazy(1, () => 2, m)).toBe(2); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/Tuple.test.ts: -------------------------------------------------------------------------------- 1 | import Tuple, { getLeaf } from '../src/Tuple'; 2 | import WeakishMap from '../src/WeakishMap'; 3 | 4 | describe(Tuple.name, () => { 5 | const a = {}; 6 | const { tuple } = Tuple; 7 | it('constructor throws', () => { 8 | expect(() => new (Tuple as any)([1, {}], null)).toThrow(); 9 | }); 10 | 11 | it('static method constructs', () => { 12 | expect(tuple(1, {})).toBeInstanceOf(Tuple); 13 | }); 14 | 15 | it('iterates', () => { 16 | expect([...tuple(1, a)[Symbol.iterator]()]).toEqual([1, a]); 17 | expect([...tuple(1, a)]).toEqual([1, a]); 18 | }); 19 | 20 | it('can take spread params', () => { 21 | expect(tuple(...([1, a] as const))).toEqual([1, a]); 22 | }); 23 | }); 24 | 25 | describe('getLeaf', () => { 26 | it('supports unsafe param', () => { 27 | expect(getLeaf([1, 2, 3], true)).toBeInstanceOf(WeakishMap); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/DeepCompositeSymbol.ts: -------------------------------------------------------------------------------- 1 | import Tuple from './Tuple'; 2 | import { isObject } from './helpers'; 3 | 4 | /** 5 | * Recursively creates a "composite key" (like a "value identity") for 6 | * an object's entries (key-value pairs). 7 | */ 8 | // tslint:disable-next-line: variable-name 9 | const DeepCompositeSymbol = (object: any, filter?: (entry: [string, any]) => boolean) => { 10 | const entries = filter ? Object.entries(object).filter(filter) : Object.entries(object); 11 | // Recursively replace non-tuple object values with tuples 12 | entries.forEach(entry => update(entry, filter)); 13 | return Tuple.unsafeSymbol(...flatten(entries)); 14 | }; 15 | 16 | const update = (entry: any, filter?: any) => { 17 | const v = entry[1]; 18 | if (isObject(v) && !(v instanceof Tuple)) { 19 | entry[1] = DeepCompositeSymbol(v, filter); 20 | } 21 | }; 22 | 23 | const flatten = (entries: any[][]) => Array.prototype.concat.apply([], entries); 24 | 25 | export default DeepCompositeSymbol; 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | We're really glad you're reading this, because we need volunteer developers to help this project come to fruition. 👏 2 | 3 | ## Instructions 4 | 5 | These steps will guide you through contributing to this project: 6 | 7 | - Fork the repo 8 | - Clone it and install dependencies 9 | 10 | git clone https://github.com/YOUR-USERNAME/typescript-library-starter 11 | npm install 12 | 13 | Keep in mind that after running `npm install` the git repo is reset. So a good way to cope with this is to have a copy of the folder to push the changes, and the other to try them. 14 | 15 | Make and commit your changes. Make sure the commands npm run build and npm run test:prod are working. 16 | 17 | Finally send a [GitHub Pull Request](https://github.com/alexjoverm/typescript-library-starter/compare?expand=1) with a clear list of what you've done (read more [about pull requests](https://help.github.com/articles/about-pull-requests/)). Make sure all of your commits are atomic (one feature per commit). 18 | -------------------------------------------------------------------------------- /tools/gh-pages-publish.ts: -------------------------------------------------------------------------------- 1 | const { cd, exec, echo, touch } = require("shelljs") 2 | const { readFileSync } = require("fs") 3 | const url = require("url") 4 | 5 | let repoUrl 6 | let pkg = JSON.parse(readFileSync("package.json") as any) 7 | if (typeof pkg.repository === "object") { 8 | if (!pkg.repository.hasOwnProperty("url")) { 9 | throw new Error("URL does not exist in repository section") 10 | } 11 | repoUrl = pkg.repository.url 12 | } else { 13 | repoUrl = pkg.repository 14 | } 15 | 16 | let parsedUrl = url.parse(repoUrl) 17 | let repository = (parsedUrl.host || "") + (parsedUrl.path || "") 18 | let ghToken = process.env.GH_TOKEN 19 | 20 | echo("Deploying docs!!!") 21 | cd("docs") 22 | touch(".nojekyll") 23 | exec("git init") 24 | exec("git add .") 25 | exec('git config user.name "slikts"') 26 | exec('git config user.email "dabas@untu.ms"') 27 | exec('git commit -m "docs(docs): update gh-pages"') 28 | exec( 29 | `git push --force --quiet "https://${ghToken}@${repository}" master:gh-pages` 30 | ) 31 | echo("Docs deployed!!") 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 slikts 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/tuplerone.ts: -------------------------------------------------------------------------------- 1 | import { unsafe, tuple, unsafeSymbol, symbol } from './Tuple'; 2 | import DeepCompositeSymbol from './DeepCompositeSymbol'; 3 | import ValueObject from './ValueObject'; 4 | 5 | export { symbol as CompositeSymbol } from './Tuple'; 6 | export { memoize } from './memoize'; 7 | 8 | /** 9 | * A tuple whose members are allowed to all be primitive, 10 | * so it can't be garbage-collected and should only be used 11 | * in advanced contexts. 12 | */ 13 | // tslint:disable-next-line: variable-name 14 | export const UnsafeTuple = unsafe as typeof tuple; 15 | 16 | // tslint:disable-next-line: variable-name 17 | export const UnsafeCompositeSymbol = unsafeSymbol as typeof tuple; 18 | 19 | export { DeepCompositeSymbol, ValueObject, tuple as Tuple }; 20 | 21 | export { 22 | Tuple0, 23 | Tuple1, 24 | Tuple2, 25 | Tuple3, 26 | Tuple4, 27 | Tuple5, 28 | Tuple6, 29 | Tuple7, 30 | Tuple8, 31 | CompositeSymbol as CompositeSymbolType, 32 | CompositeSymbol0, 33 | CompositeSymbol1, 34 | CompositeSymbol2, 35 | CompositeSymbol3, 36 | CompositeSymbol4, 37 | CompositeSymbol5, 38 | CompositeSymbol6, 39 | CompositeSymbol7, 40 | CompositeSymbol8, 41 | } from './types'; 42 | -------------------------------------------------------------------------------- /test/memoize.test.ts: -------------------------------------------------------------------------------- 1 | import { memoize } from '../src/memoize'; 2 | 3 | describe(memoize.name, () => { 4 | it('returns a function', () => { 5 | expect(memoize(() => {})).toBeInstanceOf(Function); 6 | }); 7 | 8 | it('returns the same object', () => { 9 | const f = memoize((a: any) => ({})); 10 | const o = f(1); 11 | expect(f(1)).toBe(o); 12 | expect(f(1)).toBe(o); 13 | expect(f(2)).not.toBe(o); 14 | }); 15 | 16 | it('supports multiple arguments', () => { 17 | const f = memoize((a: any, b: any, c: any) => ({})); 18 | const o = f(1, 2, 3); 19 | expect(f(1, 2, 3)).toBe(o); 20 | expect(f(1, 2, 3)).toBe(o); 21 | expect(f(2, 3, 4)).not.toBe(o); 22 | }); 23 | 24 | it('supports setting receiver', () => { 25 | const f = memoize(function(this: any) { 26 | return this; 27 | }); 28 | expect(f.call(123)).toBe(123); 29 | }); 30 | 31 | it('receiver is memoized', () => { 32 | let n = 0; 33 | const f = memoize((x: any) => { 34 | n += x; 35 | return n; 36 | }); 37 | const o = {}; 38 | expect(f.call(o, 1)).toBe(1); 39 | expect(f.call(o, 1)).toBe(1); 40 | expect(f.call(o, 2)).toBe(3); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/WeakishMap.ts: -------------------------------------------------------------------------------- 1 | import { GenericMap } from './types'; 2 | import { isObject } from './helpers'; 3 | 4 | /** 5 | * A generic wrapper to Map and WeakMap that can use both non-primitives 6 | * (objects and symbols) and primitives as keys, creating the underlying 7 | * storage as needed. 8 | */ 9 | export default class WeakishMap implements GenericMap { 10 | private weakMap?: WeakMap; 11 | private map?: Map; 12 | 13 | set(k: A, v: B): this { 14 | if (isObject(k)) { 15 | if (!this.weakMap) { 16 | this.weakMap = new WeakMap(); 17 | } 18 | this.weakMap.set(k, v); 19 | } else { 20 | if (!this.map) { 21 | this.map = new Map(); 22 | } 23 | this.map.set(k, v); 24 | } 25 | return this; 26 | } 27 | 28 | get(k: A): B | undefined { 29 | if (isObject(k) && this.weakMap) { 30 | return this.weakMap.get(k); 31 | } 32 | if (this.map) { 33 | return this.map.get(k); 34 | } 35 | } 36 | 37 | has(k: A): boolean { 38 | if (isObject(k) && this.weakMap) { 39 | return this.weakMap.has(k); 40 | } 41 | if (this.map) { 42 | return this.map.has(k); 43 | } 44 | return false; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /test/WeakishMap.test.ts: -------------------------------------------------------------------------------- 1 | import WeakishMap from '../src/WeakishMap'; 2 | 3 | describe(WeakishMap.name, () => { 4 | it('is instantiable', () => { 5 | expect(new WeakishMap()).toBeInstanceOf(WeakishMap); 6 | }); 7 | 8 | it('can set/get primitives', () => { 9 | expect(new WeakishMap().set('a', 1).get('a')).toBe(1); 10 | }); 11 | 12 | it('can set/get objects', () => { 13 | const o = {}; 14 | expect(new WeakishMap().set(o, 1).get(o)).toBe(1); 15 | }); 16 | 17 | it('can test primitive membership', () => { 18 | expect(new WeakishMap().set('a', 1).has('a')).toBe(true); 19 | }); 20 | 21 | it('can test object membership', () => { 22 | const o = {}; 23 | const m = new WeakishMap().set(o, 1); 24 | expect(m.has(o)).toBe(true); 25 | expect(m.has({})).toBe(false); 26 | }); 27 | 28 | it('can get primitive', () => { 29 | const m = new WeakishMap(); 30 | expect(m.get(1)).toBe(undefined); 31 | m.set(1, 2); 32 | expect(m.get(1)).toBe(2); 33 | expect(m.get(2)).toBe(undefined); 34 | }); 35 | 36 | it('can set twice', () => { 37 | expect( 38 | new WeakishMap() 39 | .set('a', 1) 40 | .set('b', 2) 41 | .get('b'), 42 | ).toBe(2); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: y18n 10 | versions: 11 | - 4.0.1 12 | - dependency-name: typedoc 13 | versions: 14 | - 0.20.18 15 | - 0.20.20 16 | - 0.20.23 17 | - 0.20.25 18 | - 0.20.27 19 | - 0.20.28 20 | - 0.20.30 21 | - 0.20.32 22 | - 0.20.33 23 | - 0.20.34 24 | - dependency-name: rollup 25 | versions: 26 | - 2.38.0 27 | - 2.38.3 28 | - 2.38.5 29 | - 2.39.0 30 | - 2.40.0 31 | - 2.41.2 32 | - 2.42.2 33 | - 2.43.1 34 | - dependency-name: husky 35 | versions: 36 | - 4.3.8 37 | - 5.0.9 38 | - 5.1.0 39 | - 5.1.2 40 | - 5.1.3 41 | - 5.2.0 42 | - dependency-name: "@types/node" 43 | versions: 44 | - 14.14.22 45 | - 14.14.25 46 | - 14.14.28 47 | - 14.14.31 48 | - 14.14.32 49 | - 14.14.34 50 | - 14.14.35 51 | - dependency-name: "@types/jest" 52 | versions: 53 | - 26.0.20 54 | - 26.0.21 55 | - dependency-name: semantic-release 56 | versions: 57 | - 17.3.7 58 | - 17.3.9 59 | - 17.4.0 60 | - 17.4.1 61 | -------------------------------------------------------------------------------- /test/ValueObject.test.ts: -------------------------------------------------------------------------------- 1 | import { ValueObject } from '../src/tuplerone'; 2 | 3 | describe(ValueObject.name, () => { 4 | it('constructs', () => { 5 | expect(typeof ValueObject({ a: 1, b: 2 })).toBe('object'); 6 | }); 7 | 8 | it('structurally equals object', () => { 9 | expect(ValueObject({ a: 1, b: 2 })).toBe(ValueObject({ a: 1, b: 2 })); 10 | }); 11 | 12 | it('equals identical objects', () => { 13 | const o = { a: 1, b: 2 }; 14 | expect(ValueObject(o)).toBe(ValueObject(o)); 15 | }); 16 | 17 | it("doesn't equal structurally different object", () => { 18 | expect(ValueObject({ a: 1, b: 2 })).not.toBe(ValueObject({ a: 1, b: 3 })); 19 | }); 20 | 21 | it('supports deep structural equality', () => { 22 | const o = () => ({ a: { c: 1 }, b: 2 }); 23 | expect(ValueObject(o())).toBe(ValueObject(o())); 24 | expect(ValueObject(o())).not.toBe(ValueObject({})); 25 | }); 26 | 27 | it('changes reference if value changes', () => { 28 | const o = { a: { c: 1 }, b: 2 }; 29 | const vO1 = ValueObject(o); 30 | o.b = 3; 31 | const vO2 = ValueObject(o); 32 | 33 | expect(vO2.b).toBe(o.b); 34 | expect(vO1).not.toBe(vO2); 35 | expect(ValueObject(vO2)).toBe(ValueObject(vO2)); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /rollup.config.ts: -------------------------------------------------------------------------------- 1 | import resolve from 'rollup-plugin-node-resolve'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import sourceMaps from 'rollup-plugin-sourcemaps'; 4 | import camelCase from 'lodash.camelcase'; 5 | import json from 'rollup-plugin-json'; 6 | import ts from '@wessberg/rollup-plugin-ts'; 7 | 8 | const pkg = require('./package.json'); 9 | 10 | const libraryName = 'tuplerone'; 11 | 12 | export default { 13 | input: `src/${libraryName}.ts`, 14 | output: [ 15 | { file: pkg.main, name: camelCase(libraryName), format: 'umd', sourcemap: true }, 16 | { file: pkg.module, format: 'es', sourcemap: true }, 17 | ], 18 | // Indicate here external modules you don't wanna include in your bundle (i.e.: 'lodash') 19 | external: [], 20 | watch: { 21 | include: 'src/**', 22 | }, 23 | plugins: [ 24 | // Allow json resolution 25 | json(), 26 | // Compile TypeScript files 27 | ts(), 28 | // Allow bundling cjs modules (unlike webpack, rollup doesn't understand cjs) 29 | commonjs(), 30 | // Allow node_modules resolution, so you can use 'external' to control 31 | // which external modules to include in the bundle 32 | // https://github.com/rollup/rollup-plugin-node-resolve#usage 33 | resolve(), 34 | 35 | // Resolve source maps to the original source 36 | sourceMaps(), 37 | ], 38 | }; 39 | -------------------------------------------------------------------------------- /test/DeepCompositeSymbol.test.ts: -------------------------------------------------------------------------------- 1 | import { DeepCompositeSymbol } from '../src/tuplerone'; 2 | 3 | describe(DeepCompositeSymbol.name, () => { 4 | it('constructs', () => { 5 | expect(typeof DeepCompositeSymbol({ a: 1, b: 2 })).toBe('symbol'); 6 | }); 7 | 8 | it('structurally equals object', () => { 9 | expect(DeepCompositeSymbol({ a: 1, b: 2 })).toBe(DeepCompositeSymbol({ a: 1, b: 2 })); 10 | }); 11 | 12 | it("doesn't equal structurally different object", () => { 13 | expect(DeepCompositeSymbol({ a: 1, b: 2 })).not.toBe(DeepCompositeSymbol({ a: 1, b: 3 })); 14 | }); 15 | 16 | it('supports deep structural equality', () => { 17 | const o = () => ({ a: { c: 1 }, b: 2 }); 18 | expect(DeepCompositeSymbol(o())).toBe(DeepCompositeSymbol(o())); 19 | expect(DeepCompositeSymbol(o())).not.toBe(DeepCompositeSymbol({})); 20 | }); 21 | 22 | it('allows filtering by key', () => { 23 | const o1 = { a: { c: 1 }, b: 2, _d: 3 }; 24 | const o2 = { ...o1, _d: 4 }; 25 | const filter = ([key]: [string, any]) => !key.startsWith('_'); 26 | expect(DeepCompositeSymbol(o1, filter)).toBe(DeepCompositeSymbol(o2, filter)); 27 | expect(DeepCompositeSymbol(o1)).not.toBe(DeepCompositeSymbol(o2)); 28 | }); 29 | 30 | it('allows filtering by key recursively', () => { 31 | const o1 = { a: { c: 1 }, b: 2, _d: 3 }; 32 | const o2 = { ...o1, a: { ...o1.a, _e: 4 } }; 33 | const filter = ([key]: [string, any]) => !key.startsWith('_'); 34 | expect(DeepCompositeSymbol(o1, filter)).toBe(DeepCompositeSymbol(o2, filter)); 35 | expect(DeepCompositeSymbol(o1)).not.toBe(DeepCompositeSymbol(o2)); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /tools/semantic-release-prepare.ts: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const { fork } = require("child_process") 3 | const colors = require("colors") 4 | 5 | const { readFileSync, writeFileSync } = require("fs") 6 | const pkg = JSON.parse( 7 | readFileSync(path.resolve(__dirname, "..", "package.json")) 8 | ) 9 | 10 | pkg.scripts.prepush = "npm run test:prod && npm run build" 11 | pkg.scripts.commitmsg = "validate-commit-msg" 12 | 13 | writeFileSync( 14 | path.resolve(__dirname, "..", "package.json"), 15 | JSON.stringify(pkg, null, 2) 16 | ) 17 | 18 | // Call husky to set up the hooks 19 | fork(path.resolve(__dirname, "..", "node_modules", "husky", "bin", "install")) 20 | 21 | console.log() 22 | console.log(colors.green("Done!!")) 23 | console.log() 24 | 25 | if (pkg.repository.url.trim()) { 26 | console.log(colors.cyan("Now run:")) 27 | console.log(colors.cyan(" npm install -g semantic-release-cli")) 28 | console.log(colors.cyan(" semantic-release-cli setup")) 29 | console.log() 30 | console.log( 31 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 32 | ) 33 | console.log() 34 | console.log( 35 | colors.gray( 36 | 'Note: Make sure "repository.url" in your package.json is correct before' 37 | ) 38 | ) 39 | } else { 40 | console.log( 41 | colors.red( 42 | 'First you need to set the "repository.url" property in package.json' 43 | ) 44 | ) 45 | console.log(colors.cyan("Then run:")) 46 | console.log(colors.cyan(" npm install -g semantic-release-cli")) 47 | console.log(colors.cyan(" semantic-release-cli setup")) 48 | console.log() 49 | console.log( 50 | colors.cyan('Important! Answer NO to "Generate travis.yml" question') 51 | ) 52 | } 53 | 54 | console.log() 55 | -------------------------------------------------------------------------------- /test/tuplerone.test.ts: -------------------------------------------------------------------------------- 1 | import { Tuple, CompositeSymbol } from '../src/tuplerone'; 2 | 3 | describe(Tuple.name, () => { 4 | const a = Object('a'); 5 | const b = Object('b'); 6 | const c = Object('c'); 7 | 8 | it('1-tuple objects compare', () => { 9 | expect(Tuple(a)).toBe(Tuple(a)); 10 | }); 11 | 12 | it('2-tuple objects compare', () => { 13 | expect(Tuple(a, b)).toBe(Tuple(a, b)); 14 | }); 15 | 16 | it('3-tuple objects compare', () => { 17 | expect(Tuple(a, b, c)).toBe(Tuple(a, b, c)); 18 | expect(Tuple(a, b, c)).not.toBe(Tuple(a, b)); 19 | }); 20 | 21 | it('all-primitive tuples throw', () => { 22 | expect(() => Tuple(1, 2, 3)).toThrow(); 23 | }); 24 | 25 | it('root object tuple compares', () => { 26 | expect(Tuple(a, 1, 2)).toBe(Tuple(a, 1, 2)); 27 | }); 28 | 29 | it('non-root object tuple compares', () => { 30 | expect(Tuple(1, 2, a)).toBe(Tuple(1, 2, a)); 31 | }); 32 | 33 | it('2-tuple different roots not compare', () => { 34 | expect(Tuple(a, 1)).not.toBe(Tuple(a, 2)); 35 | }); 36 | 37 | it('2-tuple different roots reverse not compare', () => { 38 | expect(Tuple(1, a)).not.toBe(Tuple(2, a)); 39 | }); 40 | 41 | it('3-tuple different roots not compare', () => { 42 | expect(Tuple(a, 1, 2)).not.toBe(Tuple(a, 2, 1)); 43 | }); 44 | 45 | it('overlapping not compare', () => { 46 | expect(Tuple(a, 1, 2)).not.toBe(Tuple(a, 1)); 47 | expect(Tuple(1, a, 2)).not.toBe(Tuple(1, a)); 48 | }); 49 | }); 50 | 51 | describe('tuple symbol', () => { 52 | it('is of type symbol', () => { 53 | expect(typeof CompositeSymbol(1 as const, 2, {})).toBe('symbol'); 54 | }); 55 | 56 | it('compares', () => { 57 | const a = {}; 58 | expect(CompositeSymbol(1, 2, a)).toBe(CompositeSymbol(1, 2, a)); 59 | }); 60 | }); 61 | 62 | describe('other', () => { 63 | it('0-tuple', () => { 64 | expect(Tuple()).toBe(Tuple()); 65 | expect(Tuple()).not.toBe(Tuple({})); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { GenericMap, Indexable } from './types'; 2 | 3 | /** 4 | * Gets a map element, lazily initializing it with a default value. 5 | */ 6 | export const getDefaultLazy = (key: A, init: () => B, target: GenericMap): B => { 7 | if (!target.has(key)) { 8 | const value = init(); 9 | target.set(key, value); 10 | return value; 11 | } 12 | return target.get(key); 13 | }; 14 | 15 | /** 16 | * Gets a map element, initializing it with a default value. 17 | */ 18 | export const getDefault = (key: A, defaultValue: B, target: GenericMap): B => { 19 | if (!target.has(key)) { 20 | target.set(key, defaultValue); 21 | return defaultValue; 22 | } 23 | return target.get(key); 24 | }; 25 | 26 | /** 27 | * Tests if a value is an object. 28 | * 29 | * Doesn't test for symbols because symbols are invalid as `WeakMap` keys. 30 | */ 31 | export const isObject = (x: any): x is object => 32 | x !== null && (typeof x === 'object' || typeof x === 'function'); 33 | 34 | export const forEach = (iterator: Iterator, callback: (value: A) => void) => { 35 | do { 36 | const { value, done } = iterator.next(); 37 | if (done) { 38 | break; 39 | } 40 | callback(value); 41 | } while (true); 42 | }; 43 | 44 | /** 45 | * Sets all items from an iterable as index properties on the target object. 46 | */ 47 | export const assignArraylike = (iterator: Iterator, target: Indexable): number => { 48 | let i = 0; 49 | forEach(iterator, (value: A) => { 50 | target[i] = value; 51 | i += 1; 52 | }); 53 | return i; 54 | }; 55 | 56 | export const arraylikeToIterable = (source: ArrayLike): IterableIterator => { 57 | let i = 0; 58 | return { 59 | next() { 60 | let done; 61 | let value; 62 | if (i < source.length) { 63 | done = false; 64 | value = source[i]; 65 | i += 1; 66 | } else { 67 | done = true; 68 | // Issue: https://github.com/Microsoft/TypeScript/issues/2983 69 | value = undefined; 70 | } 71 | return { 72 | done, 73 | value, 74 | }; 75 | }, 76 | 77 | [Symbol.iterator]() { 78 | return arraylikeToIterable(source); 79 | }, 80 | }; 81 | }; 82 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import Tuple from './Tuple'; 2 | 3 | export interface Settable { 4 | set(key: A, value: B): this; 5 | } 6 | 7 | export interface Gettable { 8 | get(key: A): B | undefined; 9 | } 10 | 11 | export interface GenericMap extends GetSettable { 12 | has(key: A): boolean; 13 | } 14 | 15 | export type GetSettable = Settable & Gettable; 16 | 17 | export type Primitive = boolean | undefined | null | number | string | symbol; 18 | 19 | export interface Tuple0 extends Tuple { 20 | readonly length: 0; 21 | } 22 | /** Singleton */ 23 | export interface Tuple1 extends Tuple { 24 | readonly 0: A; 25 | readonly length: 1; 26 | } 27 | /** Pair */ 28 | export interface Tuple2 extends Tuple { 29 | readonly 0: A; 30 | readonly 1: B; 31 | readonly length: 2; 32 | } 33 | /** Triple */ 34 | export interface Tuple3 extends Tuple { 35 | readonly 0: A; 36 | readonly 1: B; 37 | readonly 2: C; 38 | readonly length: 3; 39 | } 40 | /** Quadruple */ 41 | export interface Tuple4 extends Tuple { 42 | readonly 0: A; 43 | readonly 1: B; 44 | readonly 2: C; 45 | readonly 3: D; 46 | readonly length: 4; 47 | } 48 | /** Quintuple */ 49 | export interface Tuple5 extends Tuple { 50 | readonly 0: A; 51 | readonly 1: B; 52 | readonly 2: C; 53 | readonly 3: D; 54 | readonly 4: E; 55 | readonly length: 5; 56 | } 57 | /** Sextuple */ 58 | export interface Tuple6 extends Tuple { 59 | readonly 0: A; 60 | readonly 1: B; 61 | readonly 2: C; 62 | readonly 3: D; 63 | readonly 4: E; 64 | readonly 5: F; 65 | readonly length: 6; 66 | } 67 | /** Septuple */ 68 | export interface Tuple7 extends Tuple { 69 | readonly 0: A; 70 | readonly 1: B; 71 | readonly 2: C; 72 | readonly 3: D; 73 | readonly 4: E; 74 | readonly 5: F; 75 | readonly 6: G; 76 | readonly length: 7; 77 | } 78 | /** Octuple */ 79 | export interface Tuple8 extends Tuple { 80 | readonly 0: A; 81 | readonly 1: B; 82 | readonly 2: C; 83 | readonly 3: D; 84 | readonly 4: E; 85 | readonly 5: F; 86 | readonly 6: G; 87 | readonly 7: H; 88 | readonly length: 8; 89 | } 90 | 91 | export type CompositeSymbol = { 92 | t: T; 93 | } & symbol; 94 | // tslint:disable-next-line: variable-name 95 | export const CompositeSymbol0: CompositeSymbol<[never]> = Symbol('CompositeSymbol0') as any; 96 | export type CompositeSymbol1 = CompositeSymbol<[A]>; 97 | export type CompositeSymbol2 = CompositeSymbol<[A, B]>; 98 | export type CompositeSymbol3 = CompositeSymbol<[A, B, C]>; 99 | export type CompositeSymbol4 = CompositeSymbol<[A, B, C, D]>; 100 | export type CompositeSymbol5 = CompositeSymbol<[A, B, C, D, E]>; 101 | export type CompositeSymbol6 = CompositeSymbol<[A, B, C, D, E, F]>; 102 | export type CompositeSymbol7 = CompositeSymbol<[A, B, C, D, E, F, G]>; 103 | export type CompositeSymbol8 = CompositeSymbol<[A, B, C, D, E, F, G, H]>; 104 | 105 | export interface Indexable { 106 | [i: number]: A; 107 | } 108 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tuplerone", 3 | "description": "A yet another tuple implementation", 4 | "keywords": [ 5 | "tuple", 6 | "weakmap", 7 | "identity", 8 | "objects", 9 | "maps", 10 | "data structures", 11 | "immutability", 12 | "memoization", 13 | "hashing", 14 | "value objects" 15 | ], 16 | "main": "dist/tuplerone.umd.js", 17 | "module": "dist/tuplerone.es5.js", 18 | "typings": "dist/types/tuplerone.d.ts", 19 | "files": [ 20 | "dist" 21 | ], 22 | "author": "slikts ", 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/slikts/tuplerone.git" 26 | }, 27 | "license": "MIT", 28 | "engines": { 29 | "node": ">=6.0.0" 30 | }, 31 | "scripts": { 32 | "lint": "tslint -t codeFrame 'src/**/*.ts' 'test/**/*.ts'", 33 | "prebuild": "rimraf dist", 34 | "build": "tsc --module commonjs && rollup -c rollup.config.ts && npm run build:doc", 35 | "build:doc": "typedoc --excludeNotExported --out docs --target es6 --theme minimal --mode file src", 36 | "start": "rollup -c rollup.config.ts -w", 37 | "test": "jest", 38 | "test:watch": "jest --watch", 39 | "test:prod": "npm run lint && npm run test -- --coverage --no-cache", 40 | "deploy-docs": "ts-node tools/gh-pages-publish", 41 | "report-coverage": "cat ./coverage/lcov.info | coveralls", 42 | "commit": "git-cz", 43 | "semantic-release": "semantic-release", 44 | "semantic-release-prepare": "ts-node tools/semantic-release-prepare" 45 | }, 46 | "lint-staged": { 47 | "{src,test}/**/*.ts": [ 48 | "prettier --write", 49 | "git add" 50 | ] 51 | }, 52 | "config": { 53 | "commitizen": { 54 | "path": "node_modules/cz-conventional-changelog" 55 | }, 56 | "validate-commit-msg": { 57 | "types": "conventional-commit-types", 58 | "helpMessage": "Use \"npm run commit\" instead, we use conventional-changelog format :) (https://github.com/commitizen/cz-cli)" 59 | } 60 | }, 61 | "jest": { 62 | "transform": { 63 | ".ts": "ts-jest" 64 | }, 65 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", 66 | "moduleFileExtensions": [ 67 | "ts", 68 | "tsx", 69 | "js" 70 | ], 71 | "coveragePathIgnorePatterns": [ 72 | "/node_modules/", 73 | "/test/" 74 | ], 75 | "coverageThreshold": { 76 | "global": { 77 | "branches": 90, 78 | "functions": 95, 79 | "lines": 95, 80 | "statements": 95 81 | } 82 | }, 83 | "collectCoverage": true 84 | }, 85 | "prettier": { 86 | "semi": true, 87 | "singleQuote": true, 88 | "trailingComma": "all" 89 | }, 90 | "devDependencies": { 91 | "@types/jest": "^26.0.24", 92 | "@types/node": "^16.4.10", 93 | "@wessberg/rollup-plugin-ts": "^1.3.14", 94 | "babel-jest": "^26.6.3", 95 | "colors": "^1.4.0", 96 | "commitizen": "^4.3.0", 97 | "coveralls": "^3.1.0", 98 | "cross-env": "^7.0.3", 99 | "cz-conventional-changelog": "^3.3.0", 100 | "husky": "^6.0.0", 101 | "jest": "^25.5.4", 102 | "lint-staged": "^13.2.2", 103 | "lodash.camelcase": "^4.3.0", 104 | "prettier": "^2.3.2", 105 | "prompt": "^1.3.0", 106 | "replace-in-file": "^6.2.0", 107 | "rimraf": "^4.1.1", 108 | "rollup": "^2.55.1", 109 | "rollup-plugin-commonjs": "^10.1.0", 110 | "rollup-plugin-json": "^4.0.0", 111 | "rollup-plugin-node-resolve": "^5.2.0", 112 | "rollup-plugin-sourcemaps": "^0.6.3", 113 | "semantic-release": "^21.0.3", 114 | "ts-jest": "^25.5.1", 115 | "ts-node": "^9.1.1", 116 | "tslint": "^5.20.1", 117 | "tslint-config-prettier": "^1.18.0", 118 | "tslint-config-slikts": "^2.0.4", 119 | "typedoc": "^0.20.37", 120 | "typescript": "^3.9.9", 121 | "typestrict": "1.0.2", 122 | "validate-commit-msg": "^2.14.0" 123 | }, 124 | "dependencies": {}, 125 | "bugs": "https://github.com/slikts/tuplerone/issues", 126 | "husky": { 127 | "hooks": { 128 | "commit-msg": "validate-commit-msg", 129 | "pre-commit": "lint-staged", 130 | "pre-push": "npm run test:prod && npm run build" 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/Tuple.ts: -------------------------------------------------------------------------------- 1 | import WeakishMap from './WeakishMap'; 2 | import { 3 | Tuple0, 4 | Tuple1, 5 | Tuple2, 6 | Tuple3, 7 | Tuple4, 8 | Tuple5, 9 | Tuple6, 10 | Tuple7, 11 | Tuple8, 12 | CompositeSymbol0, 13 | CompositeSymbol1, 14 | CompositeSymbol2, 15 | CompositeSymbol3, 16 | CompositeSymbol4, 17 | CompositeSymbol5, 18 | CompositeSymbol6, 19 | CompositeSymbol7, 20 | CompositeSymbol8, 21 | } from './types'; 22 | import { assignArraylike, arraylikeToIterable, getDefaultLazy, isObject } from './helpers'; 23 | 24 | export default class Tuple extends (Array as any) implements ArrayLike, Iterable { 25 | [i: number]: A; 26 | length: number = 0; 27 | 28 | /** 29 | * @throws {TypeError} Will throw if called non-locally; use the tuple() method instead. 30 | */ 31 | constructor(iterable: Iterable, confirm: typeof localToken) { 32 | super(); 33 | // TODO: make configurable or remove? it currently breaks subclassing 34 | if (confirm !== localToken) { 35 | throw TypeError('The `Tuple.tuple()` method must be used to construct'); 36 | } 37 | assignArraylike(iterable[Symbol.iterator](), this); 38 | Object.freeze(this); 39 | } 40 | 41 | /** 42 | * Constructs a tuple. 43 | */ 44 | static tuple( 45 | a: A, 46 | b: B, 47 | c: C, 48 | d: D, 49 | e: E, 50 | f: F, 51 | g: G, 52 | h: H, 53 | ): Tuple8; 54 | static tuple( 55 | a: A, 56 | b: B, 57 | c: C, 58 | d: D, 59 | e: E, 60 | f: F, 61 | g: G, 62 | ): Tuple7; 63 | static tuple(a: A, b: B, c: C, d: D, e: E, f: F): Tuple6; 64 | static tuple(a: A, b: B, c: C, d: D, e: E): Tuple5; 65 | static tuple(a: A, b: B, c: C, d: D): Tuple4; 66 | static tuple(a: A, b: B, c: C): Tuple3; 67 | static tuple(a: A, b: B): Tuple2; 68 | static tuple(a: A): Tuple1; 69 | static tuple(): Tuple0; 70 | static tuple(...values: unknown[]): unknown { 71 | // Special case for 0-tuples 72 | if (values.length === 0) { 73 | // Only construct if needed 74 | if (tuple0 === undefined) { 75 | tuple0 = new Tuple([], localToken) as any; 76 | } 77 | return tuple0; 78 | } 79 | return getDefaultLazy(tupleKey, () => new Tuple(values, localToken), getLeaf(values)); 80 | } 81 | static symbol( 82 | a: A, 83 | b: B, 84 | c: C, 85 | d: D, 86 | e: E, 87 | f: F, 88 | g: G, 89 | h: H, 90 | ): CompositeSymbol8; 91 | static symbol( 92 | a: A, 93 | b: B, 94 | c: C, 95 | d: D, 96 | e: E, 97 | f: F, 98 | g: G, 99 | ): CompositeSymbol7; 100 | static symbol( 101 | a: A, 102 | b: B, 103 | c: C, 104 | d: D, 105 | e: E, 106 | f: F, 107 | ): CompositeSymbol6; 108 | static symbol(a: A, b: B, c: C, d: D, e: E): CompositeSymbol5; 109 | static symbol(a: A, b: B, c: C, d: D): CompositeSymbol4; 110 | static symbol(a: A, b: B, c: C): CompositeSymbol3; 111 | static symbol(a: A, b: B): CompositeSymbol2; 112 | static symbol(a: A): CompositeSymbol1; 113 | static symbol(): typeof CompositeSymbol0; 114 | static symbol(...values: any[]): any { 115 | return getDefaultLazy(symbolKey, () => Symbol(), getLeaf(values)); 116 | } 117 | 118 | // The exported member is cast as the same type as Tuple.tuple() to avoid duplicating the overloads 119 | static unsafe(...values: any[]): any { 120 | return getDefaultLazy( 121 | tupleKey, 122 | () => new UnsafeTuple(values, localToken), 123 | getUnsafeLeaf(values), 124 | ); 125 | } 126 | 127 | static unsafeSymbol(...values: any[]): any { 128 | return getDefaultLazy(symbolKey, Symbol, getUnsafeLeaf(values)); 129 | } 130 | 131 | [Symbol.iterator](): IterableIterator { 132 | return arraylikeToIterable(this); 133 | } 134 | } 135 | 136 | // Root cache keys for each tuple type 137 | const tupleKey = Symbol(); 138 | const symbolKey = Symbol(); 139 | 140 | const cache = new WeakishMap(); 141 | 142 | // Token used to prevent calling the constructor from other modules 143 | const localToken = Symbol(); 144 | 145 | const initWeakish = () => new WeakishMap(); 146 | let tuple0: Tuple0; 147 | 148 | /** 149 | * Tries to use the first non-primitive from value list as the root key and throws 150 | * if there's only primitives. 151 | */ 152 | export const getLeaf = (values: any[], unsafe?: boolean): WeakishMap => { 153 | const rootValue = values.find(isObject); 154 | if (!rootValue && !unsafe) { 155 | // Throw since it's not possible to weak-reference objects by primitives, only by other objects 156 | throw TypeError('At least one value must be of type object'); 157 | } 158 | // If the first value is not an object, pad the values with the first object 159 | const root = rootValue === values[0] ? cache : getDefaultLazy(rootValue, initWeakish, cache); 160 | return values.reduce((prev, curr) => getDefaultLazy(curr, initWeakish, prev), root); 161 | }; 162 | 163 | // Unsafe tuples aren't garbage collected so it's more efficient to just use a normal map 164 | const unsafeCache = new Map(); 165 | const initUnsafe = () => new Map(); 166 | class UnsafeTuple extends Tuple {} 167 | /** 168 | * A memory-leaky, slightly more efficient version of `getLeaf()`. 169 | */ 170 | export const getUnsafeLeaf = (values: any[]): Map => 171 | values.reduce((prev, curr) => getDefaultLazy(curr, initUnsafe, prev), unsafeCache); 172 | 173 | export const { tuple, symbol, unsafe, unsafeSymbol } = Tuple; 174 | 175 | // Expose constructor to be used for `instanceof` 176 | tuple.constructor = Tuple; 177 | unsafe.constructor = UnsafeTuple; 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

tuplerone

2 | 3 |

4 | View this project on npm 5 | Travis 6 | Coveralls 7 | Dev Dependencies 8 | semantic-release 9 | Dependabot 10 |

11 | 12 |

A lightweight, efficient tuple and value object implementation for JavaScript and TypeScript.

13 | 14 | --- 15 | 16 | A quick reminder about what tuples are (using Python): 17 | 18 | ```python 19 | (1, 2, 3) == (1, 2, 3) # → True 20 | ``` 21 | 22 | A JavaScript version of something similar looks like this: 23 | 24 | ```js 25 | '[1,2,3]' === '[1,2,3]'; // → true 26 | ``` 27 | 28 | Except it's using a string and would need to be unserialized with `JSON.parse()` to allow accessing the separate members. Moreover, JSON is limited in what values can be serialized. 29 | 30 | You could alternatively use `"1,2,3"` and `String.split(",")`, but it's also not very convenient. Just using an array doesn't work: 31 | 32 | ```js 33 | [1, 2, 3] === [1, 2, 3]; // → false 34 | ``` 35 | 36 | Each JavaScript array is a different object and so its value is the reference to that object. Tuples are a way to make that reference the same if the array members are the same. Using Tuplerone: 37 | 38 | ```js 39 | Tuple(1, 2, 3) === Tuple(1, 2, 3); 40 | ``` 41 | 42 | Example use case for tuples is dealing with memoization like React's [`memo()`][memo] or `PureComponent`, since you can pass lists as props to components without forcing re-renders or manually caching the list. It's also useful for using multiple values as keys with `Map()`. In general, it's just a nice thing to have in your toolbox. 43 | 44 | **[Try Tuplerone in a sandbox][sandbox]** 45 | 46 | --- 47 | 48 | This library is: 49 | 50 | - _tiny_ (bundle size is [under one kilobyte][tiny] compressed), with no dependencies 51 | - _well-typed_ using TypeScript (but can still be used from JavaScript, of course) 52 | - _well-tested_ with full coverage 53 | - _efficient_ using an ES2015 [`WeakMap`][weakmap]-based directed acyclic graph for lookups 54 | 55 | The `Tuple` objects are: 56 | 57 | - _immutable_ – properties cannot be added, removed or changed, and it's enforced with [`Object.freeze()`][frozen] 58 | - [array-like] – tuple members can be accessed by indexing, and there's a `length` property, but no `Array` prototype methods 59 | - [iterable] – tuple members can be iterated over, for example, using [`for-of`][for-of] loops or spread syntax 60 | 61 | There exists a [stage-1 proposal][proposal] for adding a tuple type to JavaScript and a [different stage-1 proposal][composite] for adding a more limited value-semantic type. 62 | 63 | ## Theory 64 | 65 | [Tuples] are **finite ordered sequences of values** that serve two main purposes in programming languages: 66 | 67 | - grouping together heterogenous (mixed) data types within a static type system (this doesn't apply to a dynamically typed language like JavaScript) 68 | - simplifying value-semantic comparisons of lists, which is what this library is mainly about 69 | 70 | ### Value semantics 71 | 72 | A simple way to explain value semantics is to look at the difference between primitive values (like numbers and strings) and object values in JavaScript. Primitives are value-semantic by default, 73 | meaning that the default comparison methods (`==`, `===` and `Object.is()`) compare primitive values by their contents, so, for example, any string is equal to any other string created with the same contents: 74 | 75 | ```js 76 | 'abc' === 'abc'; // → true, because both string literals create a value with the same contents 77 | ``` 78 | 79 | The contents of primitive values are also immutable (can't change at runtime), so the results of comparing primitive value equality can't be invalidated by the contents of the values changing. 80 | 81 | Meanwhile, each object value (instance) in JavaScript has a unique identity, so each instance is only equal to itself and not any other instances: 82 | 83 | ```js 84 | [1, 2, 3] === [1, 2, 3]; // → false, because both array literals create separate array instances 85 | ``` 86 | 87 | Objects by default can't be thought of as their contents since the contents can change, and this is called reference semantics, since objects essentially represent a place in memory. The downside is that it makes reasoning about a program harder, since the programmer has to consider potential changes. 88 | 89 | A more direct practical consequence of reference semantics is that comparing instances requires _deep comparisons_, such as [`_.isEqual()`][isequal] in lodash or serializing the object values to JSON: 90 | 91 | ```js 92 | let a = [1, 2, 3]; 93 | let b = [1, 2, 3]; 94 | let result = JSON.stringify(a) === JSON.stringify(b); // → true, because it's a deep comparison 95 | a.push(4); // a and b contents are now different, so the cached comparison result is invalid 96 | ``` 97 | 98 | Deep comparison results can't be reliably cached since the compared instances can change, and it's also less efficient than just being able to use `===` directly. An another thing that's not possible with reference semantics is combining different values to use as a composite key (such as with `Map` or `WeakMap`). 99 | 100 | ### Directed acyclic graphs 101 | 102 | Directed acyclic graphs (DAGs) are a data structure that allows efficiently mapping a sequence of values to a unique object containing them, which is how this library is implemented. Specifically, it uses a `WeakMap` object (optionally a `Map` as well if mapping primitives) for each node, and the nodes are re-used for overlapping paths in the graph. Map access has constant time complexity, so the number of tuples created doesn't slow down access speed. Using `WeakMap` ensures that if the values used to create the tuple are dereferenced, the tuple object gets garbage collected. 103 | 104 | ## Installation 105 | 106 | ### npm 107 | 108 | ``` 109 | npm install tuplerone 110 | ``` 111 | 112 | ### yarn 113 | 114 | ``` 115 | yarn add tuplerone 116 | ``` 117 | 118 | ### CDN 119 | 120 | https://unpkg.com/tuplerone/dist/tuplerone.umd.js 121 | 122 | ## Usage 123 | 124 | ### `Tuple(…values)` 125 | 126 | ```js 127 | import { Tuple } from 'tuplerone'; 128 | 129 | // Dummy objects 130 | const a = Object('a'); 131 | const b = Object('b'); 132 | const c = Object('c'); 133 | 134 | // Structural equality testing using the identity operator 135 | Tuple(a, b, c) === Tuple(a, b, c); // → true 136 | Tuple(a, b) === Tuple(b, a); // → false 137 | 138 | // Mapping using a pair of values as key 139 | const map = new Map(); 140 | map.set(Tuple(a, b), 123).get(Tuple(a, b)); // → 123 141 | 142 | // Nesting tuples 143 | Tuple(a, Tuple(b, c)) === Tuple(a, Tuple(b, c)); // → true 144 | 145 | // Using primitive values 146 | Tuple(1, 'a', a); // → Tuple(3) [1, "a", Object("a")] 147 | 148 | // Indexing 149 | Tuple(a, b)[1]; // → Object("b") 150 | 151 | // Checking arity 152 | Tuple(a, b).length; // → 2 153 | 154 | // Failing to mutate 155 | Tuple(a, b)[0] = c; // throws an error 156 | ``` 157 | 158 | The tuple function caches or memoizes its arguments to produce the same tuple object for the same arguments. 159 | 160 | ### Types 161 | 162 | The library is well-typed using TypeScript: 163 | 164 | ```ts 165 | import { Tuple, Tuple0, Tuple1, Tuple2 } from 'tuplerone'; 166 | 167 | // Dummy object for use as key 168 | const o = {}; 169 | 170 | const tuple0: Tuple0 = Tuple(); // 0-tuple 171 | const tuple1: Tuple1 = Tuple(o); // 1-tuple 172 | const tuple2: Tuple2 = Tuple(o, 1); // 2-tuple 173 | 174 | Tuple(o) === Tuple(o, 1); // TS compile error due to different arities 175 | 176 | // Spreading a TypeScript tuple: 177 | Tuple(...([1, 2, 3] as const)); // → Tuple3<1, 2, 3> 178 | ``` 179 | 180 | In editors like VS Code, the type information is also available when the library is consumed as JavaScript. 181 | 182 | ### `CompositeSymbol(…values)` 183 | 184 | It's possible to avoid creating an `Array`-like tuple for cases where iterating the tuple members isn't needed (for example, just to use it as a key): 185 | 186 | ```js 187 | import { CompositeSymbol } from 'tuplerone'; 188 | 189 | typeof CompositeSymbol(1, 2, {}) === 'symbol'; // → true 190 | ``` 191 | 192 | A symbol is more space efficient than a tuple and can be used as a key for plain objects. 193 | 194 | ### `ValueObject(object)` 195 | 196 | Tuplerone also includes a simple [value object] implementation: 197 | 198 | ```js 199 | import { ValueObject } from 'tuplerone'; 200 | 201 | ValueObject({ a: 1, { b: { c: 2 } }}) === ValueObject({ a: 1, { b: { c: 2 } }}); // → true 202 | ``` 203 | 204 | Note that the passed objects are frozen with [`Object.freeze()`][frozen]. 205 | 206 | ## Caveats 207 | 208 | Since this is a userspace implementation, there are a number of limitations. 209 | 210 | ### At least one member must be an object to avoid memory leaks 211 | 212 | Due to `WeakMap` being limited to using objects as keys, there must be at least one member of a tuple with the object type, or the tuples would leak memory. Trying to create tuples with only primitive members will throw an error. 213 | 214 | ```ts 215 | Tuple(1, 2); // throws TypeError 216 | Tuple(1, 2, {}); // works 217 | ``` 218 | 219 | `WeakMap` is an ES2015 feature which is difficult to polyfill (the [polyfills][polyfill] don't support frozen objects), but this applies less to environments like node or browser extensions. 220 | 221 | #### `UnsafeTuple` 222 | 223 | There is an `UnsafeTuple` type for advanced use cases where the values not being garbage-collectable is acceptable, so it doesn't require having an object member: 224 | 225 | ```js 226 | import { UnsafeTuple as Tuple } from 'tuplerone'; 227 | 228 | Tuple(1, 2, 3) === Tuple(1, 2, 3); // → true 229 | ``` 230 | 231 | ### Can't be compared with operators like `<` or `>` 232 | 233 | tuplerone tuples are not supported by the relation comparison operators like `<`, whereas in a language like Python the following (comparing tuples by arity) would evaluate to true: `(1,) < (1, 2)`. 234 | 235 | ### `Array`-like but there's no `Array` prototypes methods 236 | 237 | Tuples subclass `Array`: 238 | 239 | ```typescript 240 | Array.isArray(Tuple()); // → true 241 | ``` 242 | 243 | Yet tuples don't support mutative `Array` prototype methods like `Array.sort()`, since tuples are frozen. 244 | 245 | The advantage of subclassing `Array` is ergonomic console representation (it's represented as an array would be), which is based on `Array.isArray()` and so requires subclassing `Array`. 246 | 247 | ### Limited number of arities 248 | 249 | The tuples are currently typed up to 8-tuple (octuple) because TypeScript doesn't yet support [variadic generics]. The types are implemented using function overloads. 250 | 251 | ### `instanceof` doesn't work as expected 252 | 253 | Tuples can be constructed without the `new` keyword to make them behave like other primitive values 254 | (`Symbol`, `Boolean`, `String`, `Number`) that also don't require `new` and also are value-semantic. This means that `instanceof` doesn't work the same as for other objects, but can still be used like so: 255 | 256 | ```js 257 | Tuple() instanceof Tuple.constructor; // → true 258 | ``` 259 | 260 | ## License 261 | 262 | MIT 263 | 264 | ## Author 265 | 266 | slikts 267 | 268 | [weakmap]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap 269 | [map]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map 270 | [tuples]: https://en.wiktionary.org/wiki/tuple 271 | [isequal]: https://lodash.com/docs/4.17.10#isEqual 272 | [frozen]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze 273 | [composite]: https://github.com/bmeck/proposal-richer-keys/tree/master/compositeKey 274 | [iterable]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#The_iterable_protocol 275 | [tuple]: https://en.wiktionary.org/wiki/tuple 276 | [array-like]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Indexed_collections#Working_with_array-like_objects 277 | [for-of]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/for...of 278 | [tiny]: https://bundlephobia.com/result?p=tuplerone 279 | [polyfill]: https://github.com/medikoo/es6-weak-map#readme 280 | [value semantics]: https://en.wikipedia.org/wiki/Value_semantics 281 | [value types]: https://en.wikipedia.org/wiki/Value_type_and_reference_type 282 | [isequal]: https://lodash.com/docs/#isEqual 283 | [proposal]: https://github.com/tc39/proposal-record-tuple 284 | [memo]: https://reactjs.org/docs/react-api.html#reactmemo 285 | [variadic generics]: https://github.com/microsoft/TypeScript/issues/5453 286 | [sandbox]: https://codesandbox.io/s/tuplerone-dm90w?expanddevtools=1 287 | [value object]: https://en.wikipedia.org/wiki/Value_object 288 | --------------------------------------------------------------------------------