iTranslated by AI
Aggregating Side Effects in Frontend main() as a Composite Function
This article covers an idea that hasn't been fully implemented yet (the lint rule mentioned later is unimplemented).
In short, I tried to build an Effect System. https://www.eff-lang.org/
Giving Meaning to void
Let's think about this kind of frontend code.
function mount(): void {
const div = document.createElement('div');
div.textContent = "hello";
document.body.append(div);
}
function print(): void {
console.log("hello");
}
function maybeError(): void {
// Function that throws an exception with a low probability
if (Math.random() > 0.999) {
throw new Error("error");
}
}
async function doSend(): Promise<void> {
try {
const res = await fetch("/post", {});
const _data = await res.json();
console.log(_data);
} catch (err) {
console.error(err);
}
}
function sub() {
maybeError();
maybeError();
}
async function main() {
mount();
print();
await doSend();
sub();
}
// run
main()
Most applications, while differing in complexity, are generally composed of a combination of these sets of processes.
The problem with these functions—or rather, the issue I perceive—is that it's difficult to imagine what they do just by looking at their types. For example, you can't tell whether the entire app is touching fetch or not. If you've never felt this way, just assume that some people do.
Capturing Events Occurring Inside
main(): void has various internal side effects. For example, it might rewrite the DOM, perform an external fetch, or output to the Console.
In JavaScript, what constitutes a side effect is ambiguous (for example, is the Console a side effect? I consider it one because it leaks memory for humans). In languages with monads like Haskell, a series of procedures that handle side effects is represented as an IO monad.
IO Monads and Side Effects - Haskell-jp
Thinking of main() in terms of an IO monad, instead of just viewing the return value as void, we could view it as a composite function that combines various side effects.
I've been building a minifier and had a desire to somehow analyze the total sum of side effects occurring within an app. The idea was: could the return value of main, for example, be something other than void—an expression that includes these side effects?
So, I decided to define an opaque Eff type that doesn't affect the runtime and represent it in the type as mount(): void & Eff<Operation.DOM>.
// Type Utility
declare const __EFFECT_TYPE__: unique symbol;
const enum Operation {
DOM,
}
type Eff<E extends Operation> = {
readonly [__EFFECT_TYPE__]?: E;
};
function mount(): void & Eff<Operation.DOM> {
const div = document.createElement('div');
div.textContent = "hello";
document.body.append(div);
}
With this, I was at least able to declare the intent that it's touching the DOM. It's just an intent, though.
If I were to push this further, I could write a Lint Rule that only allows DOM operations when Eff<Operation.DOM> is included in the function's return value.
I'll work on that later (really?), but for now, it seems like a declaration that isn't too intrusive to add casually. As for whether it has meaning—well, since TypeScript itself is essentially just a tool for declaring intent that doesn't affect the runtime, I figured I could argue that it does have meaning.
Aggregating Side Effects: It Was All Generators
Initially, I considered abstracting it with functions like handle() and perform(), which are common in Effect Systems, but it was impossible without support from the language compiler.
By the way, JavaScript has an often-forgotten feature: Generator and AsyncGenerator functions.
function * g() {
yield 1;
yield 2;
yield 3;
}
for (const i of g()) {
console.log(i); // 1, 2, 3
}
This feature is used for internal iterator implementations and is represented by the Generator<T> type. To be precise, there's a ReturnType and other details, but it can be expressed this way within the scope of using it as an iterator.
If we were to type the previous g(), it would look like this:
function * g(): Generator<number> {
yield 1;
yield 2;
yield 3;
}
In other words, I wondered if combining it with the Eff<T> from earlier would allow the following code to be naturally inferred.
async function * main() {
yield mount();
}
// run: In the runtime, all _eff are void
for await (const _eff of main()) {}
Now, the type of the main function is expressed as main(): AsyncGenerator<void & Eff<Operation.DOM>>.
So, I thought that by adding Eff<T> to all types that seem to have side effects and finally connecting them with Generator functions, all side effects should be aggregated into the type of main(). Let's try applying that to the initial code.
The main() Function that Automatically Infers Side Effects
// Type Utility
declare const __EFFECT_TYPE__: unique symbol;
const enum Operation {
DOM,
Console,
Fetch,
PostMessage,
Throwable
}
type Eff<E extends Operation> = {
readonly [__EFFECT_TYPE__]?: E;
};
type AnyGenerator<T> = AsyncGenerator<T> | Generator<T>;
// Extract the list of Operations from the main function
type GetEffect<F extends AnyGenerator<any>> =
F extends AnyGenerator<infer T> ? T extends Awaited<Eff<infer U>> ? Awaited<U> : never : never;
function mount(): void & Eff<Operation.DOM> {
const div = document.createElement('div');
div.textContent = "hello";
document.body.append(div);
}
function print(): void & Eff<Operation.Console> {
console.log("hello");
}
function maybeError(): void & Eff<Operation.Throwable> {
if (Math.random() > 0.999) {
throw new Error("error");
}
}
async function doSend(): Promise<void & Eff<Operation.Fetch | Operation.Console>> {
try {
const res = await fetch("/post", {});
const _data = await res.json();
console.log(_data);
} catch (err) {
console.error(err);
}
}
function * sub() {
yield maybeError();
yield maybeError();
}
async function * main() {
yield mount();
yield print();
yield await doSend();
yield * sub();
}
// run
for await (const _eff of main()) {}
// This type eventually represents the total sum of side effects that occurred
export type MainOps = GetEffect<ReturnType<typeof main>>;
In this way, the final MainOps type is inferred as type MainOps = Operation.DOM | Operation.Console | Operation.Fetch | Operation.Throwable. In other words, you can see that the application abstracted by this main function interacts with the DOM, outputs to the Console, performs a Fetch, and throws exceptions.
By connecting all these "intent-based" functions as Generators up to the main function, all side effects should be aggregated.
I used to think of Generators purely as a tool for creating iterators, but gaining this perspective was a real eye-opener for me.
Now, all that's left is to work hard on writing the Linter. Let's do our best.
Discussion