Records and Tuples in JavaScript

Records and Tuples are a proposed feature for JavaScript that introduces deeply immutable data structures. They provide value-based equality checking rather than the reference-based equality of objects and arrays.

The Problem They Solve

JavaScript’s built-in objects and arrays have reference-based equality, which can lead to unexpected behavior:

// Reference-based equality with objects
const obj1 = { x: 1, y: 2 };
const obj2 = { x: 1, y: 2 };
console.log(obj1 === obj2); // false, despite having the same content

// Same issue with arrays
const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
console.log(arr1 === arr2); // false, despite having the same content

Basic Concept

Records and Tuples introduce two new immutable data structures:

  1. Record: Immutable key-value pairs (similar to objects)
  2. Tuple: Immutable ordered lists (similar to arrays)
// Proposed syntax for Records and Tuples
const record = #{ x: 1, y: 2 }; // Record literal with # prefix
const tuple = #[1, 2, 3];       // Tuple literal with # prefix

// Value-based equality
const record2 = #{ x: 1, y: 2 };
console.log(record === record2); // true, because they have the same content

const tuple2 = #[1, 2, 3];
console.log(tuple === tuple2); // true, because they have the same content

Current Status

Records and Tuples are currently at Stage 2 in the TC39 proposal process. This means the feature is still being developed and is not yet part of the ECMAScript standard.

Key Characteristics

Immutability

Records and Tuples are deeply immutable:

const record = #{ x: 1, y: 2 };
// record.x = 3; // Error: Cannot assign to read-only property 'x'

const tuple = #[1, 2, 3];
// tuple[0] = 4; // Error: Cannot assign to read-only property '0'
// tuple.push(4); // Error: tuple.push is not a function

Value-Based Equality

Equality checks compare values, not references:

// Records with the same content are equal
console.log(#{ a: 1, b: 2 } === #{ a: 1, b: 2 }); // true

// Order of properties doesn't matter for Records
console.log(#{ a: 1, b: 2 } === #{ b: 2, a: 1 }); // true

// Tuples with the same content are equal
console.log(#[1, 2, 3] === #[1, 2, 3]); // true

// Order matters for Tuples
console.log(#[1, 2, 3] === #[3, 2, 1]); // false

Nested Records and Tuples

Records and Tuples can be nested, and equality checks are deep:

const nested1 = #{ 
  point: #{ x: 1, y: 2 },
  values: #[3, 4, 5]
};

const nested2 = #{ 
  point: #{ x: 1, y: 2 },
  values: #[3, 4, 5]
};

console.log(nested1 === nested2); // true, deep equality check

Valid Record Keys and Tuple Values

Records and Tuples can only contain primitive values or other Records and Tuples:

// Valid Record and Tuple values
const valid = #{
  string: "text",
  number: 42,
  boolean: true,
  null: null,
  undefined: undefined,
  symbol: Symbol("description"),
  bigint: 42n,
  record: #{ nested: true },
  tuple: #[1, 2, 3]
};

// Invalid: Cannot contain objects or functions
// const invalid = #{ obj: {}, arr: [], func: () => {} }; // Error

Creating Records and Tuples

Literal Syntax

// Record literal
const point = #{ x: 1, y: 2 };

// Tuple literal
const coordinates = #[10, 20, 30];

Conversion from Objects and Arrays

// Convert from object to Record
const obj = { a: 1, b: 2 };
const record = Record(obj);
console.log(record); // #{a: 1, b: 2}

// Convert from array to Tuple
const arr = [1, 2, 3];
const tuple = Tuple(arr);
console.log(tuple); // #[1, 2, 3]

// Deep conversion
const complex = { 
  point: { x: 1, y: 2 },
  values: [3, 4, 5]
};
const complexRecord = Record.fromEntries(
  Object.entries(complex).map(([k, v]) => {
    if (Array.isArray(v)) return [k, Tuple(v)];
    if (typeof v === 'object' && v !== null) return [k, Record(v)];
    return [k, v];
  })
);
console.log(complexRecord); // #{point: #{x: 1, y: 2}, values: #[3, 4, 5]}

Working with Records and Tuples

Creating Modified Copies

Since Records and Tuples are immutable, you need to create new ones to make changes:

// Creating a new Record with modified values
const original = #{ x: 1, y: 2 };
const updated = #{...original, y: 3 }; // Spread syntax works
console.log(updated); // #{x: 1, y: 3}

// Creating a new Tuple with modified values
const numbers = #[1, 2, 3];
const moreNumbers = #[...numbers, 4, 5]; // Spread syntax works
console.log(moreNumbers); // #[1, 2, 3, 4, 5]

// Replacing a value in a Tuple
const replaceAt = (tuple, index, value) => {
  return Tuple([...tuple.slice(0, index), value, ...tuple.slice(index + 1)]);
};
const modified = replaceAt(#[1, 2, 3], 1, 42);
console.log(modified); // #[1, 42, 3]

Accessing Properties and Elements

// Accessing Record properties
const user = #{ name: "Alice", age: 30 };
console.log(user.name); // "Alice"
console.log(user["age"]); // 30

// Destructuring works
const { name, age } = user;
console.log(name, age); // "Alice" 30

// Accessing Tuple elements
const point3D = #[10, 20, 30];
console.log(point3D[0]); // 10
console.log(point3D[1]); // 20

// Destructuring works
const [x, y, z] = point3D;
console.log(x, y, z); // 10 20 30

Iteration

Records and Tuples are iterable:

// Iterating over a Record
const record = #{ a: 1, b: 2, c: 3 };

for (const [key, value] of Object.entries(record)) {
  console.log(key, value);
}
// "a" 1
// "b" 2
// "c" 3

// Iterating over a Tuple
const tuple = #[10, 20, 30];

for (const value of tuple) {
  console.log(value);
}
// 10
// 20
// 30

// Using array methods via Array.from
const doubled = Array.from(tuple).map(x => x * 2);
console.log(doubled); // [20, 40, 60] (regular array)

Practical Applications

As Object Keys

Records and Tuples can be used as object keys, unlike objects and arrays:

// Using Records as Map keys
const pointMap = new Map();
pointMap.set(#{ x: 1, y: 2 }, "Point A");
pointMap.set(#{ x: 3, y: 4 }, "Point B");

// We can retrieve by value
console.log(pointMap.get(#{ x: 1, y: 2 })); // "Point A"

// Using Tuples as Map keys
const coordinateMap = new Map();
coordinateMap.set(#[0, 0], "Origin");
coordinateMap.set(#[1, 0], "East");

console.log(coordinateMap.get(#[0, 0])); // "Origin"

Immutable State Management

Records and Tuples are ideal for state management in applications:

// Application state as a Record
let state = #{
  user: #{
    id: 1,
    name: "Alice",
    preferences: #{
      theme: "dark",
      notifications: true
    }
  },
  items: #[
    #{ id: 1, name: "Item 1", completed: false },
    #{ id: 2, name: "Item 2", completed: true }
  ]
};

// Update state immutably
function updateTheme(currentState, newTheme) {
  return #{
    ...currentState,
    user: #{
      ...currentState.user,
      preferences: #{
        ...currentState.user.preferences,
        theme: newTheme
      }
    }
  };
}

// Apply update
state = updateTheme(state, "light");
console.log(state.user.preferences.theme); // "light"

Configuration Objects

Records are perfect for configuration that shouldn’t change:

// Application configuration as a Record
const config = #{
  api: #{
    baseUrl: "https://api.example.com",
    timeout: 5000,
    retries: 3
  },
  features: #{
    darkMode: true,
    analytics: true,
    experimental: #[
      "feature1",
      "feature2"
    ]
  }
};

// Safe to pass around without worry of mutation
function initializeApp(appConfig) {
  // appConfig is guaranteed to be immutable
  console.log(`Connecting to ${appConfig.api.baseUrl}`);
  // ...
}

initializeApp(config);

Caching and Memoization

Value-based equality makes Records and Tuples ideal for caching:

// Memoization with Records as cache keys
const memoize = (fn) => {
  const cache = new Map();
  
  return (...args) => {
    const key = Tuple(args);
    if (cache.has(key)) {
      return cache.get(key);
    }
    
    const result = fn(...args);
    cache.set(key, result);
    return result;
  };
};

// Usage
const expensiveCalculation = memoize((a, b, c) => {
  console.log("Calculating...");
  return a * b * c;
});

console.log(expensiveCalculation(2, 3, 4)); // Logs "Calculating..." then 24
console.log(expensiveCalculation(2, 3, 4)); // Just returns 24 (from cache)

Comparison with Existing Solutions

Records vs. Objects

// Regular object
const obj = { x: 1, y: 2 };
obj.z = 3; // Mutable
const obj2 = { x: 1, y: 2 };
console.log(obj === obj2); // false (reference equality)

// Record
const rec = #{ x: 1, y: 2 };
// rec.z = 3; // Error: Cannot add property z, object is not extensible
const rec2 = #{ x: 1, y: 2 };
console.log(rec === rec2); // true (value equality)

Tuples vs. Arrays

// Regular array
const arr = [1, 2, 3];
arr.push(4); // Mutable
const arr2 = [1, 2, 3];
console.log(arr === arr2); // false (reference equality)

// Tuple
const tup = #[1, 2, 3];
// tup.push(4); // Error: tup.push is not a function
const tup2 = #[1, 2, 3];
console.log(tup === tup2); // true (value equality)

Comparison with Object.freeze()

// Frozen object
const frozen = Object.freeze({ x: 1, y: { z: 2 } });
// frozen.x = 3; // Error in strict mode
frozen.y.z = 3; // Works! Only shallow immutability

// Record (deeply immutable)
const record = #{ x: 1, y: #{ z: 2 } };
// record.x = 3; // Error
// record.y.z = 3; // Error

Comparison with Immutable.js

// Immutable.js
import { Map, List } from 'immutable';
const map = Map({ x: 1, y: 2 });
const newMap = map.set('z', 3); // Returns new immutable map
console.log(map.equals(Map({ x: 1, y: 2 }))); // true (value equality)

// Records and Tuples
const rec = #{ x: 1, y: 2 };
const newRec = #{...rec, z: 3}; // Creates new record
console.log(rec === #{ x: 1, y: 2 }); // true (value equality)

Current Workarounds

Until Records and Tuples are widely supported, there are several alternatives:

Deep Freezing Objects

// Deep freeze function
function deepFreeze(obj) {
  if (obj === null || typeof obj !== 'object') return obj;
  
  Object.keys(obj).forEach(key => {
    const value = obj[key];
    if (typeof value === 'object' && value !== null) {
      deepFreeze(value);
    }
  });
  
  return Object.freeze(obj);
}

// Usage
const frozenObj = deepFreeze({ 
  point: { x: 1, y: 2 },
  values: [3, 4, 5]
});

// frozenObj.point.x = 10; // Error in strict mode

Immutable Libraries

// Using Immutable.js
import { Map, List } from 'immutable';

const state = Map({
  user: Map({
    name: 'Alice',
    preferences: Map({
      theme: 'dark'
    })
  }),
  items: List([
    Map({ id: 1, text: 'Item 1' }),
    Map({ id: 2, text: 'Item 2' })
  ])
});

// Update immutably
const newState = state.setIn(['user', 'preferences', 'theme'], 'light');

Simulating Value Equality

// Deep equality function
function deepEqual(a, b) {
  if (a === b) return true;
  
  if (a === null || b === null || 
      typeof a !== 'object' || typeof b !== 'object') {
    return false;
  }
  
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);
  
  if (keysA.length !== keysB.length) return false;
  
  return keysA.every(key => {
    if (!keysB.includes(key)) return false;
    return deepEqual(a[key], b[key]);
  });
}

// Usage
const obj1 = { x: 1, y: { z: 2 } };
const obj2 = { x: 1, y: { z: 2 } };
console.log(deepEqual(obj1, obj2)); // true

Best Practices

  1. Use for Immutable Data: Records and Tuples are ideal for data that should never change
  2. Consider Performance: Value equality checks are more expensive than reference checks
  3. Plan for Transition: Design code that can work with both regular objects/arrays and Records/Tuples
  4. Use Typed Arrays for Binary Data: Records and Tuples are not suitable for large binary data
  5. Combine with Existing Patterns: Records and Tuples complement patterns like Redux and immutable state management
// Transition strategy
function safelyUseRecordOrObject(data) {
  // Check if Records are supported
  const supportsRecords = typeof Record === 'function';
  
  if (supportsRecords) {
    // Use Record if available
    return Record(data);
  } else {
    // Fall back to frozen object
    return Object.freeze({...data});
  }
}

Browser and Environment Support

Records and Tuples are still in the proposal stage (Stage 2 in the TC39 process as of the latest update). Support varies across environments:

  1. Browsers: Not yet natively supported in major browsers
  2. Node.js: Not yet natively supported
  3. Babel: Support via the @babel/plugin-proposal-record-and-tuple plugin
  4. TypeScript: Not yet supported natively
// Using Babel plugin
// babel.config.js
module.exports = {
  plugins: [
    ['@babel/plugin-proposal-record-and-tuple', {
      importPolyfill: true,
      syntaxType: 'hash'
    }]
  ]
};

// TypeScript workaround with utility types
type Record<T> = Readonly<T> & { readonly __record__: unique symbol };
type Tuple<T> = ReadonlyArray<T> & { readonly __tuple__: unique symbol };

// Usage
function createRecord<T>(obj: T): Record<T> {
  return Object.freeze({...obj}) as Record<T>;
}

function createTuple<T>(arr: T[]): Tuple<T> {
  return Object.freeze([...arr]) as Tuple<T>;
}

Interview Tips

  • Explain the key differences between Records/Tuples and Objects/Arrays
  • Describe the benefits of value-based equality for data structures
  • Discuss use cases where Records and Tuples would be particularly beneficial
  • Explain how Records and Tuples relate to functional programming concepts
  • Discuss current workarounds and alternatives until Records and Tuples are standardized
  • Demonstrate knowledge of the TC39 proposal process and the current status of the proposal

Test Your Knowledge

Take a quick quiz to test your understanding of this topic.