TypeScript Proxy and Reflect With Full Type Safety: Patterns That Do Not Lie to the Compiler
Most Proxy implementations sacrifice type safety for runtime flexibility. This guide shows the patterns that preserve complete type inference while using Proxy and Reflect APIs.
Most TypeScript Proxy implementations fail at compile time. The problem stems from how developers treat Proxy handlers as untyped objects that bypass the compiler's inference system. Teams write handlers that accept any for target and property parameters, then wonder why production bugs slip through when object shapes change. The type system offers no protection because the handler never declared its contract.
The failure mode here is subtle but expensive. A Proxy that intercepts property access on a typed object appears to work until a refactor changes the underlying type. The compiler stays silent while the handler continues accessing properties that no longer exist. Runtime errors appear in production because the Proxy's type signature never aligned with the target object's structure.
flowchart LR
A("Developer writes Proxy handler") --> B("Handler accepts any target")
B --> C("Compiler cannot infer property types")
C --> D("Runtime errors in production")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
TypeScript's Proxy and Reflect APIs support full type safety when developers apply generic constraints and discriminated unions. The approach requires declaring the target type explicitly in the handler signature and using Reflect methods to preserve type information across operations. The compiler then enforces property access at compile time and catches shape mismatches before deployment.
flowchart LR
A("Developer writes Proxy handler") --> B("Handler uses generic constraint")
B --> C("Compiler infers property types")
C --> D("Type errors caught at compile time")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This distinction is critical. A properly typed Proxy handler acts as a compile-time contract that evolves with the codebase. When the target type changes, the compiler flags every trap that needs updating. The pattern eliminates an entire class of runtime errors while maintaining the dynamic interception capabilities that make Proxy valuable.
Key Takeaways
- TypeScript Proxy handlers lose type safety when they accept
anyfor target or property parameters—generic constraints preserve inference across all trap operations - The Reflect API maintains type information that direct property access discards, making it essential for type-safe Proxy implementations
- Discriminated unions inside Proxy handlers enable runtime validation that the compiler can verify, catching type mismatches before they reach production
- Observable object patterns built on type-safe Proxies provide change tracking without sacrificing compile-time guarantees or introducing dynamic property errors
- The tradeoff between Proxy flexibility and traditional validation appears in LOC count and coupling—Proxies win when object shapes vary frequently
Understanding Proxy Traps and Type Inference Limitations
The TypeScript compiler infers Proxy types from the target object when developers omit explicit type parameters. This creates a problem when handler traps manipulate properties without declaring their types. The compiler cannot connect the handler's property parameter to the target's actual keys, so it falls back to string | symbol and loses all structural information.
flowchart TD
A("Proxy created with target") --> B("Compiler infers ProxyHandler type")
B --> C{"Handler declares property types?"}
C -->|No| D("Property type becomes string | symbol")
C -->|Yes| E("Property type stays keyof Target")
D --> F("All property access requires runtime checks")
E --> G("Compiler validates property names")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style G stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The implication here is that unsafe handler signatures compromise the entire Proxy chain. A handler that accepts target: any propagates type loss to every trap method. When the get trap returns any, the consuming code loses type information even if the original target had precise types. This cascade effect makes poorly typed handlers more dangerous than untyped JavaScript.
Consider a validation Proxy that intercepts property writes. Without generic constraints, the handler cannot distinguish between valid and invalid property names at compile time. The set trap accepts any string as a property key, allowing writes to non-existent properties. The compiler cannot flag these errors because it never knew the target's shape.
The solution requires threading type parameters through the handler definition. When a Proxy handler declares itself as ProxyHandler<T> with a known T, the compiler enforces that all trap parameters match T's structure. Property keys become keyof T instead of generic strings. Property values become T[keyof T] instead of any. The type system can then validate every operation the handler performs.
This matters because production codebases change. A Proxy handler written without type constraints works today but breaks silently when developers refactor the target object six months later. The handler continues intercepting properties using outdated names. No compiler error appears because the handler never declared what it expected. The first signal comes from users reporting missing data or broken features.
Building a Type-Safe Proxy Handler with Generic Constraints
Type-safe Proxy handlers begin with explicit generic parameters that bind the handler to its target type.
interface User {
id: number;
name: string;
email: string;
}
function createValidatedProxy<T extends object>(target: T): T {
const handler: ProxyHandler<T> = {
get(target: T, property: keyof T, receiver: any): any {
const value = Reflect.get(target, property, receiver);
console.log(`Accessed property: ${String(property)}`);
return value;
},
set(target: T, property: keyof T, value: any, receiver: any): boolean {
if (property in target) {
console.log(`Setting ${String(property)} to ${value}`);
return Reflect.set(target, property, value, receiver);
}
throw new Error(`Property ${String(property)} does not exist on target`);
}
};
return new Proxy(target, handler);
}
const user: User = { id: 1, name: "Alice", email: "alice@example.com" };
const proxiedUser = createValidatedProxy(user);
proxiedUser.name = "Bob"; // Valid - compiler knows 'name' exists
// proxiedUser.age = 30; // Compile error - 'age' does not exist on UserThe generic constraint T extends object ensures the target has indexable properties. The handler's trap signatures use keyof T for property parameters, which restricts them to the target's actual keys. When developers attempt to access or set a property that does not exist on T, the compiler flags the error immediately.
The pattern extends to validation logic that depends on property types. When the set trap needs to validate a value before assignment, it can use conditional types to ensure the value matches the expected type:
function createTypedProxy<T extends object>(target: T): T {
const handler: ProxyHandler<T> = {
set<K extends keyof T>(
target: T,
property: K,
value: T[K],
receiver: any
): boolean {
// Value is guaranteed to match T[K] at compile time
if (typeof value !== typeof target[property]) {
throw new Error(
`Type mismatch: expected ${typeof target[property]}, got ${typeof value}`
);
}
return Reflect.set(target, property, value, receiver);
}
};
return new Proxy(target, handler);
}
const config = createTypedProxy({ timeout: 5000, retries: 3 });
config.timeout = 10000; // Valid
// config.timeout = "10000"; // Compile error - string not assignable to numberThis handler declares the property parameter as a generic K extends keyof T and the value parameter as T[K]. The compiler infers that when property is "timeout", the value must be a number. Attempts to pass mismatched types fail at compile time. The runtime check remains as a guard against external data, but the compiler prevents internal mistakes.
The limitation appears when trap methods need to return values derived from the target. A naive get trap that returns any destroys type information:
// Anti-pattern: get trap returns any
get(target: T, property: keyof T): any {
return Reflect.get(target, property);
}
const value = proxiedUser.name; // Type is 'any', not 'string'The correct pattern uses a generic return type that preserves the property's original type:
get<K extends keyof T>(target: T, property: K): T[K] {
return Reflect.get(target, property) as T[K];
}
const value = proxiedUser.name; // Type is 'string'The type assertion as T[K] is safe here because Reflect.get returns the actual property value. The compiler cannot infer this automatically due to Reflect's signature, but the assertion aligns with runtime behavior. This pattern maintains type safety while allowing the handler to intercept and log access.
Reflect API: Preserving Type Information Across Operations
The Reflect API provides methods that mirror Proxy traps while preserving type information that direct property access loses. When developers use Reflect.get instead of bracket notation inside a Proxy handler, they maintain the type relationship between the property key and its value. This matters because TypeScript's type narrowing works differently for direct access versus Reflect calls.
interface Config {
apiUrl: string;
timeout: number;
features: { [key: string]: boolean };
}
function createConfigProxy<T extends object>(target: T): T {
return new Proxy(target, {
get<K extends keyof T>(target: T, property: K, receiver: any): T[K] {
// Reflect.get maintains type relationship
const value = Reflect.get(target, property, receiver) as T[K];
if (property === 'timeout' && typeof value === 'number') {
// Type guard works because value is T[K]
return (Math.max(1000, value) as T[K]);
}
return value;
}
});
}
const config = createConfigProxy<Config>({
apiUrl: "https://api.example.com",
timeout: 500,
features: { darkMode: true }
});
const timeout = config.timeout; // Type is number, value is 1000Reflect methods also handle edge cases that direct property access misses. When a property has a getter defined on its prototype, Reflect.get invokes the getter with the correct this context via the receiver parameter. Direct bracket access would invoke the getter with the Proxy as this, breaking code that relies on the original object's context.
The pattern extends to property deletion and enumeration. Reflect.deleteProperty and Reflect.ownKeys preserve type information about which properties exist on an object:
function createImmutableProxy<T extends object>(target: T): Readonly<T> {
return new Proxy(target, {
set(): boolean {
throw new Error("Cannot modify immutable object");
},
deleteProperty(): boolean {
throw new Error("Cannot delete properties from immutable object");
},
ownKeys(target: T): Array<keyof T> {
// Reflect.ownKeys returns all own properties
return Reflect.ownKeys(target) as Array<keyof T>;
}
}) as Readonly<T>;
}The return type Readonly<T> tells the compiler that the Proxy produces a read-only version of the target. The ownKeys trap returns Array<keyof T> instead of (string | symbol)[], preserving the relationship between enumerated keys and the target type. Code that iterates over the Proxy's keys can use type narrowing on each key.
This distinction becomes critical when Proxy handlers need to forward operations to nested objects. A deep Proxy that recursively wraps nested properties loses type information unless each level preserves types through Reflect:
function createDeepProxy<T extends object>(target: T): T {
return new Proxy(target, {
get<K extends keyof T>(target: T, property: K, receiver: any): T[K] {
const value = Reflect.get(target, property, receiver);
if (value !== null && typeof value === 'object') {
// Recursively wrap nested objects
return createDeepProxy(value as object) as T[K];
}
return value as T[K];
}
});
}
interface NestedConfig {
database: {
host: string;
port: number;
};
cache: {
ttl: number;
};
}
const config = createDeepProxy<NestedConfig>({
database: { host: "localhost", port: 5432 },
cache: { ttl: 3600 }
});
const port = config.database.port; // Type is numberThe recursive call to createDeepProxy wraps nested objects in their own Proxies. The type assertion as T[K] preserves the original property type. Without this assertion, the compiler would infer the return type as object, losing all structural information about the nested object.
The tradeoff appears in complexity versus type safety. Using Reflect methods requires more verbose handler code than direct property access. The benefit comes from compile-time guarantees that prevent an entire class of runtime errors. For production codebases that evolve over time, this tradeoff favors Reflect.
Advanced Pattern: Proxy with Discriminated Unions for Runtime Validation
Discriminated unions inside Proxy handlers enable runtime validation that the compiler can verify. The pattern combines TypeScript's type narrowing with Proxy traps to enforce constraints that depend on property values. This matters when objects have interdependent fields where changing one property should trigger validation on others.
type Status = "idle" | "loading" | "success" | "error";
interface RequestState {
status: Status;
data: unknown | null;
error: Error | null;
}
type ValidRequestState =
| { status: "idle"; data: null; error: null }
| { status: "loading"; data: null; error: null }
| { status: "success"; data: unknown; error: null }
| { status: "error"; data: null; error: Error };
function createStateProxy(initial: ValidRequestState): ValidRequestState {
let state = initial;
return new Proxy(state, {
set(target: RequestState, property: keyof RequestState, value: any): boolean {
const newState = { ...target, [property]: value };
// Validate state transitions using discriminated union
if (newState.status === "idle" && (newState.data !== null || newState.error !== null)) {
throw new Error("Idle state must have null data and error");
}
if (newState.status === "loading" && (newState.data !== null || newState.error !== null)) {
throw new Error("Loading state must have null data and error");
}
if (newState.status === "success" && (newState.data === null || newState.error !== null)) {
throw new Error("Success state must have data and null error");
}
if (newState.status === "error" && (newState.data !== null || newState.error === null)) {
throw new Error("Error state must have null data and an error");
}
state = newState as ValidRequestState;
return Reflect.set(target, property, value);
},
get(target: RequestState, property: keyof RequestState): any {
return state[property];
}
}) as ValidRequestState;
}
const request = createStateProxy({ status: "idle", data: null, error: null });
request.status = "loading"; // Valid
// request.data = { result: 42 }; // Throws - loading state cannot have dataThe discriminated union ValidRequestState defines the legal combinations of status, data, and error. The Proxy's set trap validates that every property change maintains one of these valid states. When developers attempt to set data while status is "loading", the trap throws an error before the invalid state persists.
flowchart LR
A("Property assignment attempt") --> B("Proxy set trap intercepts")
B --> C("Construct new state object")
C --> D{"State matches discriminated union?"}
D -->|No| E("Throw validation error")
D -->|Yes| F("Update internal state")
F --> G("Return success to caller")
style E stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style F stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style G stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This pattern extends to form validation where field constraints depend on other fields. A shipping form might require a phone number only when the delivery method is "express":
type DeliveryMethod = "standard" | "express";
interface ShippingForm {
method: DeliveryMethod;
address: string;
phone: string | null;
}
type ValidShippingForm =
| { method: "standard"; address: string; phone: string | null }
| { method: "express"; address: string; phone: string };
function createFormProxy(initial: ValidShippingForm): ValidShippingForm {
let form = initial;
return new Proxy(form, {
set(target: ShippingForm, property: keyof ShippingForm, value: any): boolean {
const newForm = { ...target, [property]: value };
if (newForm.method === "express" && !newForm.phone) {
throw new Error("Express delivery requires a phone number");
}
form = newForm as ValidShippingForm;
return Reflect.set(target, property, value);
},
get(target: ShippingForm, property: keyof ShippingForm): any {
return form[property];
}
}) as ValidShippingForm;
}The compiler knows that when method is "express", the phone property cannot be null. Code that checks the delivery method can safely access the phone number without null checks. The Proxy enforces this invariant at runtime while the discriminated union enforces it at compile time.
The limitation appears when validation rules become complex. Deep validation across many interdependent fields can make the set trap difficult to maintain. For these cases, developers should consider whether a state machine pattern would provide clearer validation logic. The Proxy pattern works best when validation rules are straightforward and the invalid states are obvious.
Comparison: Type-Safe Proxy vs Traditional Runtime Validation
Traditional runtime validation separates type checking from object construction. Developers define validator functions that accept unknown input and return typed results. The validation happens at the boundary where external data enters the system. Type-safe Proxies embed validation into the object itself, checking constraints on every property access or mutation.
flowchart LR
subgraph TraditionalValidation["Traditional Validation Pattern"]
A1("External data arrives") --> B1("Validator function runs")
B1 --> C1{"Data matches schema?"}
C1 -->|No| D1("Throw validation error")
C1 -->|Yes| E1("Return typed object")
end
subgraph ProxyValidation["Type-Safe Proxy Pattern"]
A2("Object created with Proxy") --> B2("Property mutation attempt")
B2 --> C2("Proxy set trap validates")
C2 --> D2{"Mutation maintains invariant?"}
D2 -->|No| E2("Throw validation error")
D2 -->|Yes| F2("Apply mutation")
end
style D1 stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style E2 stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style E1 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style F2 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The traditional approach requires developers to call validation functions explicitly. A JSON API response needs parsing through a validator before the code can treat it as a known type:
interface ApiUser {
id: number;
name: string;
email: string;
}
function validateUser(data: unknown): ApiUser {
if (typeof data !== "object" || data === null) {
throw new Error("User data must be an object");
}
const obj = data as Record<string, unknown>;
if (typeof obj.id !== "number") {
throw new Error("User id must be a number");
}
if (typeof obj.name !== "string") {
throw new Error("User name must be a string");
}
if (typeof obj.email !== "string") {
throw new Error("User email must be a string");
}
return obj as ApiUser;
}
const response = await fetch("/api/user");
const data = await response.json();
const user = validateUser(data); // Type is ApiUser after validationThe Proxy approach moves validation to property mutations. After the initial object passes validation, the Proxy ensures all subsequent changes maintain validity:
function createValidatedUser(data: unknown): ApiUser {
const user = validateUser(data); // Initial validation
return new Proxy(user, {
set(target: ApiUser, property: keyof ApiUser, value: any): boolean {
// Re-validate on mutation
if (property === "id" && typeof value !== "number") {
throw new Error("User id must be a number");
}
if (property === "name" && typeof value !== "string") {
throw new Error("User name must be a string");
}
if (property === "email" && typeof value !== "string") {
throw new Error("User email must be a string");
}
return Reflect.set(target, property, value);
}
});
}The tradeoff centers on where errors appear. Traditional validation catches all issues at the boundary. If data is invalid, the validator throws before the object enters the system. The Proxy approach allows objects to exist temporarily in invalid states between property mutations. The set trap catches the invalid state, but only after the mutation was attempted.
The traditional pattern requires more lines of code for complex nested structures. Validating a deeply nested object tree needs recursive validator functions that mirror the object's shape. The Proxy pattern can validate nested structures with a single recursive handler that wraps child objects automatically.
The performance cost differs between approaches. Traditional validation runs once at the boundary. The Proxy approach runs validation on every property mutation. For objects that change frequently, this overhead accumulates. For objects that rarely change after creation, the cost is negligible.
The maintainability question depends on how often object shapes change. When developers add new fields to an interface, traditional validation requires updating validator functions to check the new fields. Proxy handlers using generic constraints automatically enforce new fields through type inference. The compiler flags missing validation logic when the handler's types no longer match the target.
For production use, developers should choose based on mutation frequency and validation complexity. Objects that external sources create and the system never modifies should use traditional validation at the boundary. Objects that the application mutates frequently and need ongoing validation benefit from type-safe Proxies. The patterns are not mutually exclusive—initial validation can happen at the boundary, then a Proxy can enforce ongoing validity.
Production Patterns: Observable Objects and Change Tracking
Observable objects built on type-safe Proxies provide change tracking without sacrificing compile-time guarantees. The pattern intercepts property mutations to notify listeners when values change. This matters for reactive state management where UI components need to re-render when underlying data updates.
type Listener<T> = (property: keyof T, oldValue: any, newValue: any) => void;
function createObservable<T extends object>(target: T): T & { subscribe(listener: Listener<T>): () => void } {
const listeners = new Set<Listener<T>>();
const proxy = new Proxy(target, {
set<K extends keyof T>(
target: T,
property: K,
value: T[K],
receiver: any
): boolean {
const oldValue = target[property];
if (oldValue === value) {
return true; // No change, skip notification
}
const success = Reflect.set(target, property, value, receiver);
if (success) {
listeners.forEach(listener => {
listener(property, oldValue, value);
});
}
return success;
}
});
return Object.assign(proxy, {
subscribe(listener: Listener<T>) {
listeners.add(listener);
return () => listeners.delete(listener);
}
});
}
interface AppState {
count: number;
user: string | null;
}
const state = createObservable<AppState>({ count: 0, user: null });
const unsubscribe = state.subscribe((property, oldValue, newValue) => {
console.log(`${String(property)} changed from ${oldValue} to ${newValue}`);
});
state.count = 1; // Logs: "count changed from 0 to 1"
state.user = "Alice"; // Logs: "user changed from null to Alice"The subscribe method allows multiple listeners to observe changes. Each listener receives the property name, old value, and new value when a mutation occurs. The compiler enforces that property is keyof T, so listeners cannot observe non-existent properties.
flowchart LR
A("Property mutation attempt") --> B("Proxy set trap intercepts")
B --> C{"Value changed?"}
C -->|No| D("Return success immediately")
C -->|Yes| E("Apply mutation via Reflect.set")
E --> F("Notify all subscribed listeners")
F --> G("Listeners receive property, old, new")
style E stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style G stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The pattern extends to computed properties that derive values from other properties. When a computed property depends on multiple source properties, the Proxy can track dependencies and invalidate caches:
interface Product {
price: number;
quantity: number;
taxRate: number;
}
interface ComputedProduct extends Product {
readonly total: number;
}
function createComputedProduct(initial: Product): ComputedProduct {
let cachedTotal: number | null = null;
const proxy = new Proxy(initial, {
get<K extends keyof Product>(
target: Product,
property: K | "total",
receiver: any
): any {
if (property === "total") {
if (cachedTotal === null) {
cachedTotal = target.price * target.quantity * (1 + target.taxRate);
}
return cachedTotal;
}
return Reflect.get(target, property, receiver);
},
set<K extends keyof Product>(
target: Product,
property: K,
value: Product[K],
receiver: any
): boolean {
cachedTotal = null; // Invalidate cache on any property change
return Reflect.set(target, property, value, receiver);
}
}) as ComputedProduct;
return proxy;
}
const product = createComputedProduct({ price: 100, quantity: 2, taxRate: 0.1 });
console.log(product.total); // 220, computed on first access
console.log(product.total); // 220, returned from cache
product.quantity = 3;
console.log(product.total); // 330, recomputed after quantity changeThe get trap computes the total when accessed and caches the result. The set trap invalidates the cache whenever any source property changes. This ensures the computed property always reflects current values while avoiding unnecessary recalculation.
The failure mode appears when Proxy chains become deep. An observable object that contains nested observable objects needs careful handling to prevent notification storms. When a deep property changes, the outer Proxy might not detect the mutation because the change happened on a nested Proxy:
interface NestedState {
settings: {
theme: string;
notifications: boolean;
};
}
// Anti-pattern: nested mutations do not trigger outer observers
const state = createObservable<NestedState>({
settings: { theme: "dark", notifications: true }
});
state.subscribe((property) => {
console.log(`${String(property)} changed`); // Only logs when settings object is replaced
});
state.settings.theme = "light"; // Does not trigger outer observerThe solution requires wrapping nested objects in their own observable Proxies and propagating change notifications up the tree. This adds complexity but maintains type safety and notification consistency across all nesting levels.
For production deployment, developers should consider whether the observable pattern fits their state management strategy. Applications using Redux or similar frameworks already have centralized state updates and do not benefit from per-object observability. Applications with decentralized state where many independent objects need change tracking benefit significantly from observable Proxies.
Frequently Asked Questions
When should teams use type-safe Proxies instead of traditional validation functions?
Type-safe Proxies work best for objects that require ongoing validation after creation, especially when mutations must maintain complex invariants across multiple properties. Traditional validation functions excel at boundary validation where external data enters the system once and then remains immutable.
Do Proxy handlers introduce runtime performance overhead that matters in production?
Yes, every Proxy trap invocation adds overhead compared to direct property access. For objects that change rarely, the cost is negligible. For hot paths that mutate properties thousands of times per second, the overhead becomes measurable. Developers should profile critical sections and avoid Proxies in performance-sensitive loops.
How do type-safe Proxies interact with JSON serialization and deserialization?
Standard JSON.stringify does not invoke Proxy traps—it serializes the underlying target object directly. This means custom serialization logic in a Proxy's get trap will not affect the JSON output. For controlled serialization, developers need to implement a toJSON method on the target object or use a custom serialization function that explicitly calls Proxy methods.
Can Proxy handlers preserve readonly modifiers from the target type?
The TypeScript compiler enforces readonly at compile time, but Proxy traps operate at runtime where readonly modifiers do not exist. A Proxy can implement readonly behavior by throwing errors in the set and deleteProperty traps, but this requires explicit runtime checks. The type system helps by marking the Proxy's return type as Readonly<T>, which prevents compile-time mutations.
What happens when developers use Proxies with classes that have private fields?
JavaScript private fields (using # syntax) are not accessible to Proxy traps because they exist on a per-instance basis tied to the original class. Attempting to access a private field through a Proxy throws a TypeError. For classes with private state, developers must either avoid Proxies or restructure the class to use TypeScript's private keyword (which compiles to public JavaScript properties).
Conclusion: When Type-Safe Proxies Belong in Your Codebase
Type-safe Proxies solve the specific problem of runtime validation that must evolve with compile-time types. When object shapes change during refactoring, a properly typed Proxy handler fails at compile time rather than allowing invalid states into production. This matters for teams maintaining large codebases where silent type drift causes bugs months after the original change.
The pattern belongs in codebases that need observable state, validation on mutation, or dynamic property interception with type guarantees. It does not belong in performance-critical paths or simple boundary validation where traditional validator functions suffice. The deciding factor is whether the object requires ongoing validation after creation or just a one-time check at the boundary.
For developers implementing these patterns, start with explicit generic constraints on every handler and use Reflect methods to preserve type information. Add discriminated unions when validation depends on property combinations. Measure performance in realistic scenarios before deploying observable Proxies in hot paths.
That covers the essential patterns for type-safe Proxy and Reflect usage in TypeScript. Apply these in production and the difference will be immediate—fewer runtime errors from stale property access, better IDE support from preserved types, and validation logic that evolves with your domain models.