├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── package.json ├── src └── index.js └── test ├── index.js └── mocha.opts /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0"], 3 | "plugins": [ 4 | "transform-decorators-legacy" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "ecmaFeatures": { 4 | "modules": true 5 | }, 6 | "env": { 7 | "node": true, 8 | "es6": true, 9 | "mocha": true 10 | }, 11 | "parser": "babel-eslint", 12 | "rules": { 13 | "array-bracket-spacing": [ 2, "always" ], 14 | "comma-dangle": [ 2, "never" ], 15 | "eol-last": 2, 16 | "indent": [ 2, 2, { "SwitchCase": 1 } ], 17 | "no-multiple-empty-lines": 2, 18 | "no-unused-vars": 2, 19 | "no-var": 2, 20 | "object-curly-spacing": [ 2, "always" ], 21 | "quotes": [ 2, "single", "avoid-escape" ], 22 | "semi": [ 2, "never" ], 23 | "strict": 0, 24 | "space-before-blocks": [ 2, "always" ], 25 | "space-before-function-paren": [ 2, { "anonymous": "always", "named": "never" } ], 26 | "valid-jsdoc": 2 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /*.log 4 | /tmp 5 | /lib 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | script: 5 | - npm run lint 6 | - npm test 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [2.1.0] - 2016-06-22 4 | 5 | - Better handling of bound methods 6 | - Support static methods and properties 7 | - Support for prototype altering after class definition 8 | 9 | ## [2.0.1] - 2016-04-08 10 | 11 | - Made properties `configurable: true` 12 | 13 | ## [2.0.0] - 2016-03-22 14 | ### Added `__origInstance` 15 | For easier debugging in console, added `__origInstance` to every class with private methods. 16 | 17 | ### API Change 18 | 19 | #### `1.x` 20 | 21 | ```js 22 | import klass from 'class-private-method-decorator' 23 | 24 | @klass 25 | class C { 26 | @klass.private 27 | p() {} 28 | } 29 | ``` 30 | 31 | #### `2.0.0` 32 | 33 | ```js 34 | import { classWithPrivateMethods, privateMethod } from 'class-private-method-decorator' 35 | 36 | @classWithPrivateMethods 37 | class C { 38 | @privateMethod 39 | p() {} 40 | } 41 | ``` 42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # class-private-method-decorator 2 | 3 | > Private methods in a JavaScript ES6 class using an ES7 decorator 4 | 5 | [![build status](https://img.shields.io/travis/elado/class-private-method-decorator/master.svg?style=flat-square)](https://travis-ci.org/elado/class-private-method-decorator) [![npm version](https://img.shields.io/npm/v/class-private-method-decorator.svg?style=flat-square)](https://www.npmjs.com/package/class-private-method-decorator) [![codeclimate](https://img.shields.io/codeclimate/github/elado/class-private-method-decorator.svg?style=flat-square)](https://codeclimate.com/github/elado/class-private-method-decorator) 6 | 7 | This decorator wraps a class with another, exposing only public and static methods and properties. Behind the scenes, it creates properties for public methods on the prototype of the wrapped class. The properties return proxied functions to the original instance. 8 | 9 | It supports inheritance and prototype changes even after class definition. 10 | 11 | ## Installation 12 | 13 | ```sh 14 | npm install class-private-method-decorator --save 15 | ``` 16 | 17 | Requires `babel` with `babel-plugin-transform-decorators-legacy` plugin. 18 | 19 | ## Usage 20 | 21 | ### Basic private methods 22 | 23 | ```js 24 | import { classWithPrivateMethods, privateMethod } from 'class-private-method-decorator' 25 | 26 | @classWithPrivateMethods 27 | class SomeClass { 28 | publicMethod() { 29 | return 1 30 | } 31 | 32 | publicMethodThatUsesAPrivateMethod() { 33 | return 2 + this.privateMethod() 34 | } 35 | 36 | @privateMethod 37 | privateMethod() { 38 | return 3 39 | } 40 | } 41 | 42 | const something = new SomeClass() 43 | something.publicMethod() // => 1 44 | something.privateMethod() // => TypeError: something.privateMethod is not a function 45 | something.publicMethodThatUsesAPrivateMethod() // => 5 46 | ``` 47 | 48 | ### Extending prototype after class definition 49 | 50 | Since this library creates a wrapper class, extending its prototype after definition needs an extra step of extending the original class' prototype. `extendClassWithPrivateMethods` takes care of that. 51 | 52 | ```js 53 | import EventEmitter from 'events' 54 | import { classWithPrivateMethods, privateMethod, extendClassWithPrivateMethods } from 'class-private-method-decorator' 55 | 56 | @classWithPrivateMethods 57 | class SomeClass { 58 | cancel() { 59 | this.emit('cancel') 60 | } 61 | } 62 | 63 | extendClassWithPrivateMethods(SomeClass, EventEmitter.prototype) 64 | 65 | const instance = new SomeClass() 66 | 67 | instance.on('cancel', () => { 68 | console.log('cancelled!') 69 | }) 70 | 71 | instance.cancel() // cancelled! 72 | ``` 73 | 74 | ### `__origInstance` 75 | 76 | The original instance with all the methods can be accessed by `__origInstance` property. Useful for debugging. 77 | 78 | ```js 79 | something.__origInstance.privateMethod() // => 3 80 | ``` 81 | 82 | ### `__origClass` 83 | 84 | The original class as a property of the wrapper class. 85 | 86 | ```js 87 | @classWithPrivateMethods 88 | class SomeClass { 89 | } 90 | 91 | SomeClass.__origClass // will return the unwrapped SomeClass 92 | ``` 93 | 94 | ## Test 95 | 96 | ```sh 97 | npm install 98 | npm test 99 | ``` 100 | 101 | ## License 102 | 103 | MIT 104 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "class-private-method-decorator", 3 | "version": "2.1.2", 4 | "description": "Private methods in a class using an ES decorator", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "mocha", 8 | "watch:test": "npm test -- -w", 9 | "build:clean": "rimraf lib dist", 10 | "build:js": "babel -d lib/ src/", 11 | "build": "npm run build:clean && npm run build:js", 12 | "prepublish": "npm run build", 13 | "test:cov": "babel-node ./node_modules/isparta/bin/isparta cover ./node_modules/mocha/bin/_mocha -- --recursive", 14 | "lint": "eslint src test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git://github.com/elado/class-private-method-decorator.git" 19 | }, 20 | "author": "Elad Ossadon (http://github.com/elado)", 21 | "license": "MIT", 22 | "devDependencies": { 23 | "assert": "^1.4.1", 24 | "babel-cli": "^6.10.1", 25 | "babel-core": "^6.10.4", 26 | "babel-eslint": "^6.1.0", 27 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 28 | "babel-preset-es2015": "^6.9.0", 29 | "babel-preset-stage-0": "^6.5.0", 30 | "eslint": "^2.13.1", 31 | "isparta": "^4.0.0", 32 | "mocha": "^2.5.3", 33 | "rimraf": "^2.5.2", 34 | "watch": "^0.19.1" 35 | }, 36 | "files": [ 37 | "lib" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const _privates = [] 2 | 3 | const BASE_OBJECT_PROTOTYPE = Object.prototype 4 | 5 | export function classWithPrivateMethods(target) { 6 | class ClassWrapper { 7 | constructor(...args) { 8 | const instance = new target(...args) 9 | 10 | Object.defineProperties(this, { 11 | __origInstance: { value: instance }, 12 | __classWithPrivateMethodsMethodMap: { value: {} } 13 | }) 14 | } 15 | } 16 | 17 | hoistPublicMethods(ClassWrapper, target) 18 | hoistStaticMethods(ClassWrapper, target) 19 | 20 | Object.defineProperties(ClassWrapper, { 21 | __origClass: { value: target, enumerable: false, configurable: true }, 22 | __origInstance: { value: target, enumerable: false, configurable: true }, 23 | __classWithPrivateMethodsMethodMap: { value: {} } 24 | }) 25 | 26 | return ClassWrapper 27 | } 28 | 29 | function hoistPublicMethods(ClassWrapper, target) { 30 | const proto = target.prototype 31 | let methodNames = Object.getOwnPropertyNames(proto) 32 | 33 | // add all methods from base class, if any 34 | const baseClassPrototype = Object.getPrototypeOf(proto) 35 | if (baseClassPrototype !== BASE_OBJECT_PROTOTYPE) { 36 | methodNames = methodNames.concat(Object.getOwnPropertyNames(baseClassPrototype)) 37 | } 38 | 39 | methodNames = methodNames.filter(m => m !== 'constructor') 40 | 41 | const privatesForTarget = new Set(_privates.filter(e => e.target === target).map(e => e.name)) 42 | 43 | methodNames 44 | .filter(methodName => !privatesForTarget.has(methodName)) 45 | .forEach(methodName => addBoundMethod(ClassWrapper.prototype, methodName, proto)) 46 | } 47 | 48 | const NATIVE_STATICS = 'length,name,prototype,__proto__,arguments,caller'.split(',') 49 | 50 | function hoistStaticMethods(ClassWrapper, target) { 51 | const statics = Object.getOwnPropertyNames(target).filter(k => NATIVE_STATICS.indexOf(k) === -1) 52 | 53 | // static methods - bind methods to original class 54 | statics 55 | .filter(k => typeof target[k] === 'function') 56 | .forEach(methodName => addBoundMethod(ClassWrapper, methodName, target)) 57 | 58 | // static properties - create accessors 59 | statics 60 | .filter(k => typeof target[k] !== 'function') 61 | .forEach(k => Object.defineProperty(ClassWrapper, k, { 62 | get() { return target[k] }, 63 | set(v) { return target[k] = v } 64 | })) 65 | } 66 | 67 | // adds a property to *target* with: 68 | // - a memoized getter that returns a bound method to the current instance 69 | // - a setter that sets both wrapper and instance's method 70 | function addBoundMethod(target, methodName, methodsContainer) { 71 | Object.defineProperty( 72 | target, 73 | methodName, 74 | { 75 | configurable: true, 76 | get() { 77 | // direct read from Class.prototype.method needs to return the method itself 78 | if (!this.__classWithPrivateMethodsMethodMap) return methodsContainer[methodName] 79 | // reads from an instance need to return a bound method to the instance 80 | return this.__classWithPrivateMethodsMethodMap[methodName] || (this.__classWithPrivateMethodsMethodMap[methodName] = methodsContainer[methodName].bind(this.__origInstance)) 81 | }, 82 | set(newFn) { 83 | if (!this.__classWithPrivateMethodsMethodMap) return methodsContainer[methodName] = newFn 84 | this.__classWithPrivateMethodsMethodMap[methodName] = newFn.bind(this.__origInstance) 85 | this.__origInstance[methodName] = newFn 86 | } 87 | } 88 | ) 89 | } 90 | 91 | export function extendClassWithPrivateMethods(target, obj) { 92 | obj = Object.assign({}, obj) 93 | Object.assign(target.__origClass.prototype, obj) 94 | 95 | const objProperties = Object.getOwnPropertyNames(obj) 96 | 97 | objProperties 98 | .filter(k => typeof obj[k] === 'function') 99 | .forEach(k => addBoundMethod(target.prototype, k, obj)) 100 | 101 | objProperties 102 | .filter(k => typeof obj[k] !== 'function') 103 | .forEach(k => target.prototype[k] = obj[k]) 104 | } 105 | 106 | export function privateMethod(target, name) { 107 | _privates.push({ target: target.constructor, name }) 108 | } 109 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { classWithPrivateMethods, privateMethod, extendClassWithPrivateMethods } from '../src' 3 | import EventEmitter from 'events' 4 | 5 | describe('@classWithPrivateMethods', function () { 6 | it('hides private methods', function () { 7 | @classWithPrivateMethods 8 | class Something { 9 | public1(text) { return `public1:${text}` } 10 | 11 | @privateMethod 12 | private2(text) { return `private2:${text}` } 13 | } 14 | 15 | const instance = new Something() 16 | assert.equal(instance.public1('hello'), 'public1:hello') 17 | assert(!('private2' in instance)) 18 | }) 19 | 20 | it('leaves all methods as public by default', function () { 21 | @classWithPrivateMethods 22 | class Something { 23 | public1(text) { return `public1:${text}` } 24 | public2(text) { return `public2:${text}` } 25 | } 26 | 27 | const instance = new Something() 28 | assert.equal(instance.public1('hello'), 'public1:hello') 29 | assert.equal(instance.public2('hello'), 'public2:hello') 30 | }) 31 | 32 | it('caches bound methods', function () { 33 | @classWithPrivateMethods 34 | class Something { 35 | public1(text) { return `public1:${text}` } 36 | } 37 | 38 | const instance = new Something() 39 | assert.equal(instance.public1, instance.public1) 40 | }) 41 | 42 | it('can still call private methods in the class', function () { 43 | @classWithPrivateMethods 44 | class Something { 45 | public1(text) { return `public1:${text}|${this.private2(text)}` } 46 | 47 | @privateMethod 48 | private2(text) { return `private2:${text}` } 49 | } 50 | 51 | const instance = new Something() 52 | assert.equal(instance.public1('hello'), 'public1:hello|private2:hello') 53 | }) 54 | 55 | it('can set a new method by assigning a function, and bind to the instance', function () { 56 | @classWithPrivateMethods 57 | class Something { 58 | public1(text) { return `public1:${text}` } 59 | public2(text) { return `public2:${text}` } 60 | } 61 | 62 | const instance = new Something() 63 | assert.equal(instance.public1('hello'), 'public1:hello') 64 | assert.equal(instance.public2('hello'), 'public2:hello') 65 | 66 | instance.public2 = function (text) { return `patched public2|${this.public1(text)}` } 67 | assert.equal(instance.public2('hello'), 'patched public2|public1:hello') 68 | }) 69 | 70 | context('inheritance', function () { 71 | it('can call inherited methods', function () { 72 | class Base { 73 | baseMethod(text) { return `baseMethod:${text}` } 74 | } 75 | 76 | @classWithPrivateMethods 77 | class Something extends Base { 78 | public1(text) { return `public1:${text}` } 79 | } 80 | 81 | const instance = new Something() 82 | assert.equal(instance.baseMethod('hello'), 'baseMethod:hello') 83 | }) 84 | 85 | it('can call inherited methods from a classWithPrivateMethods base class', function () { 86 | @classWithPrivateMethods 87 | class Base { 88 | basePublic1(text) { return `basePublic1:${text}` } 89 | 90 | @privateMethod 91 | basePrivate2(text) { return `basePrivate2:${text}` } 92 | } 93 | 94 | @classWithPrivateMethods 95 | class Something extends Base { 96 | public1(text) { return `public1:${text}` } 97 | 98 | @privateMethod 99 | private2(text) { return `private2:${text}` } 100 | } 101 | 102 | const instance = new Something() 103 | assert.equal(instance.basePublic1('hello'), 'basePublic1:hello') 104 | assert.equal(instance.public1('hello'), 'public1:hello') 105 | assert(!('private2' in instance)) 106 | }) 107 | 108 | it('supports all levels of inheritance', function () { 109 | @classWithPrivateMethods 110 | class Base { 111 | base() { return 'base' } 112 | } 113 | 114 | @classWithPrivateMethods 115 | class Derived1 extends Base { 116 | derived1() { return 'derived1' } 117 | 118 | @privateMethod 119 | derived1Private() {} 120 | } 121 | 122 | @classWithPrivateMethods 123 | class Derived2 extends Derived1 { 124 | derived2() { return 'derived2' } 125 | } 126 | 127 | @classWithPrivateMethods 128 | class Derived3 extends Derived2 { 129 | derived3() { return 'derived3' } 130 | 131 | @privateMethod 132 | derived3Private() {} 133 | } 134 | 135 | const instance = new Derived3() 136 | assert.equal(instance.base(), 'base') 137 | assert.equal(instance.derived1(), 'derived1') 138 | assert(!('derived1Private' in instance)) 139 | assert.equal(instance.derived2(), 'derived2') 140 | assert.equal(instance.derived3(), 'derived3') 141 | assert(!('derived3Private' in instance)) 142 | }) 143 | }) 144 | 145 | context('altered prototype', function () { 146 | it('can alter class prototype after class definition', function () { 147 | @classWithPrivateMethods 148 | class Something { 149 | public1(text) { return `public1:${text}` } 150 | } 151 | 152 | const instance = new Something() 153 | 154 | Something.prototype.public1 = function (text) { return `patched public1:${text}` } 155 | Something.prototype.newPublic2 = function (text) { return `newPublic2:${text}` } 156 | assert.equal(instance.public1('hello'), 'patched public1:hello') 157 | assert.equal(instance.newPublic2('hello'), 'newPublic2:hello') 158 | }) 159 | 160 | it('can access private methods from altered prototype methods', function () { 161 | @classWithPrivateMethods 162 | class Something { 163 | public1(text) { return `public1:${text}` } 164 | 165 | @privateMethod 166 | private2(text) { return `private2:${text}` } 167 | } 168 | 169 | const instance = new Something() 170 | 171 | Something.prototype.public1 = function (text) { return `patched public1:${text}|${this.private2(text)}` } 172 | assert.equal(instance.public1('hello'), 'patched public1:hello|private2:hello') 173 | }) 174 | 175 | it('cannot access private methods from a new prototype method', function () { 176 | @classWithPrivateMethods 177 | class Something { 178 | @privateMethod 179 | private2(text) { return `private2:${text}` } 180 | } 181 | 182 | const instance = new Something() 183 | 184 | Something.prototype.newPublic2 = function (text) { return this.private2(text) } 185 | try { 186 | instance.newPublic2('hello') 187 | assert.fail() 188 | } catch (err) { 189 | assert.equal(err.name, 'TypeError') 190 | } 191 | }) 192 | 193 | it('can alter prototype of base class after class definition ', function () { 194 | @classWithPrivateMethods 195 | class Base { 196 | basePublic1(text) { return `basePublic1:${text}` } 197 | basePublic2(text) { return `basePublic2:${text}` } 198 | } 199 | 200 | @classWithPrivateMethods 201 | class Something extends Base { 202 | public1(text) { return `public1:${text}` } 203 | 204 | @privateMethod 205 | private2(text) { return `private2:${text}` } 206 | } 207 | 208 | Base.prototype.basePublic1 = function (text) { return `patched basePublic1:${text}|${this.basePublic2(text)}` } 209 | 210 | const instance = new Something() 211 | assert.equal(instance.basePublic1('hello'), 'patched basePublic1:hello|basePublic2:hello') 212 | }) 213 | 214 | it('can handle extended prototype', function (done) { 215 | @classWithPrivateMethods 216 | class Something { 217 | public1() { 218 | this.emit('public1:call') 219 | } 220 | } 221 | 222 | extendClassWithPrivateMethods(Something, EventEmitter.prototype) 223 | 224 | const instance = new Something() 225 | 226 | instance.on('public1:call', () => { 227 | done() 228 | }) 229 | 230 | instance.public1() 231 | }) 232 | }) 233 | 234 | context('statics', function () { 235 | it('hoists static methods and properties', function () { 236 | const globalArr = [] 237 | 238 | @classWithPrivateMethods 239 | class Something { 240 | static prop = 123 241 | static arr = globalArr 242 | static static1(text) { return `static1:${text}` } 243 | } 244 | 245 | assert.equal(Something.static1('hello'), 'static1:hello') 246 | assert.equal(Something.prop, 123) 247 | assert.equal(Something.arr, globalArr) 248 | }) 249 | 250 | it('hoists bound static methods that have access to other static properties', function () { 251 | @classWithPrivateMethods 252 | class Something { 253 | static count = 0 254 | static inc() { this.count++ } 255 | 256 | static arr = [] 257 | static add(item) { this.arr = [ ...this.arr, item ] } 258 | 259 | getArrFromInstance() { return this.constructor.arr } 260 | } 261 | 262 | assert.equal(Something.count, 0) 263 | Something.inc() 264 | Something.inc() 265 | assert.equal(Something.count, 2) 266 | 267 | Something.count = 4 268 | assert.equal(Something.__origClass.count, 4) 269 | assert.equal(Something.count, 4) 270 | 271 | assert.deepEqual(Something.arr, []) 272 | Something.add('a') 273 | assert.deepEqual(Something.arr, [ 'a' ]) 274 | const instance = new Something() 275 | assert.equal(Something.arr, instance.getArrFromInstance()) 276 | assert.deepEqual(instance.getArrFromInstance(), [ 'a' ]) 277 | 278 | Something.arr = [ 'x' ] 279 | assert.deepEqual(instance.getArrFromInstance(), [ 'x' ]) 280 | }) 281 | }) 282 | }) 283 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --timeout 200 2 | --reporter spec 3 | --compilers js:babel-core/register 4 | --recursive 5 | --------------------------------------------------------------------------------