diff --git a/src/index.ts b/src/index.ts index 3e2092c5..e2db634a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5261,6 +5261,101 @@ export default class Elysia< return this } + /** + * Named macro with explicit dependencies (function syntax) + * + * Use this overload when you need a function-syntax named macro that + * accesses resolve values from previous macros. The `dependencies` array + * explicitly declares which macros this macro depends on, enabling proper + * TypeScript inference for the resolve context. + * + * @example + * ```typescript + * .macro("auth", { resolve: () => ({ user: "bob" }) }) + * .macro("permission", ["auth"], (perm: string) => ({ + * auth: true, + * resolve: ({ user }) => { + * // `user` is properly inferred from auth's resolve + * return { hasPermission: checkPermission(user, perm) } + * } + * })) + * ``` + * + * @see https://github.com/elysiajs/elysia/issues/1574 + */ + macro< + const Name extends string, + const Dependencies extends ReadonlyArray< + keyof Metadata['macroFn'] & string + >, + const DependencyMacros extends Pick< + Metadata['macroFn'], + Dependencies[number] + >, + const MacroContext extends MacroToContext< + Metadata['macroFn'], + // Create a selector object with all dependencies set to true + { [K in Dependencies[number]]: true }, + Definitions['typebox'] + >, + const Schema extends MergeSchema< + Metadata['schema'], + MergeSchema< + Volatile['schema'], + MergeSchema + > & + Metadata['standaloneSchema'] & + Ephemeral['standaloneSchema'] & + Volatile['standaloneSchema'] + >, + const Params, + const Property extends ( + param: Params + ) => MacroProperty< + Metadata['macro'] & + InputSchema & { + [name in Name]?: boolean + } & { [K in Dependencies[number]]?: boolean }, + Schema, + Singleton & { + derive: Partial + resolve: Partial & + // @ts-ignore + MacroContext['resolve'] + }, + Definitions['error'] + > + >( + name: Name, + dependencies: Dependencies, + macro: Property + ): Elysia< + BasePath, + Singleton, + Definitions, + { + schema: Metadata['schema'] + standaloneSchema: Metadata['standaloneSchema'] + macro: Metadata['macro'] & { + [name in Name]?: Params + } + macroFn: Metadata['macroFn'] & { + [name in Name]: Property + } + parser: Metadata['parser'] + response: Metadata['response'] + }, + Routes, + Ephemeral, + Volatile + > + + /** + * Named macro (object or simple function syntax) + * + * For macros that don't need to access previous macro resolve values, + * or for object-syntax named macros. + */ macro< const Name extends string, const Input extends Metadata['macro'] & @@ -5415,7 +5510,23 @@ export default class Elysia< Volatile > - macro(macroOrName: string | Macro, macro?: Macro) { + macro( + macroOrName: string | Macro, + dependenciesOrMacro?: ReadonlyArray | Macro, + macroDep?: Macro + ) { + // Handle new overload: macro(name, dependencies, macro) + if ( + typeof macroOrName === 'string' && + Array.isArray(dependenciesOrMacro) + ) { + if (!macroDep) throw new Error('Macro function is required') + this.extender.macro[macroOrName] = macroDep + return this as any + } + + const macro = dependenciesOrMacro as Macro | undefined + if (typeof macroOrName === 'string' && !macro) throw new Error('Macro function is required') diff --git a/test/macro/macro.test.ts b/test/macro/macro.test.ts index 2dbd7d9d..423a9d38 100644 --- a/test/macro/macro.test.ts +++ b/test/macro/macro.test.ts @@ -1438,4 +1438,117 @@ describe('Macro', () => { expect(invalid3.status).toBe(422) }) + + it('infer previous macro resolve in function syntax with explicit dependencies (issue #1574)', async () => { + // This test verifies the fix for issue #1574 + // https://github.com/elysiajs/elysia/issues/1574 + // + // The new overload macro(name, dependencies, fn) allows function-syntax + // named macros to properly infer resolve types from previous macros + // by explicitly declaring dependencies. + + const app = new Elysia() + .macro('auth', { + resolve: () => ({ user: 'bob' as const }) + }) + // Using the new explicit dependencies syntax + .macro('permission', ['auth'], (permission: string) => ({ + auth: true, + resolve: ({ user }) => { + // `user` should be properly inferred as 'bob' + // This would fail TypeScript compilation if inference doesn't work + const typedUser: 'bob' = user + return { hasPermission: user === 'bob' && permission === 'admin' } + } + })) + .get('/check', ({ hasPermission }) => ({ hasPermission }), { + permission: 'admin' + }) + + const response = await app.handle(req('/check')) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ hasPermission: true }) + }) + + it('handle multiple dependencies in function syntax macro', async () => { + const app = new Elysia() + .macro('auth', { + resolve: () => ({ user: { id: 1, name: 'Alice' } }) + }) + .macro('tenant', { + resolve: () => ({ tenantId: 'tenant-123' }) + }) + // Depending on both auth and tenant + .macro('permissions', ['auth', 'tenant'], (role: string) => ({ + auth: true, + tenant: true, + resolve: ({ user, tenantId }) => ({ + canAccess: user.id === 1 && tenantId === 'tenant-123' && role === 'admin' + }) + })) + .get('/access', ({ canAccess }) => ({ canAccess }), { + permissions: 'admin' + }) + + const response = await app.handle(req('/access')) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ canAccess: true }) + }) + + it('chain function syntax macros with dependencies', async () => { + const app = new Elysia() + .macro('a', { + resolve: () => ({ valueA: 'A' as const }) + }) + .macro('b', ['a'], (_: boolean) => ({ + a: true, + resolve: ({ valueA }) => ({ + valueB: `${valueA}-B` as const + }) + })) + .macro('c', ['a', 'b'], (_: boolean) => ({ + a: true, + b: true, + resolve: ({ valueA, valueB }) => ({ + valueC: `${valueA}-${valueB}-C` as const + }) + })) + // Use the macro at route level - the handler receives the resolved values + // Note: Type inference for route handler when using transitive macro dependencies + // is complex; the runtime behavior is correct even if types need explicit annotation + .get('/chain', (ctx) => ({ + valueA: (ctx as any).valueA, + valueB: (ctx as any).valueB, + valueC: (ctx as any).valueC + }), { + c: true + }) + + const response = await app.handle(req('/chain')) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + valueA: 'A', + valueB: 'A-B', + valueC: 'A-A-B-C' + }) + }) + + it('function syntax macro without dependencies still works', async () => { + // Verify that simple function macros without resolve dependencies + // continue to work with the existing syntax (no dependencies array) + const app = new Elysia() + .macro('logger', (prefix: string) => ({ + beforeHandle: () => { + // Just a side effect, no resolve needed + }, + resolve: () => ({ logPrefix: prefix }) + })) + .get('/', ({ logPrefix }) => ({ prefix: logPrefix }), { + logger: 'TEST' + }) + + const response = await app.handle(req('/')) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ prefix: 'TEST' }) + }) })