diff --git a/API.md b/API.md
index fced5d58..d961cbd9 100644
--- a/API.md
+++ b/API.md
@@ -51,6 +51,7 @@ dash `--`.
| **Name** | **Description** |
| --- | --- |
| toString | Returns a string representation of this construct. |
+| with | Applies one or more mixins to this construct. |
---
@@ -62,6 +63,27 @@ public toString(): string
Returns a string representation of this construct.
+##### `with`
+
+```typescript
+public with(mixins: ...IMixin[]): IConstruct
+```
+
+Applies one or more mixins to this construct.
+
+Mixins are applied in order. The list of constructs is captured at the
+start of the call, so constructs added by a mixin will not be visited.
+Use multiple `with()` calls if subsequent mixins should apply to added
+constructs.
+
+###### `mixins`Required
+
+- *Type:* ...IMixin[]
+
+The mixins to apply.
+
+---
+
#### Static Functions
| **Name** | **Description** |
@@ -161,6 +183,7 @@ dash `--`.
| **Name** | **Description** |
| --- | --- |
| toString | Returns a string representation of this construct. |
+| with | Applies one or more mixins to this construct. |
---
@@ -172,6 +195,27 @@ public toString(): string
Returns a string representation of this construct.
+##### `with`
+
+```typescript
+public with(mixins: ...IMixin[]): IConstruct
+```
+
+Applies one or more mixins to this construct.
+
+Mixins are applied in order. The list of constructs is captured at the
+start of the call, so constructs added by a mixin will not be visited.
+Use multiple `with()` calls if subsequent mixins should apply to added
+constructs.
+
+###### `mixins`Required
+
+- *Type:* ...IMixin[]
+
+The mixins to apply.
+
+---
+
#### Static Functions
| **Name** | **Description** |
@@ -1066,6 +1110,32 @@ Separator used to delimit construct path components.
Represents a construct.
+#### Methods
+
+| **Name** | **Description** |
+| --- | --- |
+| with | Applies one or more mixins to this construct. |
+
+---
+
+##### `with`
+
+```typescript
+public with(mixins: ...IMixin[]): IConstruct
+```
+
+Applies one or more mixins to this construct.
+
+Mixins are applied in order. The list of constructs is captured at the
+start of the call, so constructs added by a mixin will not be visited.
+
+###### `mixins`Required
+
+- *Type:* ...IMixin[]
+
+The mixins to apply.
+
+---
#### Properties
@@ -1103,6 +1173,50 @@ deployed.
+### IMixin
+
+- *Implemented By:* IMixin
+
+A mixin is a reusable piece of functionality that can be applied to constructs to add behavior, properties, or modify existing functionality without inheritance.
+
+#### Methods
+
+| **Name** | **Description** |
+| --- | --- |
+| applyTo | Applies the mixin functionality to the target construct. |
+| supports | Determines whether this mixin can be applied to the given construct. |
+
+---
+
+##### `applyTo`
+
+```typescript
+public applyTo(construct: IConstruct): void
+```
+
+Applies the mixin functionality to the target construct.
+
+###### `construct`Required
+
+- *Type:* IConstruct
+
+---
+
+##### `supports`
+
+```typescript
+public supports(construct: IConstruct): boolean
+```
+
+Determines whether this mixin can be applied to the given construct.
+
+###### `construct`Required
+
+- *Type:* IConstruct
+
+---
+
+
### IValidation
- *Implemented By:* IValidation
diff --git a/src/construct.ts b/src/construct.ts
index d0d14e34..fb5da567 100644
--- a/src/construct.ts
+++ b/src/construct.ts
@@ -1,6 +1,7 @@
import type { IDependable } from './dependency';
import { Dependable } from './dependency';
import type { MetadataEntry } from './metadata';
+import type { IMixin } from './mixin';
import { captureStackTrace } from './private/stack-trace';
import { addressOf } from './private/uniqueid';
@@ -14,6 +15,17 @@ export interface IConstruct extends IDependable {
* The tree node.
*/
readonly node: Node;
+
+ /**
+ * Applies one or more mixins to this construct.
+ *
+ * Mixins are applied in order. The list of constructs is captured at the
+ * start of the call, so constructs added by a mixin will not be visited.
+ *
+ * @param mixins The mixins to apply
+ * @returns This construct for chaining
+ */
+ with(...mixins: IMixin[]): IConstruct;
}
/**
@@ -505,6 +517,29 @@ export class Construct implements IConstruct {
});
}
+ /**
+ * Applies one or more mixins to this construct.
+ *
+ * Mixins are applied in order. The list of constructs is captured at the
+ * start of the call, so constructs added by a mixin will not be visited.
+ * Use multiple `with()` calls if subsequent mixins should apply to added
+ * constructs.
+ *
+ * @param mixins The mixins to apply
+ * @returns This construct for chaining
+ */
+ public with(...mixins: IMixin[]): IConstruct {
+ const allConstructs = this.node.findAll();
+ for (const mixin of mixins) {
+ for (const construct of allConstructs) {
+ if (mixin.supports(construct)) {
+ mixin.applyTo(construct);
+ }
+ }
+ }
+ return this;
+ };
+
/**
* Returns a string representation of this construct.
*/
diff --git a/src/index.ts b/src/index.ts
index 06c09a8d..f4360fe8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,4 @@
export * from './construct';
export type * from './metadata';
-export * from './dependency';
\ No newline at end of file
+export type * from './mixin';
+export * from './dependency';
diff --git a/src/mixin.ts b/src/mixin.ts
new file mode 100644
index 00000000..0437cce8
--- /dev/null
+++ b/src/mixin.ts
@@ -0,0 +1,17 @@
+import type { IConstruct } from './construct';
+
+/**
+ * A mixin is a reusable piece of functionality that can be applied to constructs
+ * to add behavior, properties, or modify existing functionality without inheritance.
+ */
+export interface IMixin {
+ /**
+ * Determines whether this mixin can be applied to the given construct.
+ */
+ supports(construct: IConstruct): boolean;
+
+ /**
+ * Applies the mixin functionality to the target construct.
+ */
+ applyTo(construct: IConstruct): void;
+}
diff --git a/test/construct.test.ts b/test/construct.test.ts
index f0502cf4..a94692f7 100644
--- a/test/construct.test.ts
+++ b/test/construct.test.ts
@@ -688,3 +688,160 @@ interface ValidationError {
readonly source: IConstruct;
readonly message: string;
}
+
+describe('with() mixin support', () => {
+ test('returns the construct for chaining', () => {
+ const root = new Root();
+ const result = root.with();
+ expect(result).toBe(root);
+ });
+
+ test('applies a single mixin to the construct', () => {
+ const root = new Root();
+ const applied: IConstruct[] = [];
+ const mixin = {
+ supports: () => true,
+ applyTo: (c: IConstruct) => applied.push(c),
+ };
+
+ root.with(mixin);
+
+ expect(applied).toContain(root);
+ });
+
+ test('applies multiple mixins to the construct', () => {
+ const root = new Root();
+ const applied1: IConstruct[] = [];
+ const applied2: IConstruct[] = [];
+ const mixin1 = {
+ supports: () => true,
+ applyTo: (c: IConstruct) => applied1.push(c),
+ };
+ const mixin2 = {
+ supports: () => true,
+ applyTo: (c: IConstruct) => applied2.push(c),
+ };
+
+ root.with(mixin1, mixin2);
+
+ expect(applied1).toContain(root);
+ expect(applied2).toContain(root);
+ });
+
+ test('applies mixin to all children in the tree', () => {
+ const root = new Root();
+ const child1 = new Construct(root, 'child1');
+ new Construct(root, 'child2');
+ new Construct(child1, 'grandchild');
+
+ const applied: string[] = [];
+ const mixin = {
+ supports: () => true,
+ applyTo: (c: IConstruct) => applied.push(c.node.id || 'root'),
+ };
+
+ root.with(mixin);
+
+ expect(applied).toEqual(['root', 'child1', 'grandchild', 'child2']);
+ });
+
+ test('only applies mixin to constructs that pass supports() check', () => {
+ const root = new Root();
+ new Construct(root, 'child1');
+ new Construct(root, 'child2');
+
+ const applied: string[] = [];
+ const mixin = {
+ supports: (c: IConstruct) => c.node.id === 'child1',
+ applyTo: (c: IConstruct) => applied.push(c.node.id),
+ };
+
+ root.with(mixin);
+
+ expect(applied).toEqual(['child1']);
+ });
+
+ test('does not apply mixin when supports() returns false for all', () => {
+ const root = new Root();
+ new Construct(root, 'child1');
+
+ const applied: IConstruct[] = [];
+ const mixin = {
+ supports: () => false,
+ applyTo: (c: IConstruct) => applied.push(c),
+ };
+
+ root.with(mixin);
+
+ expect(applied).toHaveLength(0);
+ });
+
+ test('supports chaining multiple with() calls', () => {
+ const root = new Root();
+ const applied1: IConstruct[] = [];
+ const applied2: IConstruct[] = [];
+ const mixin1 = {
+ supports: () => true,
+ applyTo: (c: IConstruct) => applied1.push(c),
+ };
+ const mixin2 = {
+ supports: () => true,
+ applyTo: (c: IConstruct) => applied2.push(c),
+ };
+
+ root.with(mixin1).with(mixin2);
+
+ expect(applied1).toContain(root);
+ expect(applied2).toContain(root);
+ });
+
+ test('mixin can modify construct metadata', () => {
+ const root = new Root();
+ const mixin = {
+ supports: () => true,
+ applyTo: (c: IConstruct) => c.node.addMetadata('mixin-applied', true),
+ };
+
+ root.with(mixin);
+
+ expect(root.node.metadata).toEqual([{ type: 'mixin-applied', data: true, trace: undefined }]);
+ });
+
+ test('applies mixins in order, completing each mixin before the next', () => {
+ const root = new Root();
+ new Construct(root, 'child');
+
+ const order: string[] = [];
+ const mixin1 = {
+ supports: () => true,
+ applyTo: (c: IConstruct) => order.push(`m1:${c.node.id || 'root'}`),
+ };
+ const mixin2 = {
+ supports: () => true,
+ applyTo: (c: IConstruct) => order.push(`m2:${c.node.id || 'root'}`),
+ };
+
+ root.with(mixin1, mixin2);
+
+ expect(order).toEqual(['m1:root', 'm1:child', 'm2:root', 'm2:child']);
+ });
+
+ test('does not apply mixins to constructs added by other mixins', () => {
+ const root = new Root();
+
+ const applied: string[] = [];
+ const addingMixin = {
+ supports: (c: IConstruct) => c.node.id === '',
+ applyTo: (c: IConstruct) => new Construct(c, 'added-by-mixin'),
+ };
+ const trackingMixin = {
+ supports: () => true,
+ applyTo: (c: IConstruct) => applied.push(c.node.id || 'root'),
+ };
+
+ root.with(addingMixin, trackingMixin);
+
+ expect(applied).toEqual(['root']);
+ expect(root.node.findChild('added-by-mixin')).toBeDefined();
+ });
+});