Typed Arrays

Typed arrays are special-purpose arrays designed to work with numeric types (not all types, as the name might imply). The origin of typed arrays can be traced to WebGL, a port of Open GL ES 2.0 designed for use in web pages with the <canvas> element. Typed arrays were created as part of the port to provide fast bitwise arithmetic in JavaScript.

Arithmetic on native JavaScript numbers was too slow for WebGL because the numbers were stored in a 64-bit floating-point format and converted to 32-bit integers as needed. Typed arrays were introduced to circumvent this limitation and provide better performance for arithmetic operations. The concept is that any single number can be treated like an array of bits and thus can use the familiar methods available on JavaScript arrays.

ECMAScript 6 adopted typed arrays as a formal part of the language to ensure better compatibility across JavaScript engines and interoperability with JavaScript arrays. While the ECMAScript 6 version of typed arrays is not exactly the same as the WebGL version, there are enough similarities to make the ECMAScript 6 version an evolution of the WebGL version rather than a different approach.

Numeric Data Types

JavaScript numbers are stored in IEEE 754 format, which uses 64 bits to store a floating-point representation of the number. This format represents both integers and floats in JavaScript, with conversion between the two formats happening frequently as numbers change. Typed arrays allow the storage and manipulation of eight different numeric types:

  1. Signed 8-bit integer (int8)
  2. Unsigned 8-bit integer (uint8)
  3. Signed 16-bit integer (int16)
  4. Unsigned 16-bit integer (uint16)
  5. Signed 32-bit integer (int32)
  6. Unsigned 32-bit integer (uint32)
  7. 32-bit float (float32)
  8. 64-bit float (float64)

If you represent a number that fits in an int8 as a normal JavaScript number, you’ll waste 56 bits. Those bits might better be used to store additional int8 values or any other number that requires less than 56 bits. Using bits more efficiently is one of the use cases typed arrays address.

All of the operations and objects related to typed arrays are centered around these eight data types. In order to use them, though, you’ll need to create an array buffer to store the data.

I> In this book, I will refer to these types by the abbreviations I showed in parentheses. Those abbreviations don’t appear in actual JavaScript code; they’re just a shorthand for the much longer descriptions.

Array Buffers

The foundation for all typed arrays is an array buffer, which is a memory location that can contain a specified number of bytes. Creating an array buffer is akin to calling malloc() in C to allocate memory without specifying what the memory block contains. You can create an array buffer by using the ArrayBuffer constructor as follows:

  1. let buffer = new ArrayBuffer(10); // allocate 10 bytes

Just pass the number of bytes the array buffer should contain when you call the constructor. This let statement creates an array buffer 10 bytes long. Once an array buffer is created, you can retrieve the number of bytes in it by checking the byteLength property:

  1. let buffer = new ArrayBuffer(10); // allocate 10 bytes
  2. console.log(buffer.byteLength); // 10

You can also use the slice() method to create a new array buffer that contains part of an existing array buffer. The slice() method works like the slice() method on arrays: you pass it the start index and end index as arguments, and it returns a new ArrayBuffer instance comprised of those elements from the original. For example:

  1. let buffer = new ArrayBuffer(10); // allocate 10 bytes
  2. let buffer2 = buffer.slice(4, 6);
  3. console.log(buffer2.byteLength); // 2

In this code, buffer2 is created by extracting the bytes at indices 4 and 5. Just like when you call the array version of this method, the second argument to slice() is exclusive.

Of course, creating a storage location isn’t very helpful without being able to write data into that location. To do so, you’ll need to create a view.

I> An array buffer always represents the exact number of bytes specified when it was created. You can change the data contained within an array buffer, but never the size of the array buffer itself.

Manipulating Array Buffers with Views

Array buffers represent memory locations, and views are the interfaces you’ll use to manipulate that memory. A view operates on an array buffer or a subset of an array buffer’s bytes, reading and writing data in one of the numeric data types. The DataView type is a generic view on an array buffer that allows you to operate on all eight numeric data types.

To use a DataView, first create an instance of ArrayBuffer and use it to create a new DataView. Here’s an example:

  1. let buffer = new ArrayBuffer(10),
  2. view = new DataView(buffer);

The view object in this example has access to all 10 bytes in buffer. You can also create a view over just a portion of a buffer. Just provide a byte offset and, optionally, the number of bytes to include from that offset. When a number of bytes isn’t included, theDataView will go from the offset to the end of the buffer by default. For example:

  1. let buffer = new ArrayBuffer(10),
  2. view = new DataView(buffer, 5, 2); // cover bytes 5 and 6

Here, view operates only on the bytes at indices 5 and 6. This approach allows you to create several views over the same array buffer, which can be useful if you want to use a single memory location for an entire application rather than dynamically allocating space as needed.

Retrieving View Information

You can retrieve information about a view by fetching the following read-only properties:

  • buffer - The array buffer that the view is tied to
  • byteOffset - The second argument to the DataView constructor, if provided (0 by default)
  • byteLength - The third argument to the DataView constructor, if provided (the buffer’s byteLength by default)

Using these properties, you can inspect exactly where a view is operating, like this:

  1. let buffer = new ArrayBuffer(10),
  2. view1 = new DataView(buffer), // cover all bytes
  3. view2 = new DataView(buffer, 5, 2); // cover bytes 5 and 6
  4. console.log(view1.buffer === buffer); // true
  5. console.log(view2.buffer === buffer); // true
  6. console.log(view1.byteOffset); // 0
  7. console.log(view2.byteOffset); // 5
  8. console.log(view1.byteLength); // 10
  9. console.log(view2.byteLength); // 2

This code creates view1, a view over the entire array buffer, and view2, which operates on a small section of the array buffer. These views have equivalent buffer properties because both work on the same array buffer. The byteOffset and byteLength are different for each view, however. They reflect the portion of the array buffer where each view operates.

Of course, reading information about memory isn’t very useful on its own. You need to write data into and read data out of that memory to get any benefit.

Reading and Writing Data

For each of JavaScript’s eight numeric data types, the DataView prototype has a method to write data and a method to read data from an array buffer. The method names all begin with either “set” or “get” and are followed by the data type abbreviation. For instance, here’s a list of the read and write methods that can operate on int8 and uint8 values:

  • getInt8(byteOffset) - Read an int8 starting at byteOffset
  • setInt8(byteOffset, value) - Write an int8 starting at byteOffset
  • getUint8(byteOffset) - Read an uint8 starting at byteOffset
  • setUint8(byteOffset, value) - Write an uint8 starting at byteOffset

The “get” methods accept a single argument: the byte offset to read from. The “set” methods accept two arguments: the byte offset to write at and the value to write.

Though I’ve only shown the methods you can use with 8-bit values, the same methods exist for operating on 16- and 32-bit values. Just replace the 8 in each name with 16 or 32. Alongside all those integer methods, DataView also has the following read and write methods for floating point numbers:

  • getFloat32(byteOffset, littleEndian) - Read a float32 starting at byteOffset
  • setFloat32(byteOffset, value, littleEndian) - Write a float32 starting at byteOffset
  • getFloat64(byteOffset, littleEndian) - Read a float64 starting at byteOffset
  • setFloat64(byteOffset, value, littleEndian) - Write a float64 starting at byteOffset

The float-related methods are only different in that they accept an additional optional boolean indicating whether the value should be read or written as little-endian. (Little-endian means the least significant byte is at byte 0, instead of in the last byte.)

To see a “set” and a “get” method in action, consider the following example:

  1. let buffer = new ArrayBuffer(2),
  2. view = new DataView(buffer);
  3. view.setInt8(0, 5);
  4. view.setInt8(1, -1);
  5. console.log(view.getInt8(0)); // 5
  6. console.log(view.getInt8(1)); // -1

This code uses a two-byte array buffer to store two int8 values. The first value is set at offset 0 and the second is at offset 1, reflecting that each value spans a full byte (8 bits). Those values are later retrieved from their positions with the getInt8() method. While this example uses int8 values, you can use any of the eight numeric types with their corresponding methods.

Views are interesting because they allow you to read and write in any format at any point in time, regardless of how data was previously stored. For instance, writing two int8 values and reading the buffer with an int16 method works just fine, as in this example:

  1. let buffer = new ArrayBuffer(2),
  2. view = new DataView(buffer);
  3. view.setInt8(0, 5);
  4. view.setInt8(1, -1);
  5. console.log(view.getInt16(0)); // 1535
  6. console.log(view.getInt8(0)); // 5
  7. console.log(view.getInt8(1)); // -1

The call to view.getInt16(0) reads all bytes in the view and interprets those bytes as the number 1535. To understand why this happens, take a look at Figure 10-1, which shows what each setInt8() line does to the array buffer.

  1. new ArrayBuffer(2) 0000000000000000
  2. view.setInt8(0, 5); 0000010100000000
  3. view.setInt8(1, -1); 0000010111111111

The array buffer starts with 16 bits that are all zero. Writing 5 to the first byte with setInt8() introduces a couple of 1s (in 8-bit representation, 5 is 00000101). Writing -1 to the second byte sets all bits in that byte to 1, which is the two’s complement representation of -1. After the second setInt8() call, the array buffer contains 16 bits, and getInt16() reads those bits as a single 16-bit integer, which is 1535 in decimal.

The DataView object is perfect for use cases that mix different data types in this way. However, if you’re only using one specific data type, then the type-specific views are a better choice.

Typed Arrays Are Views

ECMAScript 6 typed arrays are actually type-specific views for array buffers. Instead of using a generic DataView object to operate on an array buffer, you can use objects that enforce specific data types. There are eight type-specific views corresponding to the eight numeric data types, plus an additional option for uint8 values.

Table 10-1 shows an abbreviated version of the complete list of type-specific views from section 22.2 of the ECMAScript 6 specification.

Constructor NameElement Size (in bytes)DescriptionEquivalent C Type
Int8Array18-bit two’s complement signed integersigned char
Uint8Array18-bit unsigned integerunsigned char
Uint8ClampedArray18-bit unsigned integer (clamped conversion)unsigned char
Int16Array216-bit two’s complement signed integershort
Uint16Array216-bit unsigned integerunsigned short
Int32Array432-bit two’s complement signed integerint
Uint32Array432-bit unsigned integerint
Float32Array432-bit IEEE floating pointfloat
Float64Array864-bit IEEE floating pointdouble

The left column lists the typed array constructors, and the other columns describe the data each typed array can contain. A Uint8ClampedArray is the same as a Uint8Array unless values in the array buffer are less than 0 or greater than 255. A Uint8ClampedArray converts values lower than 0 to 0 (-1 becomes 0, for instance) and converts values higher than 255 to 255 (so 300 becomes 255).

Typed array operations only work on a particular type of data. For example, all operations on Int8Array use int8 values. The size of an element in a typed array also depends on the type of array. While an element in an Int8Array is a single byte long, Float64Array uses eight bytes per element. Fortunately, the elements are accessed using numeric indices just like regular arrays, allowing you to avoid the somewhat awkward calls to the “set” and “get” methods of DataView.

A> ### Element Size A> A> Each typed array is made up of a number of elements, and the element size is the number of bytes each element represents. This value is stored on a BYTES_PER_ELEMENT property on each constructor and each instance, so you can easily query the element size: A> A> js A> console.log(UInt8Array.BYTES_PER_ELEMENT); // 1 A> console.log(UInt16Array.BYTES_PER_ELEMENT); // 2 A> A> let ints = new Int8Array(5); A> console.log(ints.BYTES_PER_ELEMENT); // 1 A>

Creating Type-Specific Views

Typed array constructors accept multiple types of arguments, so there are a few ways to create typed arrays. First, you can create a new typed array by passing the same arguments DataView takes (an array buffer, an optional byte offset, and an optional byte length). For example:

  1. let buffer = new ArrayBuffer(10),
  2. view1 = new Int8Array(buffer),
  3. view2 = new Int8Array(buffer, 5, 2);
  4. console.log(view1.buffer === buffer); // true
  5. console.log(view2.buffer === buffer); // true
  6. console.log(view1.byteOffset); // 0
  7. console.log(view2.byteOffset); // 5
  8. console.log(view1.byteLength); // 10
  9. console.log(view2.byteLength); // 2

In this code, the two views are both Int8Array instances that use buffer. Both view1 and view2 have the same buffer, byteOffset, and byteLength properties that exist on DataView instances. It’s easy to switch to using a typed array wherever you use a DataView so long as you only work with one numeric type.

The second way to create a typed array is to pass a single number to the constructor. That number represents the number of elements (not bytes) to allocate to the array. The constructor will create a new buffer with the correct number of bytes to represent that number of array elements, and you can access the number of elements in the array by using the length property. For example:

  1. let ints = new Int16Array(2),
  2. floats = new Float32Array(5);
  3. console.log(ints.byteLength); // 4
  4. console.log(ints.length); // 2
  5. console.log(floats.byteLength); // 20
  6. console.log(floats.length); // 5

The ints array is created with space for two elements. Each 16-bit integer requires two bytes per value, so the array is allocated four bytes. The floats array is created to hold five elements, so the number of bytes required is 20 (four bytes per element). In both cases, a new buffer is created and can be accessed using the buffer property if necessary.

W> If no argument is passed to a typed array constructor, the constructor acts as if 0 was passed. This creates a typed array that cannot hold data because zero bytes are allocated to the buffer.

The third way to create a typed array is to pass an object as the only argument to the constructor. The object can be any of the following:

  • A Typed Array - Each element is copied into a new element on the new typed array. For example, if you pass an int8 to the Int16Array constructor, the int8 values would be copied into an int16 array. The new typed array has a different array buffer than the one that was passed in.
  • An Iterable - The object’s iterator is called to retrieve the items to insert into the typed array. The constructor will throw an error if any elements are invalid for the view type.
  • An Array - The elements of the array are copied into a new typed array. The constructor will throw an error if any elements are invalid for the type.
  • An Array-Like Object - Behaves the same as an array.

In each of these cases, a new typed array is created with the data from the source object. This can be especially useful when you want to initialize a typed array with some values, like this:

  1. let ints1 = new Int16Array([25, 50]),
  2. ints2 = new Int32Array(ints1);
  3. console.log(ints1.buffer === ints2.buffer); // false
  4. console.log(ints1.byteLength); // 4
  5. console.log(ints1.length); // 2
  6. console.log(ints1[0]); // 25
  7. console.log(ints1[1]); // 50
  8. console.log(ints2.byteLength); // 8
  9. console.log(ints2.length); // 2
  10. console.log(ints2[0]); // 25
  11. console.log(ints2[1]); // 50

This example creates an Int16Array and initializes it with an array of two values. Then, an Int32Array is created and passed the Int16Array. The values 25 and 50 are copied from ints1 into ints2 as the two typed arrays have completely separate buffers. The same numbers are represented in both typed arrays, but ints2 has eight bytes to represent the data while ints1 has only four.