Skip to main content
Master two fundamental JavaScript features: closures that capture lexical scope and prototype chains that enable object-oriented programming without classes.

Overview

Closures and prototypes are essential to JavaScript’s identity as a language. Closures enable powerful functional programming patterns, while prototypes provide a flexible inheritance mechanism.
Understanding closures and prototypes deeply is crucial for implementing a JavaScript engine and writing efficient JavaScript code.

Closure implementation with environment chains

Closures allow functions to access variables from their lexical scope even after the outer function has returned. This is implemented using environment chains.

What is a closure?

A closure is a function bundled together with references to its surrounding state (lexical environment).
function makeCounter() {
    let count = 0;  // Captured by closure
    
    return function() {
        count++;
        return count;
    };
}

const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
// count is preserved between calls

Environment chains

Environments form a chain from the innermost scope to the global scope:
1

Create environment

When a function is called, create a new environment containing its local variables and parameters.
2

Link to parent

Set the environment’s parent to the lexical parent scope (not the dynamic caller).
3

Variable lookup

Search for variables by traversing the chain from inner to outer environments.
4

Capture references

Functions store a reference to their creation environment, not a copy.

Implementation

class Environment {
public:
    std::shared_ptr<Environment> parent;
    std::unordered_map<std::string, Value> bindings;
    
    Environment(std::shared_ptr<Environment> parent = nullptr)
        : parent(parent) {}
    
    void define(const std::string& name, const Value& value) {
        bindings[name] = value;
    }
    
    Value* lookup(const std::string& name) {
        // Search in current environment
        auto it = bindings.find(name);
        if (it != bindings.end()) {
            return &it->second;
        }
        
        // Search in parent environment
        if (parent) {
            return parent->lookup(name);
        }
        
        return nullptr; // Variable not found
    }
    
    void set(const std::string& name, const Value& value) {
        // Search for existing binding
        auto it = bindings.find(name);
        if (it != bindings.end()) {
            it->second = value;
            return;
        }
        
        // Try parent environment
        if (parent) {
            parent->set(name, value);
            return;
        }
        
        // Variable not found - error or create global
        throw std::runtime_error("Undefined variable: " + name);
    }
};
Environments must use shared pointers or garbage collection to prevent memory leaks. Circular references between closures can cause memory issues.

Upvalues optimization

To avoid keeping entire environment chains alive, use upvalues to capture only needed variables:
class Upvalue {
public:
    // Points to either stack slot or heap location
    Value* location;
    Value closed;  // Used when variable moves off stack
    bool isClosed;
    
    Upvalue(Value* loc) : location(loc), isClosed(false) {}
    
    Value& get() {
        return isClosed ? closed : *location;
    }
    
    void close() {
        closed = *location;
        location = &closed;
        isClosed = true;
    }
};
Lua pioneered the upvalue technique, which is more memory-efficient than capturing full environments.

Prototype chain traversal

JavaScript uses prototype-based inheritance instead of class-based inheritance. Every object has a prototype, and property lookups traverse the prototype chain.

Prototype basics

// Every object has a [[Prototype]] (accessed via __proto__)
const obj = { x: 1 };
console.log(obj.toString); // Inherited from Object.prototype

// Constructor functions and prototypes
function Person(name) {
    this.name = name;
}

Person.prototype.greet = function() {
    console.log(`Hello, I'm ${this.name}`);
};

const alice = new Person('Alice');
alice.greet(); // "Hello, I'm Alice"
// greet is found in Person.prototype

Prototype chain structure

Every object contains:
  • Own properties (stored directly on the object)
  • A hidden [[Prototype]] link to another object
dog object
├── name: "Max" (own property)
├── breed: "Labrador" (own property)
└── [[Prototype]] → Dog.prototype
    ├── bark: function
    └── [[Prototype]] → Animal.prototype
        ├── eat: function
        └── [[Prototype]] → Object.prototype
            ├── toString: function
            ├── valueOf: function
            └── [[Prototype]] → null

Implementation

class Object {
public:
    std::unordered_map<std::string, Value> properties;
    std::shared_ptr<Object> prototype;  // [[Prototype]] link
    
    Object(std::shared_ptr<Object> proto = nullptr)
        : prototype(proto) {}
    
    // Get property (traverses prototype chain)
    Value get(const std::string& name) {
        // Check own properties
        auto it = properties.find(name);
        if (it != properties.end()) {
            return it->second;
        }
        
        // Check prototype chain
        if (prototype) {
            return prototype->get(name);
        }
        
        // Property not found
        return Value::undefined();
    }
    
    // Set property (always on own object)
    void set(const std::string& name, const Value& value) {
        properties[name] = value;
    }
    
    // Check if property exists in prototype chain
    bool has(const std::string& name) {
        if (properties.count(name) > 0) {
            return true;
        }
        if (prototype) {
            return prototype->has(name);
        }
        return false;
    }
    
    // Check if property is own property
    bool hasOwn(const std::string& name) {
        return properties.count(name) > 0;
    }
};

Property lookup optimization

Prototype chain traversal can be slow. Real JavaScript engines use sophisticated optimizations.

Inline caching

Cache property locations to avoid repeated lookups:
struct PropertyCache {
    std::shared_ptr<Object> cachedObject;
    size_t cachedOffset;  // Offset in properties map
    bool isValid;
};

class VM {
private:
    std::vector<PropertyCache> propertyCache;
    
public:
    Value getProperty(Object* obj, const std::string& name, 
                     size_t cacheIndex) {
        PropertyCache& cache = propertyCache[cacheIndex];
        
        // Check cache hit
        if (cache.isValid && cache.cachedObject.get() == obj) {
            // Fast path: use cached offset
            return obj->properties[name];
        }
        
        // Cache miss: do full lookup
        Value result = obj->get(name);
        
        // Update cache
        cache.cachedObject = obj->shared_from_this();
        cache.isValid = true;
        
        return result;
    }
};
V8’s “hidden classes” and SpiderMonkey’s “shapes” use this technique to make property access as fast as C struct field access.

Polymorphic inline caching

Handle multiple object shapes at the same call site:
Single object shape observed:
if (shape == cachedShape) {
    return slots[cachedOffset];  // Fast path
}
// Slow path: full lookup
Writing properties in different orders creates different shapes, leading to megamorphic inline caches and slower performance.

Best practices

Minimize closure size

Only capture variables actually used by the closure to reduce memory usage

Avoid modifying prototypes

Changing prototypes at runtime invalidates inline caches and hurts performance

Consistent object shapes

Initialize objects with all properties in the same order for better optimization

Prefer composition

Use composition over deep inheritance chains for better performance and maintainability

Common pitfalls

Closures keep their entire environment alive, which can prevent garbage collection:
function createHandler() {
    const largeData = new Array(1000000);
    
    // This closure captures entire environment including largeData
    return function() {
        console.log('Handler called');
    };
}

// Better: explicitly null unused variables
function createHandler() {
    const largeData = new Array(1000000);
    // Use largeData...
    largeData = null;  // Allow GC
    
    return function() {
        console.log('Handler called');
    };
}
Modifying Object.prototype affects all objects:
// NEVER do this!
Object.prototype.myMethod = function() { /* ... */ };

// Now ALL objects have myMethod
const obj = {};
console.log(obj.myMethod); // Exists!

// Can cause security issues and break code
Own properties shadow prototype properties:
function Person(name) {
    this.name = name;
}

Person.prototype.greet = function() {
    console.log(`Hello, I'm ${this.name}`);
};

const person = new Person('Alice');
person.greet = 'not a function';  // Shadows prototype method

person.greet(); // TypeError: person.greet is not a function

Next steps

Parsing and AST

Learn about lexical analysis, tokenization, and AST construction

Bytecode and VM

Design bytecode and implement a stack-based virtual machine

Build docs developers (and LLMs) love