Skip to content
theqiyas.cc
Problem Solver
cc · blog · Engineering Sunday, June 21 2026 · 4 min

Data Protective Transformer

Wrapping data so it can be read but never changed, in JavaScript and Python. The holes in my first versions and how I closed them.


Sometimes I want to hand data to some code and be sure it can read the data but not change it. A config object, an API response passed around the app, user data going into a plugin. The idea is simple: wrap the data once, read it the normal way (data.from_user.name), and get an error on any write, at any depth.

The data I use in the examples below:

const message = {
  id: 1,
  from_user: { id: 2, name: "Qiyas" },
  replies: [{ text: "hi" }],
};

I wrote this once in JavaScript and once in Python. Reading both again, both had holes. Here is what was wrong and what I use now.

JavaScript

The first version wrapped the object in a Proxy and threw on set and deleteProperty. Three problems:

  1. Object.defineProperty(data, "id", { value: 99 }) went straight through. A proxy without a defineProperty trap passes that call to the real object, and the value changed. Same with Object.setPrototypeOf.
  2. Every read created a new proxy, so data.from_user === data.from_user was false. That breaks memoization, Set lookups and React’s equality checks, and it allocates on every read.
  3. The prop in target ? ... : undefined check did nothing. A missing property is already undefined.

The fixed version:

const views = new WeakMap();

const refuse = () => {
  throw new TypeError("This data is read-only");
};

const handler = {
  get(target, key, receiver) {
    return readOnly(Reflect.get(target, key, receiver));
  },
  set: refuse,
  deleteProperty: refuse,
  defineProperty: refuse,
  setPrototypeOf: refuse,
  preventExtensions: refuse,
};

function readOnly(value) {
  if (typeof value !== "object" || value === null) return value;
  let view = views.get(value);
  if (!view) {
    view = new Proxy(value, handler);
    views.set(value, view);
  }
  return view;
}

Every way of changing an object now has a trap. The WeakMap gives each object exactly one proxy, so the same object always comes back as the same view, and it does not keep the data alive after you drop it. Reflect.get passes the receiver along, so getters on classes still work. Arrays need no special case: data.replies.map(...) works, data.replies.push(...) throws.

What it does not handle:

  • Map, Set and Date do not work behind a proxy. Their methods check the real object and throw a TypeError. Convert them to plain objects or arrays first.
  • Do not wrap an object that is already frozen. Proxy rules say a frozen property must return exactly its own value, and here it would return a proxy, so the read throws.
  • This is a read-only view, not a copy. Whoever holds the original object can still change it, and you will see the change through the view.

When I need a real snapshot that nobody can change, I copy and freeze instead:

function deepFreeze(value) {
  if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
    Object.freeze(value);
    for (const key of Reflect.ownKeys(value)) deepFreeze(value[key]);
  }
  return value;
}

const snapshot = deepFreeze(structuredClone(message));

In strict mode, which is every ES module, writing to a frozen object throws. Outside strict mode the write fails silently, which is easy to miss. So: the proxy when I want a cheap live view, the frozen copy when the data must not move.

Python

The first Python version kept the fields in a dict and blocked __setattr__. When I actually tested it, more broke than I expected:

  • toJSON() used dumps without importing it. It raised NameError the first time anyone called it.
  • Only dicts were converted. A list inside the data stayed a normal list, so data.replies.append(...) worked and the dicts inside it could be edited freely. “Read-only at any depth” was not true.
  • The inner dict was reachable: data._data["id"] = 99 changed the value.
  • copy.copy, copy.deepcopy and pickle all crashed with RecursionError. They create an empty object first, then __getattr__ looks for _data, which does not exist yet, so it keeps calling itself.
  • Keys had to be strings because of **data. {1: "a"} raised TypeError.

The fixed version:

import json
from collections.abc import Mapping
from types import MappingProxyType

def freeze(value):
    if isinstance(value, Mapping):
        return ReadOnly(value)
    if isinstance(value, (list, tuple)):
        return tuple(freeze(v) for v in value)
    if isinstance(value, set):
        return frozenset(value)
    return value

class ReadOnly:
    __slots__ = ("_data",)

    def __init__(self, data):
        frozen = {key: freeze(value) for key, value in data.items()}
        object.__setattr__(self, "_data", MappingProxyType(frozen))

    def __getattr__(self, name):
        if name == "_data":
            raise AttributeError(name)
        try:
            return self._data[name]
        except KeyError:
            raise AttributeError(f"no field named {name!r}") from None

    def __getitem__(self, key):
        return self._data[key]

    def __setattr__(self, name, value):
        raise AttributeError("read-only")

    def __delattr__(self, name):
        raise AttributeError("read-only")

    def __reduce__(self):
        return ReadOnly, (dict(self._data),)

    def __repr__(self):
        return f"ReadOnly({dict(self._data)!r})"

    def to_json(self, **kwargs):
        return json.dumps(self, default=lambda o: dict(o._data), **kwargs)

What changed:

  • freeze walks the whole structure. Dicts become ReadOnly, lists become tuples, sets become frozensets.
  • MappingProxyType is a read-only window onto a dict, so _data can be read but not written.
  • __getattr__ refuses to look up _data itself, which ends the recursion, and __reduce__ tells copy and pickle how to rebuild the object.
  • __getitem__ makes data[1] and data["first-name"] work for keys that are not valid attribute names.
  • to_json imports json properly, and tuples come out as JSON arrays.

Using it:

data = ReadOnly({
    "id": 1,
    "from_user": {"id": 2, "name": "Qiyas"},
    "replies": [{"text": "hi"}],
})

data.from_user.name       # 'Qiyas'
data.replies[0].text      # 'hi'
data.id = 5               # AttributeError
data.replies.append(1)    # AttributeError

Python has no truly private fields, so someone determined can still call object.__setattr__(data, "_data", {...}). This protects against mistakes, not against someone trying to break it on purpose.

If you know the shape of your data in advance, @dataclass(frozen=True) or a Pydantic model with frozen=True gives the same protection plus type checks. ReadOnly is for when the shape comes from JSON and you do not control it.

The full code is on GitHub Gist: JavaScript and Python. The first versions are still in the revision history.

related