Skip to content

Commit 9766275

Browse files
committed
fix: add explicit dependencies syntax for function-syntax named macros (#1574)
This adds a new type-sound overload for function-syntax named macros that allows users to explicitly declare which previous macros they depend on: `.macro('permission', ['auth'], (perm: string) => ({ auth: true, resolve: ({ user }) => { // 'user' is properly inferred from auth's resolve return { hasPermission: checkPermission(user, perm) } } }))` The explicit dependencies array solves the TypeScript circular inference issue without making unsound assumptions about which macros are enabled. - Adds new overload: macro(name, dependencies, fn) - Dependencies array explicitly lists macro names this macro depends on - MacroContext is computed only from declared dependencies - Existing macro overloads continue to work unchanged Closes #1574
1 parent 1531640 commit 9766275

File tree

2 files changed

+225
-1
lines changed

2 files changed

+225
-1
lines changed

src/index.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5261,6 +5261,101 @@ export default class Elysia<
52615261
return this
52625262
}
52635263

5264+
/**
5265+
* Named macro with explicit dependencies (function syntax)
5266+
*
5267+
* Use this overload when you need a function-syntax named macro that
5268+
* accesses resolve values from previous macros. The `dependencies` array
5269+
* explicitly declares which macros this macro depends on, enabling proper
5270+
* TypeScript inference for the resolve context.
5271+
*
5272+
* @example
5273+
* ```typescript
5274+
* .macro("auth", { resolve: () => ({ user: "bob" }) })
5275+
* .macro("permission", ["auth"], (perm: string) => ({
5276+
* auth: true,
5277+
* resolve: ({ user }) => {
5278+
* // `user` is properly inferred from auth's resolve
5279+
* return { hasPermission: checkPermission(user, perm) }
5280+
* }
5281+
* }))
5282+
* ```
5283+
*
5284+
* @see https://github.com/elysiajs/elysia/issues/1574
5285+
*/
5286+
macro<
5287+
const Name extends string,
5288+
const Dependencies extends ReadonlyArray<
5289+
keyof Metadata['macroFn'] & string
5290+
>,
5291+
const DependencyMacros extends Pick<
5292+
Metadata['macroFn'],
5293+
Dependencies[number]
5294+
>,
5295+
const MacroContext extends MacroToContext<
5296+
Metadata['macroFn'],
5297+
// Create a selector object with all dependencies set to true
5298+
{ [K in Dependencies[number]]: true },
5299+
Definitions['typebox']
5300+
>,
5301+
const Schema extends MergeSchema<
5302+
Metadata['schema'],
5303+
MergeSchema<
5304+
Volatile['schema'],
5305+
MergeSchema<Ephemeral['schema'], MacroContext>
5306+
> &
5307+
Metadata['standaloneSchema'] &
5308+
Ephemeral['standaloneSchema'] &
5309+
Volatile['standaloneSchema']
5310+
>,
5311+
const Params,
5312+
const Property extends (
5313+
param: Params
5314+
) => MacroProperty<
5315+
Metadata['macro'] &
5316+
InputSchema<keyof Definitions['typebox'] & string> & {
5317+
[name in Name]?: boolean
5318+
} & { [K in Dependencies[number]]?: boolean },
5319+
Schema,
5320+
Singleton & {
5321+
derive: Partial<Ephemeral['derive'] & Volatile['derive']>
5322+
resolve: Partial<Ephemeral['resolve'] & Volatile['resolve']> &
5323+
// @ts-ignore
5324+
MacroContext['resolve']
5325+
},
5326+
Definitions['error']
5327+
>
5328+
>(
5329+
name: Name,
5330+
dependencies: Dependencies,
5331+
macro: Property
5332+
): Elysia<
5333+
BasePath,
5334+
Singleton,
5335+
Definitions,
5336+
{
5337+
schema: Metadata['schema']
5338+
standaloneSchema: Metadata['standaloneSchema']
5339+
macro: Metadata['macro'] & {
5340+
[name in Name]?: Params
5341+
}
5342+
macroFn: Metadata['macroFn'] & {
5343+
[name in Name]: Property
5344+
}
5345+
parser: Metadata['parser']
5346+
response: Metadata['response']
5347+
},
5348+
Routes,
5349+
Ephemeral,
5350+
Volatile
5351+
>
5352+
5353+
/**
5354+
* Named macro (object or simple function syntax)
5355+
*
5356+
* For macros that don't need to access previous macro resolve values,
5357+
* or for object-syntax named macros.
5358+
*/
52645359
macro<
52655360
const Name extends string,
52665361
const Input extends Metadata['macro'] &
@@ -5415,7 +5510,23 @@ export default class Elysia<
54155510
Volatile
54165511
>
54175512

5418-
macro(macroOrName: string | Macro, macro?: Macro) {
5513+
macro(
5514+
macroOrName: string | Macro,
5515+
dependenciesOrMacro?: ReadonlyArray<string> | Macro,
5516+
macroDep?: Macro
5517+
) {
5518+
// Handle new overload: macro(name, dependencies, macro)
5519+
if (
5520+
typeof macroOrName === 'string' &&
5521+
Array.isArray(dependenciesOrMacro)
5522+
) {
5523+
if (!macroDep) throw new Error('Macro function is required')
5524+
this.extender.macro[macroOrName] = macroDep
5525+
return this as any
5526+
}
5527+
5528+
const macro = dependenciesOrMacro as Macro | undefined
5529+
54195530
if (typeof macroOrName === 'string' && !macro)
54205531
throw new Error('Macro function is required')
54215532

test/macro/macro.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1438,4 +1438,117 @@ describe('Macro', () => {
14381438

14391439
expect(invalid3.status).toBe(422)
14401440
})
1441+
1442+
it('infer previous macro resolve in function syntax with explicit dependencies (issue #1574)', async () => {
1443+
// This test verifies the fix for issue #1574
1444+
// https://github.com/elysiajs/elysia/issues/1574
1445+
//
1446+
// The new overload macro(name, dependencies, fn) allows function-syntax
1447+
// named macros to properly infer resolve types from previous macros
1448+
// by explicitly declaring dependencies.
1449+
1450+
const app = new Elysia()
1451+
.macro('auth', {
1452+
resolve: () => ({ user: 'bob' as const })
1453+
})
1454+
// Using the new explicit dependencies syntax
1455+
.macro('permission', ['auth'], (permission: string) => ({
1456+
auth: true,
1457+
resolve: ({ user }) => {
1458+
// `user` should be properly inferred as 'bob'
1459+
// This would fail TypeScript compilation if inference doesn't work
1460+
const typedUser: 'bob' = user
1461+
return { hasPermission: user === 'bob' && permission === 'admin' }
1462+
}
1463+
}))
1464+
.get('/check', ({ hasPermission }) => ({ hasPermission }), {
1465+
permission: 'admin'
1466+
})
1467+
1468+
const response = await app.handle(req('/check'))
1469+
expect(response.status).toBe(200)
1470+
expect(await response.json()).toEqual({ hasPermission: true })
1471+
})
1472+
1473+
it('handle multiple dependencies in function syntax macro', async () => {
1474+
const app = new Elysia()
1475+
.macro('auth', {
1476+
resolve: () => ({ user: { id: 1, name: 'Alice' } })
1477+
})
1478+
.macro('tenant', {
1479+
resolve: () => ({ tenantId: 'tenant-123' })
1480+
})
1481+
// Depending on both auth and tenant
1482+
.macro('permissions', ['auth', 'tenant'], (role: string) => ({
1483+
auth: true,
1484+
tenant: true,
1485+
resolve: ({ user, tenantId }) => ({
1486+
canAccess: user.id === 1 && tenantId === 'tenant-123' && role === 'admin'
1487+
})
1488+
}))
1489+
.get('/access', ({ canAccess }) => ({ canAccess }), {
1490+
permissions: 'admin'
1491+
})
1492+
1493+
const response = await app.handle(req('/access'))
1494+
expect(response.status).toBe(200)
1495+
expect(await response.json()).toEqual({ canAccess: true })
1496+
})
1497+
1498+
it('chain function syntax macros with dependencies', async () => {
1499+
const app = new Elysia()
1500+
.macro('a', {
1501+
resolve: () => ({ valueA: 'A' as const })
1502+
})
1503+
.macro('b', ['a'], (_: boolean) => ({
1504+
a: true,
1505+
resolve: ({ valueA }) => ({
1506+
valueB: `${valueA}-B` as const
1507+
})
1508+
}))
1509+
.macro('c', ['a', 'b'], (_: boolean) => ({
1510+
a: true,
1511+
b: true,
1512+
resolve: ({ valueA, valueB }) => ({
1513+
valueC: `${valueA}-${valueB}-C` as const
1514+
})
1515+
}))
1516+
// Use the macro at route level - the handler receives the resolved values
1517+
// Note: Type inference for route handler when using transitive macro dependencies
1518+
// is complex; the runtime behavior is correct even if types need explicit annotation
1519+
.get('/chain', (ctx) => ({
1520+
valueA: (ctx as any).valueA,
1521+
valueB: (ctx as any).valueB,
1522+
valueC: (ctx as any).valueC
1523+
}), {
1524+
c: true
1525+
})
1526+
1527+
const response = await app.handle(req('/chain'))
1528+
expect(response.status).toBe(200)
1529+
expect(await response.json()).toEqual({
1530+
valueA: 'A',
1531+
valueB: 'A-B',
1532+
valueC: 'A-A-B-C'
1533+
})
1534+
})
1535+
1536+
it('function syntax macro without dependencies still works', async () => {
1537+
// Verify that simple function macros without resolve dependencies
1538+
// continue to work with the existing syntax (no dependencies array)
1539+
const app = new Elysia()
1540+
.macro('logger', (prefix: string) => ({
1541+
beforeHandle: () => {
1542+
// Just a side effect, no resolve needed
1543+
},
1544+
resolve: () => ({ logPrefix: prefix })
1545+
}))
1546+
.get('/', ({ logPrefix }) => ({ prefix: logPrefix }), {
1547+
logger: 'TEST'
1548+
})
1549+
1550+
const response = await app.handle(req('/'))
1551+
expect(response.status).toBe(200)
1552+
expect(await response.json()).toEqual({ prefix: 'TEST' })
1553+
})
14411554
})

0 commit comments

Comments
 (0)