Will King
Remix

Using symbols to create type safe Remix handles without duck typing

A not often used but very powerful feature in Remix is the handle export. The handle export gives you the power to add metadata to each route. This metadata is then made available on the route data accessible in useMatches.

The Remix docs covers a great breadcrumb use case, and at CrunchyData we have used it to build a command palette system with route aware command prioritization and filtering.

A small hiccup

When we were implementing the command palette system we were using handles to store and array of commands for a route.

export let handle = { 
  commands: [thisCommand, thatCommand]
}

However, when we went to roll all of our commands up into a single list we had to deal with a type issue.

Understandably, when you are working through your handle data from useMatches your data is not typed. Why? Because any route can export whatever metadata they want.

So, what did that mean for our implementation to get the types we wanted……you guessed it, duck types.

The first implementation looked like this:

function useCommandPalette() { 
  const routesData = useMatches()
  
  return routesData.reduce(route => { 
    if (route.handle && route.handle?.commands) { 
      route.handle.commands.reduce((acc, command) => { 
        if (command && isObject(command) && command.kind === "command") { 
          // do stuff
        }
      })
    }
  })
}

Yay conditional soup! This is obviously not great.

No more ducks

With arrays and objects we can use symbols to remove all of the conditional soup.

….oh, I should probably show you how that works.

export function symbolizeArrayFactory<T>(name: string) {
	const symbolKey = Symbol(name)
	const symbol = { [symbolKey]: true } as const
	type Symbolized = T[] & typeof symbol

	function make(list: T[]): Symbolized {
		return Object.assign(list, symbol)
	}

	function is(maybeList: unknown): maybeList is Symbolized {
		return (
			Array.isArray(maybeList) &&
			Boolean((maybeList as Partial<typeof symbol>)?.[symbolKey])
		)
	}

	return {
		make,
		is,
	}
}

This helper function now allows us to symbolize our data guaranteeing that if we use the make helper that is returned that the data inside is the type we are expecting it to be.

So now, the soup from above is getting a new recipe. Let’s take a look at it all together:

const { make: makeCommandList, is: isCommandList } =
	symbolizeArrayFactory<Command>('isCommand')

export let handle = { 
  commands: makeCommandList([thisCommand, thatCommand])
}

function useCommandPalette() { 
  const routesData = useMatches()
  
  return routesData.reduce(route => { 
    if (route.handle && isCommandList(route.handle?.commands)) {
			// We are now in a place where that entire list is type-safe
      route.handle.commands.reduce((acc, command) => { 
          // do stuff
      })
    }
  })
}

We no longer need to validate individual items inside of the commands key in the handle and we have this great helper that will allow us to tag arrays…and objects (see below).

function isObject<T>(val: T) {
	return typeof val === 'object' && val !== null && !Array.isArray(val)
}

export function symbolizeObjectFactory<T>(name: string) {
	const symbolKey = Symbol(name)
	const symbol = { [symbolKey]: true } as const
	type Symbolized = T & typeof symbol

	function make(obj: T): Symbolized {
		return Object.assign(obj, symbol)
	}

	function is(maybeObj: unknown): maybeObj is Symbolized {
		return (
			isObject(maybeObj) &&
			Boolean((maybeObj as Partial<typeof symbol>)?.[symbolKey])
		)
	}

	return {
		make,
		is,
	}
}

Oooooor, get this. Even a function.

export function symbolizeFunctionFactory<T>(name: string) {
	const symbolKey = Symbol(name)
	const symbol = { [symbolKey]: true } as const
	type Symbolized = T & typeof symbol

	function make(func: T): Symbolized {
		return Object.assign(func, symbol)
	}

	function is(maybeFunc: unknown): maybeFunc is Symbolized {
		return (
			typeof maybeFunc === 'function' &&
			Boolean((maybeFunc as Partial<typeof symbol>)?.[symbolKey])
		)
	}

	return {
		make,
		is,
	}
}

This makes typing your handle metadata easy and all without needing to change the shape of the data that you are putting in there to make duck typing possible.

Updates and More

Get updated when new articles, products, or components are released. Also, whatever else I feel like would be fun to send out.