简体   繁体   中英

Typescript Interface combined with keys of map on object property

I'm attempting to create a means by to create handlers which all share the same basic definition but then have additional properties which are specific to each use.

I have a router, a context and route handler functions which accept the context as a parameter. I would like to be able to attach additional properties to the context depending on the route definition.

If a route definition is /hello/:user , there's a parameter user and when called as HTTP GET /hello/ben that'd be the value of said parameter.

As such we have context.params = {user: 'ben'} in this particular instance.

I also register lookup functions for these params.

Ie context.bindings['user] = (param) => User.find(param)

When i register the routes, I want the handlers containing the context and any additional keys which were resolved by lookup functions. For example

// In this example, each GET route has a :parameter and a binding for it so their
// handler should receive the result of the binding function as a key on context
// Both receive params because that's just part of context always.

router.binding('user', (user) => User.find(user));
router.binding('photo', (photo) => Photo.find(photo));
router.get("/bar/:photo", ({ params, photo }: HttpContextContract) => {}) // 1
router.get("/bar/:user", ({ params, user }: HttpContext) => {}) // 2

// in (1), the interface HttpContextContract doesn't know about photo so it moans
// in (2), the class HttpContext just lets any property go and there's no helpful typing/intellisense etc

I don't want to have to define a new interface each time for each route HttpContextContract & { user: User } for example. I believe I should be able to use something along the lines of type Handlers = Record<keyof typeof handlers, {}> but I can't seem to get it to work.

I've put together a basic example of the pieces described above and put it into a Typescript playground so hopefully it's easier to see roughly what i'm trying to achieve

type Binding = (param: string) => any;
type RouteHandler = (ctx: HttpContextContract) => any;

interface RouteBindings {
  [key: string]: Binding;
}

interface RouteHandlers {
  [key: string]: RouteHandler;
}

interface HttpContextContract {
  params: any;
}

interface HttpContext {
  [key: string]: any; // this allows me to attach the new keys to the instance
}

class HttpContext implements HttpContextContract {
  public params: any = {};
}

class Router {
  public bindings: RouteBindings = {};
  public handlers: RouteHandlers = {};

  public binding(key: string, binding: Binding) {
    this.bindings[key] = binding;
  }

  public get(path: string, handler: any) {
    this.handlers[path] = handler;
  }

  public find(path: string): RouteHandler {
    return this.handlers[path];
  }
}

class Server {
  constructor(protected router: Router) {}

  getParams(path: string) {
    const matches = path.match(/:([^/]+)/gi) || [];
    return matches.map(s => s.substring(1));
  }

  handle(path: string) {
    const ctx = new HttpContext(); // as HttpContext & { 'foo2': 'bar '}

    this.getParams(path).forEach((param: string) => {
      const binding = this.router.bindings[param];
      if (binding) {
        // Object.defineProperty(ctx, param, {
        //   get: binding
        // });
        ctx[param] = binding(param);
      }
    });

    const handler = this.router.find(path);
    return handler(ctx);
  }
}

const router = new Router();
const server = new Server(router);

class Photo {
  constructor(public name: string) {}
}

router.binding("user", () => "BOUND USER STRING");
router.binding("photo", () => new Photo("test"));

// This has no idea about the user property, even though it's there and readable
router.get("/foo/:user", ({ user }: HttpContextContract) => {
  return `"${user}" <- from bindings`;
});

// This now lets me use photo, but doesn't tell me user doesn't exist there
router.get("/bar/:photo", ({ photo, user }: HttpContext) => {
  return `"${JSON.stringify(photo, null, 2)} ${user}" <- from bindings`;
});

const out1 = server.handle("/foo/:user");
const out2 = server.handle("/bar/:photo");

console.log(out1);
console.log(out2);

// type ExtendedProperties<T> = { [P in keyof T]: T[P] };
// & ExtendedProperties<Record<keyof typeof this.router, {}>>;
// type BB = Router['handlers']
// type Handlers = Record<keyof typeof handlers, {}>

I think your current API is a little bit too dynamic for TypeScript to handle. But here's something that might help.

class Router<T extends Record<string, () => any> = {}> {
  bindings: T;

  constructor(handlers: T) {
    this.bindings = handlers;
  }

  bind<Key extends string, Handler>(key: Key, handler: Handler) {
    return new Router<{ [key in Key]: Handler} & T>({ 
      ...this.bindings, 
      ...({ [key]: handler } as { [key in Key]: Handler }) 
    });
  }
}
function createRouter() {
  return new Router({});
}

const router = createRouter()
  .bind('hello', () => "world")
  .bind('foo', () => Math.random());
const handler = router.bindings.hello; // const helloHandler: () => "world"
const fooHandler = router.bindings.foo; // const fooHandler: () => number

As long as you use string literals in the call to .bind() , you'll keep the types of the callbacks.

Edit: Here's a tweaked version that you can hopefully build what you need from.

// builds typed objects one property at a time
// ex. const obj = new Builder({}).add("hello", "world").build();
class Builder<T> {
  readonly obj: T;
  constructor(value: T) {
    this.obj = value;
  }
  add<K extends string, V>(key: K, value: V) {
    let next = {...this.obj, ...({[key]: value} as { [key in K]: V })};
    return new Builder(next);
  }
  build(): T { // finish building the object
    return this.obj;
  }
}

class Route<T> {
  bindings: T;
  path: string;

  constructor(path: string, bindings: (builder: Builder<{}>) => Builder<T>) {
    this.path = path;
    this.bindings = bindings(new Builder({})).build();
  }
}

let route = new Route("/api", (bindings) => bindings
  .add("hello", "world")
  .add("person", () => Math.random())
);

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM