-
Notifications
You must be signed in to change notification settings - Fork 400
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feature: Type safe custom context properties in TypeScript #1157
Conversation
@szydlovski - 🎉 Very excited to see this contribution! Let's give a few days to let folks take a look and add their comments and feedback. |
Codecov Report
@@ Coverage Diff @@
## main #1157 +/- ##
=======================================
Coverage 71.71% 71.71%
=======================================
Files 15 15
Lines 1354 1354
Branches 402 402
=======================================
Hits 971 971
Misses 312 312
Partials 71 71
Continue to review full report at Codecov.
|
I've been doing some background processing on this and I think I've found a more robust solution to this problem. Extending interface EmptyMiddlewareArgs {}
export default class App<AppMiddlewareArgs extends EmptyMiddlewareArgs = EmptyMiddlewareArgs> {
// ...
public command(
commandName: string | RegExp,
...listeners: Middleware<AppMiddlewareArgs & SlackCommandMiddlewareArgs>[]
): void {
// ...
}
// ...
} This way middlewares are not limited to only extending This could be improved even futher by recording the middlewares attached to an app: export default class App<AppMiddlewareArgs extends EmptyMiddlewareArgs = EmptyMiddlewareArgs> {
// ...
private middleware: Middleware<AnyMiddlewareArgs>[];
// ...
public use<T extends EmptyMiddlewareArgs>(middleware: Middleware<AppMiddlewareArgs & T>): App<AppMiddlewareArgs & T> {
this.middleware.push(middleware as Middleware<AnyMiddlewareArgs>);
return this as unknown as App<AppMiddlewareArgs & T>;
}
// ...
} Then we can define some typed middleware: interface Example1MiddlewareArgs { customArg: string }
const example1Middleware = (value: string): Middleware<Example1MiddlewareArgs> =>
async (middleware) => void (middleware.customArg = value)
interface Example2MiddlewareArgs { anotherCustomArg: number }
const example2Middleware = (value: number): Middleware<Example2MiddlewareArgs> =>
async (middleware) => void (middleware.anotherCustomArg = value) And attach them to the app in chained calls: const app = new App()
.use(example1Middleware('foo'))
.use(example2Middleware(1))
app.command('/test', async ({ ack, respond, customArg, anotherCustomArg}) => {
await ack();
await respond(`${customArg}, ${anotherCustomArg}`)
});
Finally, this approach could enable another somewhat common pattern - a single middleware attached to a specific listener. For example, I've been using a command args parser middleware (to parse // this is obviously a simplified version :)
const parseCommandArgs = (): Middleware<SlackCommandMiddlewareArgs & {
parsedCommandArgs: string[];
}> => async (middleware) => {
middleware.parsedCommandArgs = middleware.command.text.split(' ');
}
app.command('/test', parseCommandArgs(), async ({ack, respond, parsedCommandArgs }) => {
const firstArg = parseCommandArgs[0];
await ack();
await respond(`First argument: ${firstArg}`);
}) Obviously right now this cannot be typed correctly, but it could - with this overload on the export default class App<AppMiddlewareArgs extends EmptyMiddlewareArgs = EmptyMiddlewareArgs> {
// ...
public command<T>(commandName: string | RegExp, middleware: Middleware<SlackCommandMiddlewareArgs & AppMiddlewareArgs & T>, listener: Middleware<SlackCommandMiddlewareArgs & AppMiddlewareArgs & T>): void
public command(commandName: string | RegExp, ...listeners: Middleware<AppMiddlewareArgs & SlackCommandMiddlewareArgs>[]): void
public command(commandName: string | RegExp, ...listeners: Middleware<AppMiddlewareArgs & SlackCommandMiddlewareArgs>[]): void {
// ...
}
// ...
} |
@szydlovski Thanks for your great suggestion here! I like your approach to give more types in Although your pull request does not need to resolve this issue, one thing I wanted to mention for others here is that, even with the changes here, still the
In the first place, the bolt-js project does not encourage developers to add additional properties to middleware arguments (although it works fine in many cases). The documents do not have a warning about it (because this is a general issue in JS) but the biggest issue is the risk of naming conflicts with the built-in properties in the future. In the case where you unexpectedly modify built-in properties, troubleshooting of the issue can be hard. Instead, we recommend putting any additional properties into Overall, if we go with the changes only for @szydlovski If you have more time, adding some unit tests covering the use cases would be appreciated. |
As for the middleware args and their namespacing issues, have you considered defining the built in properties as non-configurable getters? Obviously that could still be overwritten by a developer with enough dedication, but it would make accidentally breaking things singificantly less likely (if custom middleware args become a supported feature at some point). |
Thanks for your reply.
If we completely remove
Although the framework code can be a bit more complex, this may be a good improvement for safety. |
Hey, thanks for the PR / ideas @szydlovski! My humble opinion: given what @seratch mentioned about how Slack wants to keep the middleware arguments "out of bounds" for developers so that we can change its structure down the road, perhaps better to keep the suggested change to your original approach of specifying the context's type rather than extending the middleware args? As an app author, I also don't particularly mind the approach of casting the context to some interface or type I can define on my own (as you described in your original post) - though fair warning, I don't consider myself a typescript power user so perhaps that is due to my own inexperience / ignorance! |
Wonderful feature! 💯 |
I played around with this a bit and wanted to share another use case that the current implement of this really great feature doesn't cover. A bit of context – in all of my apps, before a payload is passed into the final listener, it is passed into another middleware where I programmatically parse:
I do this to keep from having to access those deeply nested values in the payload for every single value. And if there is a clear contract between the action IDs/block IDs/callback IDs/private metadata and that middleware – it works really well and saves a lot of time and code. And I pass those parsed values to the listener as a part of The proposed implementation works great when the shape of A very simplified example, in which there are no globally applicable differences in the // handle-foo.ts
interface Foo {
foo: string;
}
export const handleFoo: Middleware<SlackActionMiddlewareArgs<BlockAction>, Foo> = async ({ context }) => {
console.log(context.foo);
}; // handle-bar.ts
interface Bar {
bar: string;
}
export const handleBar: Middleware<SlackActionMiddlewareArgs<BlockAction>, Bar> = async ({ context }) => {
console.log(context.bar);
}; // app.ts
import { App } from '@slack/bolt';
import { parseValues } from './parse-values';
import { handleFoo } from './handle-foo';
import { handleBar } from './handle-bar';
const app = new App({ token: 'xoxb-xxxx' });
app.action({ type: 'block_actions', action_id: 'foo' }, parseValues, handleFoo); // TS error
app.action({ type: 'block_actions', action_id: 'bar' }, parseValues, handleBar); // TS error The same also goes for passing in a type to // handle-foo.ts
import type { GlobalCustomContext } from './types';
interface Foo extends GlobalCustomContext {
foo: string;
}
export const handleFoo: Middleware<SlackActionMiddlewareArgs<BlockAction>, Foo> = async ({ context }) => {
console.log(context.foo);
}; // handle-bar.ts
import type { GlobalCustomContext } from './types';
interface Bar extends GlobalCustomContext {
bar: string;
}
export const handleBar: Middleware<SlackActionMiddlewareArgs<BlockAction>, Bar> = async ({ context }) => {
console.log(context.bar);
}; // app.ts
import { App } from '@slack/bolt';
import { parseValues } from './parse-values';
import { handleFoo } from './handle-foo';
import { handleBar } from './handle-bar';
import type { GlobalCustomContext } from './types';
const app = new App<GlobalCustomContext>({ token: 'xoxb-xxxx' });
app.action({ type: 'block_actions', action_id: 'foo' }, parseValues, handleFoo); // TS error
app.action({ type: 'block_actions', action_id: 'bar' }, parseValues, handleBar); // TS error I'm not sure how often developers using Bolt are implementing similar patterns, where things are parsed in earlier middleware or In an ideal world, it would be great to:
Really love this feature, think it's a great improvement, but wanted to share my two cents |
@raycharius Thanks for your comment! I like your approach to have the context type in the middleware type. As you mentioned, it should be even more useful for many use cases. Would having the custom context type in both |
@seratch I definitely think it could be, looking at the changes in this PR. @szydlovski What are your thoughts? If you're currently at full capacity, I'm also happy to take a look at this. |
I'm going to check out @szydlovski branch and play around with this a bit this week |
Unfortunately I didn't have the time to work on this further, but I really like the suggestions posted here. I think this would be a great improvement to Bolt. |
@seratch I believe this can be closed now? |
Summary
This pull request enables developers to maintain type safety when adding custom properties to the
context
middleware arg.Description
This is partially related to #897. That issue was closed with a pull request which added built-in context fields to the type definitions, however it did not address the issue of using custom context properties.
Right now Bolt does not offer any way to add TypeScript typings to the context object. Consider this example:
This code would work, but there is no way to record type information about
context.foo
. The only workaround is something like this:This does work but is very clunky, verbose and overall causes the developer experience to suffer. Casting like this should be reserved for rare, special cases, not for something as basic as middleware.
Background
How do other frameworks handle this? Koa takes a type parameter
ContextT
which is then added to the build-in context type definition in middleware listeners:Solution
My implementation is similar to Koa and works like this:
This could be accomplished in a number of ways, I decided that the following is the cleanest option:
AllMiddlewareArgs
interface is the "root" of middleware args, so I added aCustomContext
type parameter to it, constrained and defaulting toStringIndexed
, and modified its type definition to define thecontext
property asContext & CustomContext
AllMiddlewareArgs
is theMiddleware
interface, so I added aCustomContext
type parameter to it as well, constrained and defaulting toStringIndexed
, and modified its type definition to pass that type argument toAllMiddlewareArgs
.CustomContext
type parameter to theApp
class, constrained and defaulting toStringIndexed
, and modified its methods' type definitions to pass that type parameter toMiddleware
Because all added type parameters default to
StringIndexed
andContext
already extendsStringIndexed
they can still be used in other parts of the codebase without a type parameter or any changed to the typings. Other parts of the codebase don't need information about custom context properties because those are by definition added by the end developer and hence not needed in internal framework logic.These changes do not modify any typings outside of this rather narrow scope, do not introduce any changes to the logic of the framework, and should work with any existing codebase.
Requirements (place an
x
in each[ ]
)