Type Composition
Type composition is one of Inglorious Web's most powerful features. It enables elegant solutions to cross-cutting concerns without wrapper hell or HOCs.
What is Type Composition?
Types can be defined as arrays of behaviors that wrap and extend each other:
javascript
const types = {
// Simple type
counter: {
increment(entity) {
entity.count++
},
render(entity, api) {
/* ... */
},
},
// Composed type (array of behaviors)
loggedCounter: [baseCounter, loggingBehavior, analyticsBehavior],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
Each behavior in the array can:
- Intercept events before they reach the next behavior
- Modify entity state or payload
- Redirect to different logic
- Pass through to the wrapped behavior
Basic Example: Logging
javascript
// Base type
const counter = {
increment(entity) {
entity.count++
},
render(entity, api) {
return html`<button>${entity.count}</button>`
},
}
// Logging behavior
const logging = (type) => ({
increment(entity, payload, api) {
console.log(`Incrementing counter ${entity.id}`)
// Call the wrapped type's handler
type.increment(entity, payload, api)
console.log(`New value: ${entity.count}`)
},
})
// Compose
const types = {
counter: [counter, logging],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
When you dispatch #counter:increment:
- Logging intercepts it, logs start
- Calls
counter.increment - Logs end
Advanced Example: Route Guard
javascript
// Base page type
const adminPage = {
navigate(entity, route) {
entity.currentRoute = route
},
render(entity, api) {
return html`<h1>Admin Page</h1>`
},
}
// Authentication guard
const requireAuth = (type) => ({
navigate(entity, route, api) {
const user = api.getEntity("user")
if (!user.isLoggedIn) {
// Prevent navigation, redirect instead
api.notify("navigate", "/login", {
redirectTo: route,
})
return // Don't call wrapped type
}
// User authenticated, allow navigation
type.navigate(entity, route, api)
},
})
// Authorization guard
const requireAdmin = (type) => ({
navigate(entity, route, api) {
const user = api.getEntity("user")
if (user.role !== "admin") {
api.notify("navigate", "/unauthorized")
return
}
type.navigate(entity, route, api)
},
})
// Compose guards
const types = {
// Public page, no guards
publicPage: page,
// Authenticated page
userProfile: [page, requireAuth],
// Admin-only page
adminPanel: [page, requireAuth, requireAdmin],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Guards execute in order:
adminPaneldispatch triggersrequireAuthfirstrequireAuthchecks login, passes torequireAdminrequireAdminchecks role, passes topagepageactually handles navigation
Pattern: Analytics
javascript
const analytics = (type) => ({
// Intercept all events
"*": function (entity, payload, api) {
// Track event
track({
entity: entity.type,
event: this.constructor.name, // Event name
timestamp: Date.now(),
})
// Call original handler
type[arguments.callee.name]?.(entity, payload, api)
},
})
const types = {
trackedCounter: [counter, analytics],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Pattern: Validation
javascript
const validated = (type) => ({
setEmail(entity, email, api) {
// Validate before calling handler
if (!email.includes("@")) {
api.notify("validation:error", { field: "email" })
return
}
type.setEmail(entity, email, api)
},
})
const types = {
form: [baseForm, validated],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Pattern: State Snapshots
javascript
const withSnapshots = (type) => ({
// Before any event, save state
snapshot(entity, payload, api) {
if (!entity._history) entity._history = []
entity._history.push(JSON.parse(JSON.stringify(entity)))
// Call wrapped handler
const handlerName = arguments.callee.name
type[handlerName]?.(entity, payload, api)
},
undo(entity) {
if (entity._history?.length > 0) {
const previous = entity._history.pop()
Object.assign(entity, previous)
}
},
})
const types = {
undoableCounter: [counter, withSnapshots],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Pattern: Debouncing
javascript
const debounced = (type) => {
const pending = {}
return {
saveData(entity, data, api) {
// Cancel previous save
if (pending[entity.id]) {
clearTimeout(pending[entity.id])
}
// Debounce: wait 500ms before actually saving
pending[entity.id] = setTimeout(() => {
type.saveData(entity, data, api)
delete pending[entity.id]
}, 500)
},
}
}
const types = {
autosaveForm: [form, debounced],
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Multiple Behaviors
Chain multiple behaviors:
javascript
const types = {
advancedForm: [form, logging, validation, analytics, debounced],
}
1
2
3
2
3
Execution order (for a fieldChange event):
logginglogs startvalidationvalidatesanalyticstracksdebounceddebouncesform(base type) executesdebouncedfinishes timeout setupanalyticscompletes trackingvalidationlogs resultlogginglogs end
Partial Behavior
A behavior doesn't need to intercept all events:
javascript
const logging = (type) => ({
// Only intercept increment
increment(entity, payload, api) {
console.log("Incrementing")
type.increment(entity, payload, api)
},
// All other events pass through unchanged
})
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
Conditional Interception
javascript
const requireMode = (type) => ({
editTitle(entity, newTitle, api) {
if (entity.mode !== "edit") {
console.warn("Not in edit mode")
return
}
type.editTitle(entity, newTitle, api)
},
})
1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
Modifying Payload
javascript
const sanitizer = (type) => ({
setTitle(entity, title, api) {
// Clean the payload before passing
const clean = title.trim().replace(/[<>]/g, "")
type.setTitle(entity, clean, api)
},
})
1
2
3
4
5
6
7
2
3
4
5
6
7
Benefits
✅ No Wrapper Hell — Array composition instead of nested HOCs
✅ Explicit Order — Clear which behavior runs first
✅ Easy to Test — Test each behavior independently
✅ Reusable — Share behaviors across types
✅ Composable — Mix and match as needed
When to Use
- Cross-cutting concerns — Logging, analytics, validation
- Route guards — Auth, permissions, redirects
- State patterns — Snapshots, undo/redo, debouncing
- Middleware — Transform data, check preconditions
When Not to Use
- Simple modifications — Just put in the base type
- Type-specific logic — Belongs in the type itself
- One-off concerns — Not worth abstracting
Testing Composed Types
Test behaviors independently:
javascript
import { trigger } from "@inglorious/web/test"
test("logging logs events", () => {
const logged = []
const logging = (type) => ({
increment(entity, payload, api) {
logged.push("increment")
type.increment(entity, payload, api)
},
})
const testType = [
{
increment(e) {
e.count++
},
},
logging,
]
const { entity } = trigger(
{ count: 0 },
testType.find((t) => t.increment).increment,
)
expect(logged).toContain("increment")
expect(entity.count).toBe(1)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Next Steps
- Testing — Comprehensive testing strategies
- Route Guards — Use type composition for advanced routing
- Type Composition in Store — Full documentation in Store docs
Happy composing! 🎯
Inglorious Web