hono icon indicating copy to clipboard operation
hono copied to clipboard

Downstream methods without `path` args break upstream method path typing

Open ambergristle opened this issue 1 month ago • 1 comments

What version of Hono are you using?

4.10.5

What runtime/platform is your app running on? (with version if possible)

Bun 1.2.13

What steps can reproduce the bug?

a typing edge case was reported on discord

TL;DR

  • when a handler or middleware is called with a path argument, the Hono instance this.#path is updated
    • if the next method in the chain is also called with the path argument, there's no issue
    • if the next method is called without the path argument, then:
      • the call defaults the path to the previously-set this.#path (expected)
      • the schema uses the previously-set path type (expected)
      • any methods called before the middleware are also typed using the not-yet-set path (unexpected)
  • i don't totally understand why this happens, but i think has to do with the type merging that ChangePathOfSchema does
    • in order to resolve downstream schemas, it flattens them to the Path key, but i think this also overwrites the correct (type) paths for upstream schemas
    • linking these PRs as they seem like relevant context:
      • https://github.com/honojs/hono/pull/3087
      • https://github.com/honojs/hono/pull/3128

Test Case

  • the expected endpoints are GET / and POST /:id
  • the actual endpoints are GET / and POST /:id
  • the endpoint types are GET /:id and POST /:id
type Env = {
  Variables: {
    foo: string
  }
}

const app = new Hono<Env>()
  .get((c) => c.text('before'))
  .use('/:id', async (_c, next) => {
    await next()
  })
  .post((c) => c.text('after'))

type Actual = ExtractSchema<typeof app>
/**
  {
    '/:id': {
      $get: {
        input: {};
        output: 'before';
        outputFormat: 'text';
        status: ContentfulStatusCode;
      }
    }
  } & {
    '/:id': {
      $post: {
        input: {
          param: {
            id: string;
          };
        };
        output: 'after';
        outputFormat: 'text';
        status: ContentfulStatusCode;
      }
    }
  }
*/
type Expected = {
  '/': {
    $get: {
      input: {};
      output: 'before';
      outputFormat: 'text';
      status: ContentfulStatusCode;
    }
  }
} & {
  '/:id': {
    $post: {
      input: {
        param: {
          id: string;
        };
      };
      output: 'after';
      outputFormat: 'text';
      status: ContentfulStatusCode;
    }
  }
}

// error
type verify = Expect<Equal<Expected, Actual>>

What is the expected behavior?

No response

What do you see instead?

No response

Additional information

ideally the types would be in sync with the endpoint behavior. that being said, the current solution covers a majority of cases, and i'm not sure there's a performant solution with better coverage. there are a few simple rules/workarounds

  • avoid calling methods/use without the path argument
  • front-load middleware at the top of the chain
  • break routes up RESTfully

Example Workaround

const idRoute = new Hono<Env>()
  .use('/:id', async (_c, next) => {
    await next()
  })
  .post('/:id', (c) => c.text('after'))

const app = new Hono<Env>()
  .get('/', (c) => c.text('before'))
  .route('/', idRoute)

Next Steps

  • i'll update the docs to describe the path defaulting behavior when methods are called without a path argument
    • i'll include a warning about this edge-case
  • i'll work on a solution when i have a bit more time, but i'm wondering whether it's worth trying to resolve at all right now

ambergristle avatar Nov 12 '25 19:11 ambergristle

@ambergristle

Thanks. This is a bug.

yusukebe avatar Nov 20 '25 11:11 yusukebe