-
The documentation at Effection Collections mentions that Stream corresponds to AsyncIterable, but in practice, const delay = (x: number) => new Promise(r => setTimeout(r, x));
(async () => {
const stream: AsyncIterable<number> = (async function* () {
await delay(100)
yield 1
await delay(100)
yield 2
await delay(100)
yield 3
})();
const subcription1 = stream[Symbol.asyncIterator]() // No `await` on the stream itself
// const subcription2 = ...
console.log(await subcription1.next())
// ....
})() The example in the Effection documentation is also confusing: let subscription1 = yield* channel;
let subscription2 = yield* channel; There are two different subscriptions created here, and it feels like merely using Nowadays, for the same functionality, I think the following expression in Effection is better: import { main, createChannel } from 'effection';
await main(function*() {
let channel = createChannel();
// the channel has no subscribers yet!
yield* channel.send('too early');
let subscription1 = channel.subscribe();
let subscription2 = channel.subscribe();
yield* channel.send('hello');
yield* channel.send('world');
console.log(yield* subscription1.next());
//=> { done: false, value: "hello" }
console.log(yield* subscription1.next());
//=> { done: false, value: "world" }
console.log(yield* subscription2.next());
//=> { done: false, value: "hello" }
console.log(yield* subscription2.next());
//=> { done: false, value: "world" }
}); Hmm, what about Stream? Maybe the signature of Stream should be |
Beta Was this translation helpful? Give feedback.
Replies: 2 comments 3 replies
-
@iplaylf2 I think I see where you're coming from. There definitely are some differences between the Async/Await primitives and their Effection analogues, and that they can seem confusing and/or unnecessary when you are new to Effection. However, it is precisely in these differences that you will find the key to the guarantees that Effection provides you that Async/Await does not. Specifically: It is impossible in Effection to create any effect whatsoever that is not bound to a scope. This may seem restrictive at first, but it is actually a superpower. Take for example the const delay = (x: number) => new Promise(r => setTimeout(r, x)); This eagerly creates the timeout effect every time it is called, and it can be called anywhere at any time: const delay = (x: number) => new Promise(r => setTimeout(r, x));
delay(500);
(() => { delay(1000}; delay(400); })();
for (let i = 0; i < 1000; i++) {
delay(i);
} This will create over 1000 calls to const delay = (x: number) => action(r => {
let timeoutId = setTimout(r,x};
return () => clearTimeout(timeoutId);
});
delay(500);
(() => { delay(1000}; delay(400); })();
for (let i =0; i < 1000; i++) {
delay(i);
} Which causes The same principle applies to subscriptions. Usually, whether it is a file handle, a web socket, a bucket storage download, or whatever, there is some "hot" state associated with a subscription. const subcription1 = stream[Symbol.asyncIterator]() // No `await` on the stream itself In that code, it is unknown what state I hope that helps explain why the differences are necessary. While there are analogues for Async/Await constructs in Effection, they are only that: analogues. They must be different it very distinct ways so that we can experience the guarantees that structured concurrency provides which Async/Await doesn't. I'll leave one final difference which I think is important to grasp. In Effection, |
Beta Was this translation helpful? Give feedback.
-
I confidently refactored my code, but the results still didn't meet expectations. Perhaps there's indeed something unreasonable with the Stream type definition. I haven't used First, what does
In TypeScript, any object implementing the Iterable protocol can legally be used as the operand of interface Iterable<T, TReturn = any, TNext = any> {
[Symbol.iterator](): Iterator<T, TReturn, TNext>;
} declare const foo: Iterable<unknown>
function* test() {
yield* foo // passes TypeScript's check
} However, generator functions return Generators that implement Iterable but also have methods like function *foo(){
return 2333
}
function* test(){
const generator=foo()
console.log(yield* generator) // prints 233
console.log(yield* generator) // prints undefined
}
Array.from(test()) It's understandable that generators can slice execution through But a generator being stateful doesn't mean the operand of function* foo() {
return 2333
}
function* test() {
const generator = { [Symbol.iterator]: () => foo() }
console.log(yield* generator) // prints 233
console.log(yield* generator) // prints 233
}
Array.from(test()) As long as Now back to the question: "Why do I think Stream's type definition is unreasonable?" @cowboyd In Effection, we always use generator functions to create Operations. Although Operation's signature resembles Iterable, its construction method inherently makes it a Generator - unless it's not expressed through generator functions. TypeScript's automatic type inference always reminds us that these functions return Generator-type things. We have to accept that Operations created via generator functions are "stateful" - yielding* the same Operation twice will do nothing on the second attempt and directly return undefined. What's frustrating is that TypeScript won't warn about this undefined result from the second yield* - issues only surface during execution. But in Effection's documentation, we see channel usage like: let channel = createChannel();
// the channel has no subscribers yet!
yield* channel.send('too early');
let subscription1 = yield* channel;
let subscription2 = yield* channel; The channel (Stream-type instance) is yield* twice. Maybe it's a new Iterable subclass? But no - Stream is an implementation of the Operation generic: type Stream<T, TReturn> = Operation<Subscription<T, TReturn>>; Thus in Effection, we must constantly remember which Operations created via generator functions can only be yielded once, and which are "stateless" Operations constructed differently that can be yielded multiple times. Without checking implementations, we can't distinguish them - not a good development experience. (Even distinguishing between Operation and Stream isn't enough, as Stream is more specific.) My thoughts:
Maybe Effection could introduce a subtype XXX for "stateless" Iterables that can be yield* multiple times. For example: Stream<T, TReturn> = XXX<Subscription<T, TReturn>> This way, we could identify it without knowing implementations. Ideally, Stream (XXX) shouldn't be a subtype of Operation, preventing assignment of Stream instances to Operation-type variables. |
Beta Was this translation helpful? Give feedback.
@iplaylf2 I think I see where you're coming from. There definitely are some differences between the Async/Await primitives and their Effection analogues, and that they can seem confusing and/or unnecessary when you are new to Effection. However, it is precisely in these differences that you will find the key to the guarantees that Effection provides you that Async/Await does not. Specifically:
It is impossible in Effection to create any effect whatsoever that is not bound to a scope.
This may seem restrictive at first, but it is actually a superpower.
Take for example the
delay
function you defined:This eagerly creates the …