honojs / hono

Web framework built on Web Standards
https://hono.dev
MIT License
16.93k stars 470 forks source link

`ContextVariableMap` Counterpart for env `type Bindings` for Global Context Types #2313

Open Luzefiru opened 4 months ago

Luzefiru commented 4 months ago

What is the feature you are proposing?

Suggestion

There is no simple way to type environment bindings (like for Cloudflare Worker secrets) and you'll need to pass a constructor like this const app = new Hono<{ Bindings: Bindings; Variables: Variables }>() to your Hono apps.

The problem occurs when you want to use app.route and create multiple Hono apps with the same variables or bindings.

If only there was a ContextBindingMap, similar to ContextVariableMap, to declare a module interface to write any global bindings there, then you can simply type your context like (c: Context) in a route to be able to get types in your c.env or env(c) Bindings.

A simple example to use this potential feature is like so:

import { Context } from 'hono';

declare module 'hono' {
  // can be used with (c: Context)
  interface ContextBindingMap {
    SECRET: string;
  }
}

const app = new Hono();

app.get('/path', (c: Context) => c.text(c.env.SECRET)); // typed as string, not any

By extension of this feature being implemented, the documentation website should also be updated to reflect the new API.

Status Quo

Currently, in the Hono Documentation Website, there is a section on adding global types to your context's c.var interface via ContextVariableMap.

Once you make a route handler with the ('/path', (c: Context) => ...) arguments, you can access c.var variables without having to make an Env generic for the Context like Context<Env>.

// global.d.ts
declare module 'hono' {
  // can be used with (c: Context)
  interface ContextVariableMap {
    foo: string;
  }

  // can be used with (c: Context<Env>)
  interface Env {
    // c.var
    Variables: {
      bar: number;
    };

    // c.env
    Bindings: {
      MY_SECRET: string;
    };
  }
}
// index.ts
import { Env, Context } from 'hono';

function handleRouteWithAny(c) {
 c.get("foo");  // any
 c.var.foo;     // any

 c.get("bar");  // any
 c.var.bar;     // any

 c.env.MY_SECRET; // any
}

function handleRouteWithContext(c: Context) {
 c.get("foo");  // this works, Get <"client">(key: "client") => number
 c.var.foo;     // this works, (property) foo: string

 c.get("bar");  // cannot infer type, Get <"bar">(key: "bar") => any (+1 overload)
 c.var.bar;     // no type at all, any

 c.env.MY_SECRET; // any
}

function handleRouteWithEnvGeneric(c: Context<Env>) {
 c.get("foo");  // this works, Get <"client">(key: "client") => number
 c.var.foo;     // this works, (property) foo: string

 c.get("bar");  // works now, Get <"bar">(key: "bar") => number (+1 overload)
 c.var.bar;     // works now, (property) bar: number

 c.env.MY_SECRET; // (property) MY_SECRET: string
}

Here are the sections that talk about it typing the context c.var or c.env properties in your new Hono() instance using type Bindings and type Variables:

Exhibit A: /helpers/factory

There is documentation on how to use theEnv generic using the createFactory helper.

You can pass your Env types as Generics:

type Env = {
  Variables: {
    foo: string
  }
}

const factory = createFactory<Env>()

[!Caution] There is no official documentation on the Env generic.

Exhibit B: /api/context

The Generics documentation shows that we can pass our Variables and Bindings to our new Hono() constructor. This documentation is consistent with Exhibit A, showcasing the fact that a factory is just saving the config of the Hono instance so that we can pass type Variables to the constructors to type our c.var properties.

type Bindings = {
  TOKEN: string
}

type Variables = {
  user: User
}

const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()

app.use('/auth/*', async (c, next) => {
  const token = c.env.TOKEN // token is `string`
  // ...
  c.set('user', user) // user should be `User`
  await next()
})

[!Tip] This is useful for defining local variables or bindings for a single new Hono() app.

There's also similar usage in the set() / get() docs.

yusukebe commented 3 months ago

Hi @Luzefiru

Thanks for the issue, and sorry for the delayed response!

Now, we have to consider matters such as ContextVariableMap or something related to it.

Your suggestion is good. But there is another option: https://github.com/honojs/hono/issues/2249, although ContextBindingMap is different from ContextVariableMap. This issue is said to discourage using ContextVariableMap. I also think it's not good that it pollutes global types.

Anyway, I'll think about this issue deeply.

Luzefiru commented 3 months ago

This issue is said to discourage using ContextVariableMap. I also think it's not good that it pollutes global types.

I agree on this part, I guess making another globally scoped type will create more issues and shouldn't be done. For future reference, should we simply just type the context argument as Context<Env> for controller functions that do not have access to the Hono app? Like so:

function handleRouteWithEnvGeneric(c: Context<Env>) {
 c.get("foo");  // this works, Get <"client">(key: "client") => number
 c.var.foo;     // this works, (property) foo: string

 c.get("bar");  // works now, Get <"bar">(key: "bar") => number (+1 overload)
 c.var.bar;     // works now, (property) bar: number

 c.env.MY_SECRET; // (property) MY_SECRET: string
}

I understand that it's something that is advised against in the Best Practices documentation, but my route files are getting too big. I was hoping to implement the pattern without having to always type the c: Context<Env> argument.

Is there a better way to do this? Thank you very much for your response.

SupertigerDev commented 2 months ago
declare module 'hono' {
    interface ContextVariableMap {

    }
    interface Env {
        Bindings: {
            ip: SocketAddress
        }
    }
}

This seems to be working