Deeply freeze object

This commit is contained in:
2026-08-10 01:58:04 +00:00
parent 6693964f5b
commit 576a78022c
4 changed files with 95 additions and 7 deletions
+14 -7
View File
@@ -1,13 +1,17 @@
import type { DeeplyReadonly } from './types.ts';
import { deepFreeze } from './misc.ts';
export type EventMap = Record<string, unknown>;
type Listener<T> = (detail: Readonly<T>) => void;
type Listener<T> = (detail: DeeplyReadonly<T>) => void;
/**
* Internally permits listeners for individual event payloads to be stored
* in a collection typed with the union of all event payloads.
*/
type StoredListener<T> = {
bivarianceHack(detail: Readonly<T>): void;
bivarianceHack(detail: DeeplyReadonly<T>): void;
}['bivarianceHack'];
/**
@@ -79,7 +83,7 @@ export class EventEmitter<T extends EventMap> {
* @returns An off callback that can be called to stop listening for events.
*/
once<K extends keyof T>(type: K, listener: Listener<T[K]>, debounceMilliseconds?: number): OffCallback {
const wrappedListener: Listener<T[K]> = (detail: Readonly<T[K]>) => {
const wrappedListener: Listener<T[K]> = (detail: DeeplyReadonly<T[K]>) => {
this.off(type, listener);
listener(detail);
};
@@ -151,8 +155,11 @@ export class EventEmitter<T extends EventMap> {
const listeners = this.#listeners.get(type);
if (!listeners) return false;
// Freeze the payload to make it readonly.
const readonlyPayload = Object.freeze(payload);
// Clone the payload to avoid freezing the original object.
const payloadClone = structuredClone(payload);
// Freeze the cloned payload to make it readonly.
const readonlyPayload = deepFreeze(payloadClone);
// Emit the event to all listeners.
listeners.forEach((entry) => {
@@ -177,7 +184,7 @@ export class EventEmitter<T extends EventMap> {
* @param timeoutMs - The timeout in milliseconds.
* @returns The event payload.
*/
async waitFor<K extends keyof T>(type: K, predicate: (payload: Readonly<T[K]>) => boolean, timeoutMs?: number): Promise<Readonly<T[K]>> {
async waitFor<K extends keyof T>(type: K, predicate: (payload: DeeplyReadonly<T[K]>) => boolean, timeoutMs?: number): Promise<DeeplyReadonly<T[K]>> {
// Create a promise to wait for the event to be emitted.
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
@@ -225,7 +232,7 @@ export class EventEmitter<T extends EventMap> {
// Create a timeout variable.
let timeout: ReturnType<typeof setTimeout>;
return (detail: Readonly<T[K]>) => {
return (detail: DeeplyReadonly<T[K]>) => {
// If a debounce timer is already pending, clear it before scheduling the next one.
if (timeout !== undefined) {
clearTimeout(timeout);
+26
View File
@@ -1,3 +1,5 @@
import { DeeplyReadonly } from "./types";
/**
* Validate the value is within the bounds, returning true if it is within the bounds, false otherwise
*
@@ -30,3 +32,27 @@ export const tryAsync = async (fn: () => unknown, onError?: (error: Error) => vo
onError?.(errorInstance);
}
};
/**
* Recursively freezes an object by iterating over all properties and freezing them.
* @param obj - The object to freeze.
* @returns The frozen object.
*/
export const deepFreeze = <T>(value: T): DeeplyReadonly<T> => {
if (
value !== null &&
(typeof value === 'object' || typeof value === 'function')
) {
for (const key of Reflect.ownKeys(value)) {
const descriptor = Reflect.getOwnPropertyDescriptor(value, key);
if (descriptor && 'value' in descriptor) {
deepFreeze(descriptor.value);
}
}
Object.freeze(value);
}
return value;
}
+11
View File
@@ -0,0 +1,11 @@
/**
* A deeply readonly type.
* @template T - The type to make deeply readonly.
* @returns The deeply readonly type.
*/
export type DeeplyReadonly<T> = {
readonly [K in keyof T]:
T[K] extends (...args: never[]) => unknown
? T[K]
: DeeplyReadonly<T[K]>;
};