remix-run/remix · error · TypeError

Expected a controller with an object `actions` property

Error message

Expected a controller with an object `actions` property

What it means

Thrown by mapRoutes in @remix-run/fetch-router when router.map() receives a routes object but the corresponding handler is not a controller, i.e. not an object with an `actions` property. When mapping multiple routes at once, fetch-router expects a controller whose actions object keys match the routes keys; single-route targets accept plain handlers.

Source

Thrown at packages/fetch-router/src/lib/router.ts:409

  }

  function addRoute(
    method: RequestMethod | 'ANY',
    route: RouteTarget,
    action: unknown,
    state: BuilderState,
  ): void {
    registerRoute(method, route, normalizeAction(action), state)
  }

  function mapRoutes(target: MapTarget, handler: unknown, state: BuilderState): void {
    if (isRouteTarget(target)) {
      mapSingleRoute(target, handler, state)
      return
    }

    if (!isController(handler)) {
      throw new TypeError('Expected a controller with an object `actions` property')
    }

    mapController(target, handler, state)
  }

  function mapSingleRoute(target: RouteTarget, handler: unknown, state: BuilderState): void {
    registerRoute(getMappedRouteMethod(target), target, normalizeAction(handler), state)
  }

  function mapController(
    routes: RouteMap,
    controller: {
      middleware?: readonly AnyMiddleware[] | undefined
      actions: Record<string, unknown>
    },
    state: BuilderState,
  ): void {
    let controllerMiddleware = normalizeMiddleware(controller.middleware)

View on GitHub (pinned to 9696913134)

Solutions

  1. If mapping one route, pass a single Route target (e.g. route(...)) so mapSingleRoute runs
  2. If mapping multiple routes, wrap handlers in { actions: { key: handler } }
  3. Check the `actions` property spelling and that it is a plain object
  4. Verify controller shape matches the version of fetch-router you depend on

Example fix

// before
router.map({ '/a': route(get, '/a'), '/b': route(post, '/b') }, handleAll)

// after
router.map(
  { '/a': route(get, '/a'), '/b': route(post, '/b') },
  { actions: { '/a': handleA, '/b': handleB } },
)
Defensive patterns

Strategy: type-guard

Validate before calling

let isControllerLike = (v: unknown) =>
  typeof v === 'object' && v !== null && typeof (v as any).actions === 'object'

Type guard

function isController(value: unknown): value is { actions: Record<string, unknown>; middleware?: unknown } {
  return typeof value === 'object' && value !== null && typeof (value as { actions?: unknown }).actions === 'object'
}

Try / catch

catch (error) { if (error instanceof TypeError && /Expected a controller/.test(error.message)) throw new Error(`Bad router.map() call: ${error.message}`) throw error }

Prevention

When it happens

Trigger: router.map({ '/a': ..., '/b': ... }, plainFunction) or router.map(routesObject, { notActions: {...} }). Any multi-key routes target paired with a non-controller handler.

Common situations: Passing a single handler function for a routes map, typoing `actions` (e.g. `action` or `handlers`), or passing a controller instance from a different library/version whose shape changed.

Understand the failure class

Background: Invalid argument type errors: "must be of type string", "expected X, got Y", and ERR_INVALID_ARG_TYPE explained — this error's family across 15 libraries.

Related errors


AI-assisted analysis of remix-run/remix@9696913134 (2026-08-27). Data as JSON: /api/errors/f91bf1d354f66232. Report an issue: GitHub.