2
\$\begingroup\$

One day I stumbled upon a question on Stack Overflow: How do I create an array in javascript whose index starts from 1. The top answer says that it's impossible. However, that answer was posted in 2010 and things changed since then. ECMAScript 6 introduced Proxy, which allows to intercept basic language operations like property lookup.

Using Proxy I created a one-based array, i.e. an array with indexes starting at 1. Mostly it involved adding appropriate Proxy handlers, for example get handler to make arr[1] return the element with index 0. Also I had to intercept array methods, for instance to make indexOf() return a number greater by one.

Here is the code:

import 'babel-polyfill'

const disableHandlersList = new WeakSet()

const convertMethod = (method, target) => {
  const decrementIfPositive = number => number > 0 ? number - 1 : number
  const transformArgsMap = {
    concat: (...args) => args.map(x => x instanceof target.constructor ? Array.from(x) : x)
   ,includes: (searchElement, fromIndex = 1) => [searchElement, decrementIfPositive(fromIndex)]
   ,slice: (begin, end) => [decrementIfPositive(begin), decrementIfPositive(end)]
   ,indexOf: (searchElement, fromIndex = 1) => [searchElement, decrementIfPositive(fromIndex)]
   ,lastIndexOf: (searchElement, fromIndex = Infinity) => [searchElement, decrementIfPositive(fromIndex)]
   ,copyWithin: (...args) => args.map(decrementIfPositive)
   ,fill: (value, start = 1, end = target.length + 1) => [value, decrementIfPositive(start), decrementIfPositive(end)]
   ,splice: (start, ...rest) => [decrementIfPositive(start), ...rest]
   ,reduce: (callback, ...rest) => [(a, b, i, array) =>
      callback(a, b, i + 1, Reflect.construct(target.constructor, array))
     ,...rest]
  }
  transformArgsMap.reduceRight = transformArgsMap.reduce
  const callbackList = ['forEach', 'every', 'some', 'find', 'filter', 'findIndex', 'map']
  const convertReturnedValueList = ['concat', 'slice', 'copyWithin', 'fill', 'reverse', 'sort', 'splice', 'filter', 'map']
  const indexOfList = ['indexOf', 'lastIndexOf', 'findIndex']
  const generators = {
    *keys(result) {
      for (const element of result) yield element + 1
    }
    ,*entries(result) {
      for (const [i, element] of result) yield [i + 1, element]
    }
  }
  return (...args) => {
    disableHandlersList.add(target)

    let transformedArgs = args
    if (transformArgsMap.hasOwnProperty(method)) {
      transformedArgs = transformArgsMap[method](...args)
    } else if (callbackList.includes(method)) {
      transformedArgs = [(currentValue, index, array) =>
        Reflect.apply(args[0], args[1], [currentValue, index + 1, Reflect.construct(target.constructor, array)])]
    }

    let result = Reflect.apply(target[method], target, transformedArgs)
    if (convertReturnedValueList.includes(method)) {
      result = Reflect.construct(target.constructor, Array.from(result))
    } else if (indexOfList.includes(method)) {
      result = result === -1 ? result : result + 1
    } else if (generators.hasOwnProperty(method)) {
      result = generators[method](result)
    }

    disableHandlersList.delete(target)

    return result
  }
}
export default class OneBasedArray extends Array {
  constructor(...args) {
    super(...args)

    const convertHelper = (property, back = false) => {
      if (typeof property === 'symbol') return property
      const parsed = parseInt(property, 10)
      const maxArrayLength = 4294967295
      return !Number.isNaN(parsed) && Number.isFinite(parsed) && parsed >= 0 && parsed <= maxArrayLength
        ? back ? String(parsed + 1) : parsed - 1
        : property
    }
    const convertProperty = property => convertHelper(property)
         ,convertBack = property => convertHelper(property, true)

    return new Proxy(this, {
      get(target, property, receiver) {
        const methodList = ['concat', 'includes', 'join', 'slice', 'toString', 'toLocaleString', 'indexOf', 'lastIndexOf'
          ,'copyWithin', 'fill', 'pop', 'push', 'reverse', 'shift', 'unshift', 'sort', 'splice', 'forEach', 'every', 'some'
          ,'find', 'filter', 'findIndex', 'map', 'reduce', 'reduceRight', 'keys', 'values', 'entries']
        return methodList.includes(property)
          ? convertMethod(property, target)
          : Reflect.get(target, convertProperty(property), receiver)
      }
     ,set(target, property, value, receiver) {
       disableHandlersList.add(target)
       const result = Reflect.set(target, convertProperty(property), value, receiver)
       disableHandlersList.delete(target)
       return result
      }
     ,has(target, property) {
       return Reflect.has(target, convertProperty(property))
      }
     ,deleteProperty(target, property) {
       return Reflect.deleteProperty(target, convertProperty(property))
      }
     ,ownKeys(target) {
       return Reflect.ownKeys(target).map(convertBack)
      }
     ,getOwnPropertyDescriptor(target, property) {
       return Reflect.getOwnPropertyDescriptor(target, disableHandlersList.has(target) ? property : convertProperty(property))
      }
    })
  }
  [Symbol.iterator]() {
    return this.values()
  }
}

I also made tests using Mocha, Chai, and Sinon.JS:

import OneBasedArray from '../build/index.js'
import chai from 'chai'
import iterator from 'chai-iterator'
chai.use(iterator)
import things from 'chai-things'
chai.use(things)
import sinon from 'sinon'
const expect = chai.expect

describe('OneBasedArray', () => {
  let arr
  beforeEach(() => {
    arr = new OneBasedArray('a', 'b', 'c')
  })
  describe('basic functionality', () => {
    it('should be an instance of OneBasedArray', () => {
      expect(arr).to.be.an.instanceof(OneBasedArray)
    })
  })
  describe('proxy handlers', () => {
    describe('get', () => {
      it('should return property with n - 1 index for every number n', () => {
        expect([arr[1], arr[2], arr[3]]).to.deep.equal(['a', 'b', 'c'])
      })
      it('should return correct length', () => {
        expect(arr.length).to.equal(3)
      })
      it('should return undefined for properties which don\'t exist', () => {
        expect([arr[-1], arr[0], arr[4], arr.nonexistent]).to.all.equal(undefined)
      })
      it('should work with symbols', () => {
        expect(arr[Symbol.iterator]).to.exist
        expect(arr[Symbol()]).to.be.undefined
      })
    })
    describe('set', () => {
      it('should assign a value to a new property', () => {
        arr[4] = 'd'
        expect(arr[4]).to.equal('d')
      })
      it('should overwrite existing properties', () => {
        arr[1] = 'A'
        expect(arr[1]).to.equal('A')
      })
      it('should work with symbols', () => {
        arr[Symbol.for('test')] = 'symbol'
        expect(arr[Symbol.for('test')]).to.equal('symbol')
      })
    })
    describe('has', () => {
      it('should return true if the array has that key', () => {
        expect([1 in arr, 2 in arr, 3 in arr]).to.all.equal(true)
      })
      it('should return false if the array doesn\'t have that key', () => {
        expect([-1 in arr, 0 in arr, 4 in arr, 'nonexistent' in arr]).to.all.equal(false)
      })
      it('should work with symbols', () => {
        expect(Symbol.iterator in arr).to.be.true
      })
    })
    describe('deleteProperty', () => {
      it('should delete properties', () => {
        Reflect.deleteProperty(arr, 2)
        expect(arr[2]).to.be.undefined
      })
      it('should return false for unconfigurable properties', () => {
        Reflect.defineProperty(arr, 'unconfigurable', {configurable: false, enumerable: false, value: true})
        expect(Reflect.deleteProperty(arr, 'unconfigurable')).to.be.false
        expect(arr.unconfigurable).to.be.true
      })
      it('should work with symbols', () => {
        arr[Symbol.for('test')] = true
        Reflect.deleteProperty(arr, Symbol.for('test'))
        expect(arr[Symbol.for('test')]).to.be.undefined
      })
    })
    describe('ownKeys', () => {
      it('should return all properties of arr', () => {
        expect(Object.getOwnPropertyNames(arr)).to.deep.equal(['1', '2', '3', 'length'])
      })
      it('should return all symbols of arr', () => {
        arr[Symbol.for('test')] = true
        expect(Object.getOwnPropertySymbols(arr)).to.deep.equal([Symbol.for('test')])
      })
    })
    describe('getOwnPropertyDescriptor', () => {
      it('should return a property descriptor for an existing property', () => {
        expect(Reflect.getOwnPropertyDescriptor(arr, '1')).to.deep.equal({
          value: 'a'
         ,writable: true
         ,enumerable: true
         ,configurable: true
        })
      })
      it('should return undefined for a nonexistent property', () => {
        expect(Reflect.getOwnPropertyDescriptor(arr, 'nonexistent')).to.be.undefined
      })
    })
  })
  describe('Symbol.iterator', () => {
    it('should iterate over all elements', () => {
      expect(arr).to.iterate.over(['a', 'b', 'c'])
    })
  })
  describe('Array methods', () => {
    describe('concat', () => {
      it('should concat OneBasedArray object with a regular array', () => {
        expect(arr.concat(['d', 'e', 'f'])).to.deep.equal(new OneBasedArray('a', 'b', 'c', 'd', 'e', 'f'))
      })
      it('should concat OneBasedArray object with another OneBasedArray object', () => {
        expect(arr.concat(new OneBasedArray('d', 'e', 'f'))).to.deep.equal(new OneBasedArray('a', 'b', 'c', 'd', 'e', 'f'))
      })
      it('should concat OneBasedArray with non-array elements', () => {
        expect(arr.concat(true, null, 42, 'd', Symbol.for('test'), {})).to.deep.equal(
          new OneBasedArray('a', 'b', 'c', true, null, 42, 'd', Symbol.for('test'), {})
        )
      })
    })
    describe('includes', () => {
      it('should return true if it includes that element', () => {
        expect([arr.includes('a'), arr.includes('b'), arr.includes('c')]).to.all.equal(true)
      })
      it('should return false if it doesn\'t include that element', () => {
        expect(arr.includes('nonexistent')).to.be.false
      })
      it('should search the array from fromIndex parameter', () => {
        expect(arr.includes('a', 1)).to.be.true
        expect(arr.includes('a', 2)).to.be.false
        expect(arr.includes('a', -3)).to.be.true
        expect(arr.includes('a', -2)).to.be.false
      })
    })
    describe('join', () => {
      it('should return joined array with chosen separator', () => {
        expect(arr.join()).to.equal('a,b,c')
        expect(arr.join(';')).to.equal('a;b;c')
      })
    })
    describe('slice', () => {
      it('should return a copy of array when called without parameters', () => {
        expect(arr.slice()).to.deep.equal(arr)
      })
      it('should return a portion of array starting with specified index', () => {
        expect(arr.slice(2)).to.deep.equal(new OneBasedArray('b', 'c'))
        expect(arr.slice(-1)).to.deep.equal(new OneBasedArray('c'))
      })
      it('should return a portion of array ending with specified index', () => {
        expect(arr.slice(1, 3)).to.deep.equal(new OneBasedArray('a', 'b'))
        expect(arr.slice(1, -2)).to.deep.equal(new OneBasedArray('a'))
      })
    })
    describe('toString, toLocaleString', () => {
      it('should return joined array', () => {
        expect(arr.toString()).to.equal(arr.join())
        expect(arr.toLocaleString()).to.equal(arr.join())
      })
      it('should return empty string for empty array', () => {
        expect(new OneBasedArray().toString()).to.equal('')
        expect(new OneBasedArray().toLocaleString()).to.equal('')
      })
    })
    describe('indexOf', () => {
      it('should return the index of an element if it exists', () => {
        expect(arr.indexOf('a')).to.equal(1)
        expect(arr.indexOf('b')).to.equal(2)
        expect(arr.indexOf('c')).to.equal(3)
      })
      it('should return -1 if the element doesn\'t exist', () => {
        expect(arr.indexOf('nonexistent')).to.equal(-1)
      })
      it('should start searching from the index specified as the second parameter', () => {
        expect(arr.indexOf('b', 2)).to.equal(2)
        expect(arr.indexOf('b', 3)).to.equal(-1)
        expect(arr.indexOf('a', -3)).to.equal(1)
        expect(arr.indexOf('a', -2)).to.equal(-1)
      })
    })
    describe('lastIndexOf', () => {
      beforeEach(() => {
        arr = new OneBasedArray('a', 'b', 'a', 'b')
      })
      it('should return the last index of an element if it exists', () => {
        expect(arr.lastIndexOf('a')).to.equal(3)
        expect(arr.lastIndexOf('b')).to.equal(4)
      })
      it('should return -1 if the element doesn\'t exist', () => {
        expect(arr.lastIndexOf('nonexistent')).to.equal(-1)
      })
      it('should start searching backwards from the index specified as the second parameter', () => {
        expect(arr.lastIndexOf('b', 4)).to.equal(4)
        expect(arr.lastIndexOf('b', 3)).to.equal(2)
        expect(arr.lastIndexOf('a', -4)).to.equal(1)
      })
    })
    describe('copyWithin', () => {
      it('should copy the array to the target index', () => {
        expect(arr.copyWithin(2)).to.deep.equal(new OneBasedArray('a', 'a', 'b'))
      })
      it('should count the target from end, if negative', () => {
        expect(arr.copyWithin(-1)).to.deep.equal(new OneBasedArray('a', 'b', 'a'))
      })
      it('should start copying from the start index', () => {
        expect(arr.copyWithin(3, 2)).to.deep.equal(new OneBasedArray('a', 'b', 'b'))
      })
      it('should count the start index from end, if negative', () => {
        expect(arr.copyWithin(3, -2)).to.deep.equal(new OneBasedArray('a', 'b', 'b'))
      })
      it('should end coping at the end index', () => {
        expect(arr.copyWithin(3, 1, 2)).to.deep.equal(new OneBasedArray('a', 'b', 'a'))
      })
      it('should count the end index from end, if negative', () => {
        expect(arr.copyWithin(2, 1, -1)).to.deep.equal(new OneBasedArray('a', 'a', 'b'))
      })
    })
    describe('fill', () => {
      it('should fill the array with the specified value', () => {
        expect(arr.fill('d')).to.deep.equal(new OneBasedArray('d', 'd', 'd'))
      })
      it('should start filling from the start index', () => {
        expect(arr.fill('d', 2)).to.deep.equal(new OneBasedArray('a', 'd', 'd'))
      })
      it('should count the start index from end, if negative', () => {
        expect(arr.fill('d', -1)).to.deep.equal(new OneBasedArray('a', 'b', 'd'))
      })
      it('should end filling at the end index', () => {
        expect(arr.fill('d', 1, 3)).to.deep.equal(new OneBasedArray('d', 'd', 'c'))
      })
      it('should count the end index from end, if negative', () => {
        expect(arr.fill('d', 2, -1)).to.deep.equal(new OneBasedArray('a', 'd', 'c'))
      })
    })
    describe('pop', () => {
      it('should remove the last element from the array', () => {
        arr.pop()
        expect(arr).to.deep.equal(new OneBasedArray('a', 'b'))
      })
      it('should return the removed element', () => {
        expect(arr.pop()).to.equal('c')
      })
      it('should return undefined when the array is empty', () => {
        expect(new OneBasedArray().pop()).to.be.undefined
      })
    })
    describe('push', () => {
      it('should add one or more elements to the end of the array', () => {
        arr.push('d')
        expect(arr).to.deep.equal(new OneBasedArray('a', 'b', 'c', 'd'))
        arr.push('e', 'f')
        expect(arr).to.deep.equal(new OneBasedArray('a', 'b', 'c', 'd', 'e', 'f'))
      })
      it('should return the new length of the array', () => {
        expect(arr.push('d')).to.equal(4)
        expect(arr.push('e', 'f')).to.equal(6)
      })
    })
    describe('reverse', () => {
      it('should reverse the array', () => {
        arr.reverse()
        expect(arr).to.deep.equal(new OneBasedArray('c', 'b', 'a'))
      })
      it('should return the reversed array', () => {
        expect(arr.reverse()).to.deep.equal(arr)
      })
    })
    describe('shift', () => {
      it('should remove the first element from the array', () => {
        arr.shift()
        expect(arr).to.deep.equal(new OneBasedArray('b', 'c'))
      })
      it('should return the removed element', () => {
        expect(arr.shift()).to.equal('a')
      })
      it('should return undefined when the array is empty', () => {
        expect(new OneBasedArray().shift()).to.be.undefined
      })
    })
    describe('unshift', () => {
      it('should add one or more elements to the beginning of the array', () => {
        arr.unshift('z')
        expect(arr).to.deep.equal(new OneBasedArray('z', 'a', 'b', 'c'))
        arr.unshift('x', 'y')
        expect(arr).to.deep.equal(new OneBasedArray('x', 'y', 'z', 'a', 'b', 'c'))
      })
      it('should return the new length of the array', () => {
        expect(arr.unshift('z')).to.equal(4)
        expect(arr.unshift('x', 'y')).to.equal(6)
      })
    })
    describe('sort', () => {
      it('should sort the array by Unicode code point value when no sorting function is provided', () => {
        arr = new OneBasedArray('b', 'c', 'a')
        arr.sort()
        expect(arr).to.deep.equal(new OneBasedArray('a', 'b', 'c'))
      })
      it('should sort by the provided sorting function', () => {
        arr = new OneBasedArray('battery', 'horse', 'staple', 'correct')
        const order = ['correct', 'horse', 'battery', 'staple']
        arr.sort((a, b) => order[b] - order[a])
      })
      it('should return the sorted array', () => {
        expect(new OneBasedArray('b', 'c', 'a').sort()).to.deep.equal(new OneBasedArray('a', 'b', 'c'))
      })
    })
    describe('splice', () => {
      it('should remove the specified amount of element from the array, starting with the specified index', () => {
        arr.splice(2, 1)
        expect(arr).to.deep.equal(new OneBasedArray('a', 'c'))
      })
      it('should return an array containing the removed elements', () => {
        expect(arr.splice(1, 2)).to.deep.equal(new OneBasedArray('a', 'b'))
      })
      it('should add the specified elements at the start index', () => {
        arr.splice(2, 1, 'B')
        expect(arr).to.deep.equal(new OneBasedArray('a', 'B', 'c'))
      })
      it('should count the start index from end, if negative', () => {
        arr.splice(-2, 2)
        expect(arr).to.deep.equal(new OneBasedArray('a'))
      })
    })
    describe('forEach, every, some, find, findIndex, filter, map', () => {
      it('should execute the function once for each element with this value equal to the provided thisArg', () => {
        for (const method of ['forEach', 'every', 'some', 'find', 'findIndex', 'filter', 'map']) {
          const stub = sinon.stub().returns(method === 'every')
               ,thisArg = {}
          arr[method](stub, thisArg)
          expect(stub.alwaysCalledOn(thisArg)).to.be.true
          expect(stub.args).to.deep.equal([
            ['a', 1, arr]
           ,['b', 2, arr]
           ,['c', 3, arr]
          ])
        }
      })
    })
    describe('filter', () => {
      it('should return the filtered array', () => {
        expect(arr.filter(x => x === 'a' || x === 'b')).to.deep.equal(new OneBasedArray('a', 'b'))
      })
    })
    describe('findIndex', () => {
      it('should return the index of the element, if found', () => {
        expect(arr.findIndex(x => x === 'a')).to.equal(1)
        expect(arr.findIndex(x => x === 'b')).to.equal(2)
        expect(arr.findIndex(x => x === 'c')).to.equal(3)
      })
      it('should return -1 otherwise', () => {
        expect(arr.findIndex(x => x === 'd')).to.equal(-1)
      })
    })
    describe('reduce, reduceRight', () => {
      it('should execute the callback function with appropriate arguments', () => {
        const stub = sinon.stub().returns('')
        arr.reduce(stub)
        arr.reduceRight(stub)
        expect(stub.args).to.deep.equal([
          ['a', 'b', 2, arr]
         ,['', 'c', 3, arr]
         ,['c', 'b', 2, arr]
         ,['', 'a', 1, arr]
        ])
      })
      it('should start counting current index from 1 when initial value is provided', () => {
        const stub = sinon.stub().returns('')
        arr = new OneBasedArray('b')
        arr.reduce(stub, 'a')
        arr.reduceRight(stub, 'a')
        expect(stub.args).to.deep.equal([
          ['a', 'b', 1, arr]
         ,['a', 'b', 1, arr]
        ])
      })
    })
    describe('keys, values, entries', () => {
      it('should return appropriate iterator object', () => {
        expect(arr.keys()).to.iterate.over([1, 2, 3])
        expect(arr.values()).to.iterate.over(['a', 'b', 'c'])
        expect(arr.entries()).to.deep.iterate.over([[1, 'a'], [2, 'b'], [3, 'c']])
      })
    })
  })
})

I'm most concerned about the following:

  • Complexity: if I wrote in 5 lines something that could have been written in 1 line, I'm sure it's worth refactoring.
  • Readability: I tried to make this code as easy-to-understand as I could, for example by using descriptive identifiers, but I don't know if I succeeded.

I made a repository on GitHub—you can find some usage examples there.

\$\endgroup\$
  • \$\begingroup\$ I guess you could iterate the existing methods on an Array object and replace the ones that exist that you don't have support for (new methods added over time) and throw an exception if those are called to avoid subtle bugs if someone uses those. \$\endgroup\$ – jfriend00 Sep 18 '16 at 20:46
  • \$\begingroup\$ @jfriend00 That's not only about replacing Array methods. Using Proxy I made that arr[1] returns the first element, not the second. \$\endgroup\$ – Michał Perłakowski Sep 19 '16 at 5:29
  • \$\begingroup\$ Duh. I know that. But, to do that, you had to replace/patch all the array methods that use or return an index. So, as soon as an enhanced version of JS adds a new method to the Array object, your code will be able to make a hard to track down bug for anyone who tries to use that new method. I was suggesting that you make any new methods that you don't have support for throw an error to avoid the hard to track down bug. And, you can do that by iterating existing methods to find the ones your code doesn't know about. \$\endgroup\$ – jfriend00 Sep 19 '16 at 5:31
  • \$\begingroup\$ FYI, if you want general feedback on your code, I'd say that something this complex deserves a bunch of comments explaining how each portion of the code works. You appear to have zero explanatory comments. \$\endgroup\$ – jfriend00 Sep 19 '16 at 5:34
  • \$\begingroup\$ Impressive, but why? \$\endgroup\$ – RubberDuck Nov 29 '16 at 1:45

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.