Skip to content

Commit d500c56

Browse files
committed
📃 docs(README): Add example to build a logging system with effects
1 parent a44cc7f commit d500c56

File tree

3 files changed

+622
-0
lines changed

3 files changed

+622
-0
lines changed

README.md

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1511,6 +1511,246 @@ While the pipeline syntax shown above is more compact, it may not be as readable
15111511

15121512
Both generator syntax and pipeline syntax are fully supported in tinyeffect — choose whichever approach makes your code most readable and maintainable for you and your team. The best choice often depends on the specific task and your team’s preferences.
15131513

1514+
### Example: Build a configurable logging system with effects
1515+
1516+
Let’s walk through a practical example of using algebraic effects to build a flexible logging system similar to what you might use in a real application. We aim to achieve the following goals:
1517+
1518+
- Support multiple logging levels (debug, info, warn, error).
1519+
- Allow setting minimum logging levels for different parts of the application.
1520+
- Enable logging to different outputs (console, file, etc.).
1521+
- Provide a way to customize the logging format.
1522+
1523+
In the following example, we’ll achieve this by defining a set of effects for logging, creating default effect handlers that log to the console, and defining several helper functions to manage logging levels and outputs. All of this is implemented in ~60 lines of code.
1524+
1525+
**Step 1: Define a dependency for logger**
1526+
1527+
We’ll start by defining a dependency for the logger, which will be used to redirect log messages to different outputs.
1528+
1529+
```typescript
1530+
export interface Logger {
1531+
debug: (...args: unknown[]) => void | Promise<void>;
1532+
info: (...args: unknown[]) => void | Promise<void>;
1533+
warn: (...args: unknown[]) => void | Promise<void>;
1534+
error: (...args: unknown[]) => void | Promise<void>;
1535+
}
1536+
1537+
// Define a dependency for injecting a logger
1538+
export type LoggerDependency = Default<Effect.Dependency<"logger", Logger>>;
1539+
// Create dependency with console as default implementation
1540+
export const askLogger = dependency<Logger>()("logger", () => console);
1541+
```
1542+
1543+
Note that we allow loggers to be asynchronous, so you can implement a logger that writes to a file or sends logs to a remote server. This is one advantage of algebraic effects: you don’t need to distinguish between synchronous and asynchronous effects, as they are all treated uniformly.
1544+
1545+
**Step 2: Create effects for each log level**
1546+
1547+
Next, we’ll define effects for each log level. These effects will be used to log messages at different levels.
1548+
1549+
```typescript
1550+
export type Logging =
1551+
| Default<Effect<"logging.debug", unknown[], void>, never, LoggerDependency>
1552+
| Default<Effect<"logging.info", unknown[], void>, never, LoggerDependency>
1553+
| Default<Effect<"logging.warn", unknown[], void>, never, LoggerDependency>
1554+
| Default<Effect<"logging.error", unknown[], void>, never, LoggerDependency>;
1555+
1556+
export const logLevels = ["debug", "info", "warn", "error"] as const;
1557+
export type LogLevel = (typeof logLevels)[number];
1558+
1559+
// A helper function to create a logging effect for each level
1560+
const logEffect = (level: LogLevel): EffectFactory<Logging> =>
1561+
effect(`logging.${level}`, {
1562+
*defaultHandler({ resume }, ...args) {
1563+
const logger = yield* askLogger();
1564+
const result = logger[level](...args);
1565+
// Handle async loggers
1566+
if (result instanceof Promise) result.then(resume);
1567+
else resume();
1568+
},
1569+
});
1570+
1571+
// Create effect functions for each log level
1572+
export const logDebug = logEffect("debug");
1573+
export const logInfo = logEffect("info");
1574+
export const logWarn = logEffect("warn");
1575+
export const logError = logEffect("error");
1576+
```
1577+
1578+
We define a default effect handler that relies on the `Logger` dependency for each log level. This allows us to control which log levels are enabled at runtime.
1579+
1580+
**Step 3: Define handlers for common logging features**
1581+
1582+
We’ll define several helper functions to manage logging levels and outputs. The `defineHandlerFor` helper will be used to create these helper functions.
1583+
1584+
The first helper is `withPrefix(prefixFactory: (level) => string)`, which adds a prefix to each log message based on the log level. This is useful for distinguishing between different log levels in the output.
1585+
1586+
```typescript
1587+
export function withPrefix(prefixFactory: (level: LogLevel) => string) {
1588+
return defineHandlerFor<Logging>().with((self) =>
1589+
self.handle(
1590+
(name): name is Logging["name"] => typeof name === "string" && name.startsWith("logging."),
1591+
function* ({ effect, resume }): Generator<Logging, void> {
1592+
const prefix = prefixFactory(effect.name.slice("logging.".length) as LogLevel);
1593+
// Insert prefix at the beginning of the payloads
1594+
effect.payloads.splice(0, 0, prefix);
1595+
yield effect; // Re-yield the effect with the modified payloads
1596+
resume();
1597+
},
1598+
),
1599+
);
1600+
}
1601+
```
1602+
1603+
The next is `withMinimumLogLevel(level)`, which filters out log messages below a specified minimum level. This allows you to control the verbosity of the logs based on the current logging level.
1604+
1605+
```typescript
1606+
export function withMinimumLogLevel(level: LogLevel | "none") {
1607+
return defineHandlerFor<Logging>().with((self) => {
1608+
const disabledLevels = new Set(
1609+
level === "none" ? logLevels : logLevels.slice(0, logLevels.indexOf(level)),
1610+
);
1611+
return self.handle(
1612+
(name): name is Logging["name"] =>
1613+
typeof name === "string" &&
1614+
name.startsWith("logging.") &&
1615+
disabledLevels.has(name.slice("logging.".length) as LogLevel),
1616+
function* ({ effect, resume }): Generator<Logging, void> {
1617+
// Change default handler of disabled log levels to resume immediately
1618+
effect.defaultHandler = ({ resume }) => resume();
1619+
yield effect; // Re-yield the effect with the modified default handler
1620+
resume();
1621+
},
1622+
);
1623+
});
1624+
}
1625+
```
1626+
1627+
**Step 4: Use the logging system!**
1628+
1629+
Done! We’ve already created a fully functional logging system. But now you might wonder how to use it in practice. Let’s start by creating a simple program that uses the logging system:
1630+
1631+
```typescript
1632+
const program = effected(function* () {
1633+
yield* logDebug("Debug message");
1634+
yield* logInfo("Info message");
1635+
yield* logWarn("Warning!");
1636+
yield* logError("Error occurred!");
1637+
});
1638+
1639+
await program.runAsync();
1640+
```
1641+
1642+
We do not explicitly handle any logging effects, so the default handler will be used, which logs to the console. The output will look like this:
1643+
1644+
```text
1645+
Debug message
1646+
Info message
1647+
Warning!
1648+
Error occurred!
1649+
```
1650+
1651+
By default, all log levels are enabled, so all messages are printed. Now, let’s set the minimum log level to `warn` to disable debug and info messages:
1652+
1653+
```typescript
1654+
const program = effected(function* () {
1655+
// ...
1656+
}).pipe(withMinimumLogLevel("warn"));
1657+
```
1658+
1659+
The output will now only show the warning and error messages:
1660+
1661+
```text
1662+
Warning!
1663+
Error occurred!
1664+
```
1665+
1666+
For now, it’s not easy to distinguish between different log levels. To add a prefix to each log message, we can use the `withPrefix` helper function:
1667+
1668+
```typescript
1669+
function logPrefix(level: LogLevel) {
1670+
const date = new Date();
1671+
const yyyy = date.getFullYear();
1672+
const MM = String(date.getMonth() + 1).padStart(2, "0");
1673+
const dd = String(date.getDate()).padStart(2, "0");
1674+
const HH = String(date.getHours()).padStart(2, "0");
1675+
const mm = String(date.getMinutes()).padStart(2, "0");
1676+
const ss = String(date.getSeconds()).padStart(2, "0");
1677+
const ms = String(date.getMilliseconds()).padEnd(3, "0");
1678+
const datePart = `[${yyyy}-${MM}-${dd} ${HH}:${mm}:${ss}.${ms}]`;
1679+
const levelPart = `[${level}]`;
1680+
return `${datePart} ${levelPart}`;
1681+
}
1682+
1683+
const program = effected(function* () {
1684+
// ...
1685+
}).pipe(withMinimumLogLevel("warn"), withPrefix(logPrefix));
1686+
```
1687+
1688+
Now, the output will look like this:
1689+
1690+
```text
1691+
[2025-03-29 21:49:03.633] [warn] Warning!
1692+
[2025-03-29 21:49:03.634] [error] Error occurred!
1693+
```
1694+
1695+
What if we want to log to a file instead of the console? We can create a custom logger that writes to a file and provide it as the `Logger` dependency. Here’s an example of how to do this:
1696+
1697+
```typescript
1698+
import fs from "node:fs/promises";
1699+
import { show } from "showify";
1700+
1701+
function fileLogger(path: string) {
1702+
return new Proxy({} as Logger, {
1703+
get() {
1704+
return async (...args: unknown[]) => {
1705+
const message = args
1706+
.map((arg) => (typeof arg === "string" ? arg : show(arg, { indent: 2 })))
1707+
.join(" ");
1708+
await fs.appendFile(path, message + "\n");
1709+
};
1710+
},
1711+
});
1712+
}
1713+
1714+
const program = effected(function* () {
1715+
// ...
1716+
})
1717+
.pipe(withMinimumLogLevel("warn"), withPrefix(logPrefix))
1718+
.provide("logger", fileLogger("log.txt"));
1719+
```
1720+
1721+
In this example, we use [showify](https://github.com/Snowflyt/showify) to format the log messages into human-readable strings. The `fileLogger` function accepts a file path and returns a logger object that writes log messages to that file. We use `node:fs/promises` to handle file operations asynchronously.
1722+
1723+
Now, instead of logging to the console, all log messages will be written to the `log.txt` file. You can customize the logger to write to different outputs, such as a database or a remote server.
1724+
1725+
You can also create a helper function to combine multiple handlers together:
1726+
1727+
```typescript
1728+
function dualLogger(logger1: Logger, logger2: Logger) {
1729+
return new Proxy({} as Logger, {
1730+
get(_, prop, receiver) {
1731+
return (...args: unknown[]) => {
1732+
const result1 = Reflect.get(logger1, prop, receiver)(...args);
1733+
const result2 = Reflect.get(logger2, prop, receiver)(...args);
1734+
if (result1 instanceof Promise && result2 instanceof Promise)
1735+
return Promise.all([result1, result2]);
1736+
else if (result1 instanceof Promise) return result1;
1737+
else if (result2 instanceof Promise) return result2;
1738+
};
1739+
},
1740+
});
1741+
}
1742+
1743+
const program = effected(function* () {
1744+
// ...
1745+
})
1746+
.pipe(withMinimumLogLevel("warn"), withPrefix(logPrefix))
1747+
.provide("logger", dualLogger(console, fileLogger("log.txt")));
1748+
```
1749+
1750+
Now, all log messages will be logged to both the console and the `log.txt` file.
1751+
1752+
To extend the logging system, you can create additional effects for other log levels, such as `trace`, `fatal`, or a `log` effect that defaults to `info` but can be configured to use any log level. In real applications, you can also redirect log messages to a worker thread and then handle them with a message queue, allowing you to log messages without blocking the main thread.
1753+
15141754
## FAQ
15151755

15161756
### What’s the relationship between tinyeffect and Effect?
@@ -1520,3 +1760,7 @@ It is a coincidence that the name “tinyeffect” is similar to [Effect](https:
15201760
However, it is not surprising that the two libraries share some similarities, as they both aim to provide a way to handle side effects in a type-safe manner. The `Effect` type in Effect and the `Effected` type in tinyeffect are both monads that abstract effectful computations, and they share similarities in their API design, e.g., `Effect.map()` vs. `Effected.prototype.map()`, `Effect.flatMap()` vs. `Effected.prototype.flatMap()`, `Effect.andThen()` vs. `Effected.prototype.andThen()`, etc.
15211761

15221762
While sharing some similarities, tinyeffect and Effect are fundamentally different in their design and implementation. tinyeffect is designed to be a lightweight library that focuses on providing a simple and intuitive API for handling side effects, while Effect is designed to be a full-fledged library that provides a comprehensive set of features for building effectful applications. Also, both libraries provide concurrency primitives, but Effect uses a fiber-based concurrency model, whereas tinyeffect uses a simple iterator-based model.
1763+
1764+
```
1765+
1766+
```

0 commit comments

Comments
 (0)