A factory function that wraps objects in a Proxy, validating all property assignments against a Zod schema at runtime while maintaining transparent read access.
import { z, ZodObject, ZodRawShape } from 'zod';
type ValidatedObject<T extends ZodRawShape> = z.infer<ZodObject<T>>;
interface ValidatedProxy<T extends ZodRawShape> {
data: ValidatedObject<T>;
getSnapshot: () => Readonly<ValidatedObject<T>>;
reset: (newData: ValidatedObject<T>) => void;
}
class ValidationError extends Error {
constructor(
public readonly property: string,
public readonly value: unknown,
public readonly zodErrors: z.ZodError
) {
const messages = zodErrors.errors.map(e => `${e.path.join('.')}: ${e.message}`).join('; ');
super(`Validation failed for property '${property}': ${messages}`);
this.name = 'ValidationError';
}
}
function createValidated<T extends ZodRawShape>(
schema: ZodObject<T>,
initialData?: Partial<ValidatedObject<T>>
): ValidatedProxy<T> {
const parseResult = schema.safeParse(initialData ?? {});
if (!parseResult.success && initialData !== undefined) {
throw new ValidationError('initialData', initialData, parseResult.error);
}
const internalData: ValidatedObject<T> = parseResult.success
? parseResult.data
: ({} as ValidatedObject<T>);
const handler: ProxyHandler<ValidatedObject<T>> = {
get(target, prop, receiver) {
if (typeof prop === 'symbol') {
return Reflect.get(target, prop, receiver);
}
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
if (typeof prop === 'symbol') {
return Reflect.set(target, prop, value, receiver);
}
const propSchema = schema.shape[prop as keyof T];
if (!propSchema) {
throw new Error(`Property '${prop}' does not exist in schema`);
}
const result = propSchema.safeParse(value);
if (!result.success) {
throw new ValidationError(prop, value, result.error);
}
return Reflect.set(target, prop, result.data, receiver);
},
deleteProperty(target, prop) {
if (typeof prop === 'symbol') {
return Reflect.deleteProperty(target, prop);
}
const propSchema = schema.shape[prop as keyof T];
if (propSchema && !propSchema.isOptional()) {
throw new Error(`Cannot delete required property '${prop}'`);
}
return Reflect.deleteProperty(target, prop);
},
has(target, prop) {
return Reflect.has(target, prop);
},
ownKeys(target) {
return Reflect.ownKeys(target);
},
getOwnPropertyDescriptor(target, prop) {
return Reflect.getOwnPropertyDescriptor(target, prop);
}
};
const proxy = new Proxy(internalData, handler);
return {
data: proxy,
getSnapshot(): Readonly<ValidatedObject<T>> {
return Object.freeze({ ...internalData });
},
reset(newData: ValidatedObject<T>): void {
const result = schema.safeParse(newData);
if (!result.success) {
throw new ValidationError('reset', newData, result.error);
}
for (const key of Object.keys(internalData)) {
delete (internalData as Record<string, unknown>)[key];
}
Object.assign(internalData, result.data);
}
};
}
// Example usage and demonstration
const UserSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150),
role: z.enum(['admin', 'user', 'guest']),
metadata: z.object({
createdAt: z.date(),
updatedAt: z.date().optional()
}).optional()
});
const validatedUser = createValidated(UserSchema, {
id: '550e8400-e29b-41d4-a716-446655440000',
name: 'John Doe',
email: 'john@example.com',
age: 30,
role: 'user'
});
// Transparent get - works normally
console.log(validatedUser.data.name); // 'John Doe'
console.log(validatedUser.data.email); // 'john@example.com'
// Valid set - works normally
validatedUser.data.name = 'Jane Doe';
validatedUser.data.age = 25;
console.log(validatedUser.data.name); // 'Jane Doe'
// Invalid set - throws ValidationError
try {
validatedUser.data.email = 'not-an-email';
} catch (error) {
if (error instanceof ValidationError) {
console.error(error.message);
// "Validation failed for property 'email': : Invalid email"
}
}
try {
validatedUser.data.age = -5;
} catch (error) {
if (error instanceof ValidationError) {
console.error(error.message);
// "Validation failed for property 'age': : Number must be greater than or equal to 0"
}
}
// Get immutable snapshot
const snapshot = validatedUser.getSnapshot();
console.log(snapshot);
// Reset entire object
validatedUser.reset({
id: '550e8400-e29b-41d4-a716-446655440001',
name: 'New User',
email: 'new@example.com',
age: 22,
role: 'admin'
});
export { createValidated, ValidationError, type ValidatedProxy, type ValidatedObject };This implementation creates a powerful runtime validation layer using JavaScript's Proxy API combined with Zod's schema validation. The createValidated factory function accepts a Zod object schema and optional initial data, returning a wrapped object that validates every property assignment against the schema.
The Proxy handler intercepts the set trap to validate incoming values before they're written to the target object. When a value fails validation, it throws a custom ValidationError that includes the property name, the invalid value, and the detailed Zod error information. The get trap remains transparent, simply delegating to Reflect.get without any transformation, ensuring read operations have zero overhead. Symbol properties are handled separately to maintain compatibility with built-in iteration and inspection mechanisms.
The implementation also handles edge cases like preventing deletion of required properties through the deleteProperty trap, and validates unknown properties by checking if they exist in the schema before allowing assignment. The reset method provides a way to replace the entire object atomically while still validating the complete new state. The getSnapshot method returns a frozen copy for safe sharing without risking mutations.
One key design decision is using safeParse instead of parse throughout, giving us control over error handling rather than letting Zod throw directly. This allows us to wrap errors in our custom ValidationError class with additional context. The validated data from safeParse is used for assignment, which means Zod transformations (like trimming strings or coercing types) are automatically applied.
This pattern is ideal for form state management, API response handling, or any scenario where you need guaranteed data integrity at runtime. However, be aware that Proxy validation adds overhead to every write operation, so avoid using it in performance-critical hot paths. For deeply nested objects, consider using Zod's nested object schemas carefully, as validation only occurs at the top level of property assignment—nested mutations won't trigger validation unless you reassign the entire nested object.