Hiding internal state in TypeScript objects

carlos-menezes2 pts0 comments

Hiding internal state in TypeScript objects ✦ Carlos Menezes<br>[↖]<br>Hiding internal state in TypeScript objects<br>Aug 20, 2026<br>Sometimes an object needs to carry state that consumers should not use directly.

Consider a small logger1 that stores context internally:

type Logger = {<br>context: Recordstring, unknown>;<br>info: (message: string) => void;<br>};<br>The context is part of the implementation, but it is also exposed as part of the logger’s public API. Nothing prevents consumers from reading or changing it.

Prefixing the property with an underscore only communicates intent:

type Logger = {<br>_context: Recordstring, unknown>;<br>info: (message: string) => void;<br>};

const logger: Logger = {<br>_context: {},<br>info: (message) => console.log(message),<br>};<br>logger._context = { userId: 123 }; // Nothing stops this<br>An unique symbol, however, can make that boundary stronger and the logger can still store the context as a regular object property

const CONTEXT_SYMBOL: unique symbol = Symbol("CONTEXT");

type Logger = {<br>info: (message: string) => void;<br>[CONTEXT_SYMBOL]: Recordstring, unknown>;<br>};

const createLogger = (<br>context: Recordstring, unknown>,<br>): Logger => ({<br>info: (message) => {<br>console.log({ message, context });<br>},<br>[CONTEXT_SYMBOL]: context,<br>});<br>Unlike string keys, symbols are unique values. A symbol-keyed property can only be accessed with the same symbol instance. By keeping that symbol inside the module, consumers have no direct reference to the property key. Code inside the module can access the property using the symbol:

export const createChildLogger = (<br>parent: Logger,<br>context: Recordstring, unknown>,<br>): Logger =><br>createLogger({<br>...parent[CONTEXT_SYMBOL],<br>...context,<br>});<br>Code outside the module cannot refer to CONTEXT_SYMBOL because it is not exported. The property also stays out of common string-based operations such as Object.keys and JSON.stringify.

const logger = createLogger({ requestId: "123" });

Object.keys(logger);<br>// ["info"]

JSON.stringify(logger);<br>// {}<br>This is useful when internal state must live on an object but should not become part of its practical public API. The object remains simple, while related functions in the same module retain access to the state.

Symbols, however, do not provide absolute runtime privacy. The property can still be discovered with Reflect.ownKeys or Object.getOwnPropertySymbols and object spread copies enumerable symbol properties. Still, for library APIs where the goal is to discourage accidental access rather than defend against hostile code, a non-exported unique symbol provides a clean boundary.

Footnotes

This is ripped out of @caravan-logger's rewrite which is still a work in progress. ↩

logger context symbol object message property

Related Articles