Meta-programming magic with Type Script and JavaScript Object Proxies

Meta-programming magic with Type Script and JavaScript Object Proxies
Image of a robot programming - as imagined by DALL-E

Way back in 2015, ECMAScript 2015 (ES6) introduced amongst a host of other new features, Object Proxies. An Object Proxy allows you to take an JavaScript object, and wrap it in a proxy type which allows you to intercept property access, method invocation, and several reflection operations (eg. Reflect.ownKeys,  'property' in obj etc).

Some of the applications for object proxies are immediately obvious. For instance you could implement a property changed notification pattern by specifying a set trap.

function attachOnChangeNotifier<T extends object>(
  obj: T,
  onChange: (prop: keyof T, prev: unknown, next: unknown) => void,
) {
  return new Proxy(obj, {
    set(target: any, property, nextValue) {
      const prev = target[property]
      target[property] = nextValue
      if (prev !== nextValue) {
        onChange(property as keyof T, prev, nextValue)
      }
      return true
    },
  })
}

const myModel = attachOnChangeNotifier(
  {
    firstName: 'Max',
    lastName: 'Maxwell',
  },
  (prop, prev, next) => console.log(`${prop} changed from ${prev} to ${next}`),
)

// Logs "firstName changed from Max to Alex" 
myModel.firstName = 'Alex'
// Logs "lastName changed from Maxwell to Smith" 
myModel.lastName = 'Smith'
Property Changed Example

You could also use the set trap to validate the value before allowing it to be set, or use a get trap to format a value before returning it. These examples assume the property and/or method you're wrapping actually exists, but does it have to?


Strongly typed form field names

If you've ever written a form in react, irrespective of the form framework you have chosen, it's probably looked something like this.

interface LoginFormFields {
  username: string
  password: string
}

function LoginForm() {
  const onSubmit = useCallback((values: LoginFormFields) => {
    // do something with values
  }, [])
  
  return <Form onSubmit={onSubmit}>
    <InputField name={'usernaem'}  />
    <InputField name={'password'} type='password' />
  </Form>
}
Login Form Example

The astute amongst you might have noticed the typo on the name property of the user name input field. Sometimes it's a typo, sometimes someone has renamed a field in a refactor and a value that was correct is no longer. The issue is we don't have any type safety between our name properties and the form they are a part of. One way we could achieve this type safety would be to add generics to our field components.

function InputField<TFormFields>({ name }: { name: keyof TFormFields }) {
  /* implementation */
}

<InputField<LoginFormFields> name='username' />

The downside to this solution is verbose jsx, and potentially the need to refactor a lot of form components. All we really need is a pattern that makes it easy to auto complete correct values, and gives us that coveted red squiggle at compile time if something's not quite right. Something like this:

interface LoginFormFields {
  username: string
  password: string
}

function LoginForm() {
  const fieldNames = useFieldNames<LoginFormFields>()
  const onSubmit = useCallback((values: LoginFormFields) => {
    // do something with values
  }, [])
  
  return <Form onSubmit={onSubmit}>
    <InputField name={fieldNames.username}  />
    <InputField name={fieldNames.password} type='password' />
  </Form>
}

Our custom hook returns an object with all the properties of the generic type provided to it, but instead of the original data type every property just returns the name of itself (ie. { username: 'username', password: 'password' }). The conditional type for the return value of our hook would look something like this:

type FormFields<T> = {
  [key in keyof T]-?: key
}
const useFieldNames = <T>(): FormFields<T> => {
  return ????
}

Now everything looks good at compile time, but at run time the properties don't exist. The object we need is quite simple, but how do we produce it when type script doesn't allow us to reflect the properties of an interface at run time? The answer of course is object proxies! All we need to do is intercept property access on an empty object and return the name of the property which was being accessed.

function useFieldNames<T>(): FormFields<T> {
  return new Proxy({}, {
    get(target, property) {
      return property
    },
  }) as FormFields<T>
}

Alright, so that was pretty basic and not all that magic - but still a great solution to a common problem. The generic type doesn't even have to be a simple interface, such as when you're using zod validation.

const loginValidator = z.object({
  username: z.string(),
  password: z.string(),
})

const fieldNames = useFieldNames<z.infer<typeof loginValidator>>()

// Or wrap it in a higher order hook
function useValidatorFields<T extends Validator<any>>(validator: T) {
   return useFormFields<z.infer<T>>()
}

const fieldNames = useValidatorFields(loginValidator)

A better property changed pattern

Let's step it up a notch and go back to our property changed pattern with a different take. Instead of providing a handler at the time we create the proxy we want to have an event we can subscribe to for each property of a given object.

Start by defining a conditional type that given an object type, returns that type with the on change event hooks.

// This type describes what the callback function looks like for 
// a given property. The type of prev and next should match 
// the type of the property.
type OnChangeCallbackFn<TObj extends object, TProp extends keyof TObj> = 
  (prev: TObj[TProp], next: TObj[TProp]) => void

// Describe a dispose fn
type DisposeFn = () => void

// For each property in the original type, add a matched
// onPropertyNameChange method which takes a callback 
// function from above.
type ObjectWithOnChange<TObj extends object> = TObj & {
  [TProp in keyof TObj as TProp extends string 
    ? `on${Capitalize<TProp>}Change` 
    : never]: (handler: OnChangeCallbackFn<TObj, TProp>) => DisposeFn
}

function addOnChangeEvents<T extends object>(obj: T): ObjectWithOnChange<T> {
  return ???
}

const myObject = {
  firstName: 'John',
  lastName: 'Smith',
  age: 40,
}
const myEnhancedObject = addOnChangeEvents(myObject)

myEnhancedObject.onFirstNameChange((prev, next) => 
  console.log(`First name changed from ${prev} to ${next}`))
myEnhancedObject.onLastNameChange((prev, next) => 
  console.log(`Last name changed from ${prev} to ${next}`))
myEnhancedObject.onAgeChange((prev, next) => 
  console.log(`Age has changed by ${next - prev} to ${next}`))
Enhanced onChange events

With property specific handlers, we can have property specific typing of the prev and next parameters. Our conditional type ObjectWithOnChange is taking our existing type and intersecting it with a new type that has an onPropertyNameChange method for each property on the original type. We're using Template Literal Types to produce the method name if you are unfamiliar with the syntax.

Whilst our type definitions above work, the intellisense leaves a little to be desired in terms of describing what's actually there.

If we tweak our type definitions to include some conditions we can encourage intellisense to evaluate the expressions and provide something more useful. This has no effect on the actual type, but does change how they're displayed by intellisense.

type OnChangeFunc<T extends object, TProp extends keyof T> = T extends any
  ? (prev: T[TProp], next: T[TProp]) => void 
  : never

type ObjectWithOnChange<T extends object> = (T & {
  [TProp in keyof T as TProp extends string 
    ? `on${Capitalize<TProp>}Change` 
    : never]: (handler: OnChangeFunc<T, TProp>) => void
}) extends infer O ? { [key in keyof O]: O[key]} : never

Now all that's left to do is implement these onChange events via an object proxy. We'll make an assumption here that the object we're wrapping will never have an existing property or method that looks like onPropertyNameChange. We'll also assume property names will never start with an uppercase character.

function addOnChangeEvents<T extends object>(obj: T): ObjectWithOnChange<T> {
  const handlers = {} as Record<
    keyof T, 
    undefined | Array<OnChangeFunc<T, keyof T>>
  >

  return new Proxy(obj, {
    get(target, property) {
      if (typeof property !== 'string') 
        return Reflect.get(target, property)
      const match = /^on(.*)Change$/.exec(property)
      if (match === null) 
        return Reflect.get(target, property)
      const propertyName = (match[1][0].toLowerCase() 
        + match[1].substring(1)) as keyof T
      return function (handler: any) {
        // Add the provided handler to our handlers collection
        handlers[propertyName] = [...(handlers[propertyName] ?? []), handler]
        // Return an unsubscribe delegate
        return () => {
          handlers[propertyName] = handlers[propertyName]
            ?.filter(h => h !== handler)
        }
      }
    },
    set(target, property, value) {
      if (typeof property !== 'string') 
        return Reflect.set(target, property, value)

      const prev = Reflect.get(target, property)
      const setResult = Reflect.set(target, property, value)
      if (value !== prev && handlers[property as keyof T] && setResult) {
        // Use setTimeout to make change notification asynchronous
        setTimeout(() => {
          handlers[property as keyof T]
            ?.forEach(handler => handler(prev, value))
        }, 0)
      }
      return setResult
    },
  }) as unknown as ObjectWithOnChange<T>
Enhanced onChange Implementation

So we can use object proxies to react to changes to existing properties, and to implement properties and methods that don't exist. What else can we do?


Request Factories

Let's imagine we're building a non trivial client application that needs to talk to a back end REST API. An overly simplified version of our code might look something like this:

type User = {
  id: number
  username: string
}

const baseUrl = '//localhost:5000'
const api = {
  async getUsers() {
    const res = await fetch(`${baseUrl}/users`, {
      method: 'get',
      headers: {
        Authorization: `Bearer ${sessionStorage.getItem('jwt')}`,
      },
    })
    return (await res.json()) as User[]
  },
  async getUser(id: number) {
    const res = await fetch(`${baseUrl}/users/${id}`, {
      method: 'get',
      headers: {
        Authorization: `Bearer ${sessionStorage.getItem('jwt')}`,
      },
    })
    return (await res.json()) as User
  },
}
Simple api wrapper

This is a step above using fetch inline in our components, but it's still way too much manual plumbing and way too much repeated code. If we wanted to add some cross cutting concerns like logging, or change how we provide our auth headers it would mean making the change in a tonne of places. Let's see if we can clean this up a bit.

type User = {
  id: number
  username: string
}

const baseUrl = '//localhost:5000'

type RequestData = { method: string; url: string; body?: unknown }

async function makeRequest(requestData: RequestData) {
  try {
    const res = await fetch(`${baseUrl}${requestData.url}`, {
      method: requestData.method,
      headers: {
        Authorization: `Bearer ${sessionStorage.getItem('jwt')}`,
      },
      body: requestData.body !== undefined 
        ? JSON.stringify(requestData.body) 
        : undefined,
    })
    return await res.json()
  } catch (e) {
    console.error(e)
    throw e
  }
}

const api = {
  async getUsers() {
    return (await makeRequest({
      method: 'get',
      url: '/users',
    })) as User[]
  },
  async getUser(id: number) {
    return (await makeRequest({
      method: 'get',
      url: `/users/${id}`,
    })) as User
  },
}
Refactored api wrapper

That's better, now we have just one location where we actually issue requests. If we want to handle non 2xx status codes for example, we only have to do it in one place. Each api operation only differs by how it constructs the requestData object, and the expected type of the response data. If we made request data generic with a type parameter to represent the response type (ie. RequestData<TResponse>) then we can separate the creation of the requestData from the issuing of the request.

type User = {
  id: number
  username: string
}

const baseUrl = '//localhost:5000'

type RequestData<TResponse> = { method: string; url: string; body?: unknown }

async function makeRequest<TResponse>(requestData: RequestData<TResponse>) {
  try {
    const res = await fetch(`${baseUrl}${requestData.url}`, {
      method: requestData.method,
      headers: {
        Authorization: `Bearer ${sessionStorage.getItem('jwt')}`,
      },
      body: requestData.body !== undefined 
        ? JSON.stringify(requestData.body) 
        : undefined,
    })
    return await res.json() as TResponse
  } catch (e) {
    console.error(e)
    throw e
  }
}

const requestFactory = {
  getUsers(): RequestData<User[]> {
    return {
      method: 'get',
      url: '/users',
    }
  },
  getUser(id: number): RequestData<User> {
    return {
      method: 'get',
      url: `/users/${id}`,
    }
  },
}

const api = {
  async getUsers() {
    return makeRequest(requestFactory.getUsers())
  },
  async getUser(id: number) {
    return makeRequest(requestFactory.getUser(id))
  },
}
Refactor to request factory

At this point it looks like we've created more code and more indirection than the simple solution we had above, but bear with me. That api object is pure boilerplate which we could easily replace with an object proxy implementation. Let's define an interface that, for a given requestFactory, describes the resulting api.

type RequestFactory = { [key: string]: () => RequestData<any> }

type ApiFor<TFactory extends RequestFactory> = {
  [key in keyof TFactory]: TFactory[key] extends (
    ...args: infer TArgs
  ) => RequestData<infer TResponse>
    ? (...args: TArgs) => Promise<TResponse>
    : never
}
ApiFor<TFactory> type

Next we'll implement the proxy which fulfills that interface.

function createApiFor<TFactory extends RequestFactory>(factory: TFactory) {
  return new Proxy(factory, {
    get(target: any, property) {
      return (...args: any[]) => {
        const requestData = target[property](...args)
        return makeRequest(requestData)
      }
    },
  }) as unknown as ApiFor<TFactory>
}
API Proxy generation

Lastly our api code can be simplified to.

const api = createApiFor(requestFactory)

const users = await api.getUsers()
const user = await api.getUser(1)

Now if we want to add a new method to our api, all we need to do is describe what the request looks like in the requestFactory.

const requestFactory = {
  getUsers(): RequestData<User[]> {
    return {
      method: 'get',
      url: '/users',
    }
  },
  getUser(id: number): RequestData<User> {
    return {
      method: 'get',
      url: `/users/${id}`,
    }
  },
  saveUser(id: number, userData: Omit<User, 'id'>): RequestData<User> {
    return {
      method: 'put',
      url: `/users/${id}`,
      body: userData,
    }
  }
}

And we can use it immediately on the api object

const updatedUser = await api.saveUser(1, { username: 'bob' })

Things get even more streamlined if you use a code generation tool to automatically generate your requestFactory type based on your api code, or an OpenAPI Specification. If you'd like to explore this option yourself I have a project called openapi-tsrf (Type Script Request Factory) which allows you to do just that. It's still in its infancy, and only supports version 3 of the specification - and a subset at that - but as it is open source so you're free to fork the project to tweak it to your needs. Contributions are also welcome.

If you're server is written in c# you could also explore TeeSquare as a reflection based type generator. The documentation is light on for this project, but there are loads of unit tests describing the different supported scenarios and extension points.


Well these are just a few of the uses I've found for object proxies. I hope you've enjoyed them. Happy coding!