Operator<T, U> (type)
Operators provide a way to transform events data before they are passed to the callback.
EVT Operators can be of three types:
- Filter:
(data: T)=> boolean
.Only the matched event data will be passed to the callback. - Type guard:
<U extends T>(data: T)=> data is U
Functionally equivalent to filter but restrict the event data type. - fλFilter / transform / detach handlers
- Stateless fλ:
<U>(data: T)=> [U] | null
- Stateful fλ:
[ <U>(data: T, prev: U)=> ..., U ]
Uses the previous matched event data transformation as input à laArray.prototype.reduce
Operators do not have to be pure, they can use variables available in scope and involve time
(Date.now())
, but they must not have any side effect. In particular they cannot modify their input.Let us consider the example use of an operator that filters out every word that does not start with 'H'.
import { Evt } from "evt";
const evtText= Evt.create<string>();
evtText.attach(
text=> text.startsWith("H"),
text=> {
console.assert( text.startsWith("H") );
console.log(text);
}
);
//Nothing will be printed to the console.
evtText.post("Bonjour");
//"Hi!" will be printed to the console.
evtText.post("Hi!");
It is important to be sure that your filter always return a
boolean
, typewise you will be warned it is not the case but you must be sure that it is actually the case at runtime.
If in doubts use 'bang bang' ( !!returnedValue
). This note also applies for Type Gard operators.If you use a filter that is also a type guard, the type of the callback argument will be narrowed down to the matched type.
Let us define a straight forward type hierarchy to illustrate this feature.
type Circle = {
type: "CIRCLE";
radius: number;
};
type Square = {
type: "SQUARE";
sideLength: number;
};
type Shape = Circle | Square;
//Type Guard for Circle:
const matchCircle = (shape: Shape): shape is Circle =>
shape.type === "CIRCLE";
The
matchCircle
type guard can be used to attach a callback to an Evt<Shape>
that will only be called against circles.import { Evt } from "evt";
const evtShape = Evt.create<Shape>();
evtShape.attach(
matchCircle,
shape => console.log(shape.radius)
);
//Nothing will be printed on the console, a Square is not a Circle.
evtShape.post({ "type": "SQUARE", "sideLength": 3 });
//"33" Will be printed to the console.
evtShape.post({ "type": "CIRCLE", "radius": 33 });
The type of the Shape object is narrowed down to
Circle

Anonymous functions to simultaneously filter, transform the data and control the event flow.
fλ Returns
The type of values that a fλ operator sole determine what it does:
null
If the event should be ignored and nothing passed to the callback.[ U ]
When the event should be handled, wrapped into the singleton is the value will be passed to the callback.
Stateless fλ operator only takes the event data as arguments.
import { Evt } from "evt";
const evtShape = Evt.create<Shape>();
/*
* Filter:
* Only circle events are handled.
* AND
* to be handled circles must have a radius greater than 100
*
* Transform:
* Pass the radius of such circles to the callback.
*/
evtShape.$attach(
shape => shape.type === "CIRCLE" && shape.radius > 100 ?
[ shape.radius ] : null,
radiusOfBigCircle => console.log(`radius: ${radius}`)
//NOTE: The radius argument is inferred as being of type number!
);
//Nothing will be printed to the console, it's not a circle
evtShape.post({ "type": "SQUARE", "sideLength": 3 });
//Nothing will be printed to the console, The circle is too small.
evtShape.post({ "type": "CIRCLE", "radius": 3 });
//"radius 200" Will be printed to the console.
evtShape.post({ "type": "CIRCLE", "radius": 200 });
The result of the previously matched event is passed as argument to the operator.
import { Evt } from "evt";
const evtText= Evt.create<string>();
evtText.$attach(
[
(str, prev)=> [`${prev} ${str}`],
"START: " //<= seed
],
sentence => console.log(sentence)
);
evtText.post("Hello"); //Prints "START: Hello"
evtText.post("World"); //Prints "START: Hello World"
Operators cannot have any side effect (they cannot modify anything). No assumption should be made on when and how they are called.
The first thing that you might be tempted to do is to use a variable available in the operator's scope as an accumulator.
The following example seems equivalent from the previous one but it is not.
const evtText= Evt.create<string>();
//🚨 DO NOT do that 🚨...
evtText.$attach(
(()=> {
let acc= "START:";
return (data: string) => [acc += ` ${data}`] as const;
})(),
sentence => console.log(sentence)
);
const text= "Foo bar";
if( evtText.isHandled(text) ){
//Prints "START: Foo Bar Foo bar", probably not what you wanted...
evtText.post(text);
}
When evt.isHandled(data) is invoked the operator of every handler is invoked. The operator is invoked again when the event is actually posted.
In the example every time the operator is invoked the encapsulated variable acc is updated. This result in "Foo bar" being accumulated twice when the event is posted only once.
evt.postAsyncOnceHandled(data)
will also cause dry invokations of the operators.If state is needed stat full fλ have to be used.
import { Evt } from "evt";
const evtText= Evt.create<string>();
//Do not modify the accumulator value.
evtText.$attach(
[
(text, arr: string[])=> {
arr.push(text);
return [arr];
},
[]
],
arr=> { /*...*/ }
);
/* ----------------------------- */
//Do Return a new array
evtText.$attach(
[
(text, arr: string[]) => [[...arr, text]],
[]
],
arr=> { /*...*/ }
);
The TypeScript const assertion features come in handy if you introduce closures, for example. The following example does not compile without the use of
as const
.const evtShapeOrUndefined = Evt.create<Shape | undefined>();
evtShapeOrUndefined.$attach(
shape => !shape ?
null :
(() => {
switch (shape.type) {
case "CIRCLE": return [shape.radius] as const;
case "SQUARE": return [shape.sideLength] as const;
}
})(),
radiusOrSide => { /* ... */ }
);
Generally const assertions can help you narrow down the return type of your operator. In the following example without the const assertions
data
is inferred as being string | number
, with the const assertions it is "TOO LARGE" | number
import { Evt } from "evt";
const evtN = Evt.create<number>();
evtN.$attach(
n => [ n>43 ? "TOO LARGE" as const : n ],
data=> { /* ... */ }
);
This is more a guideline than a requirement but you should favor
data => expression
over data=> { ...return x; }
wherever possible for multiple reasons:- 1.It is much less likely to inadvertently produce a side effect writing a single expression function than it is writing a function with explicit returns.
- 2.Operators are meant to be easily readable. If you think the operator you need is too complex to be clearly expressed by a single instruction, you should consider splitting it in multiple operators and using the compose function introduced in the next section.
- 3.It is easier for TypeScript to infer the return type of single expression functions.
Here is the previous example using explicit returns just to show you that the return type has to be explicitly specified, this code does not copy without it.
import { Evt } from "evt";
const evtN = Evt.create<number>();
//🚨 This is NOT recomanded 🚨...
evtN.$attach(
(n): [ "TOO LARGE" | number ] => {
if( n > 43 ){
return [ "TOO LARGE" ];
}
return [n];
},
data=> { /* ... */ }
);.
Operators can be composed ( aka piped ) to achieve more complex behaviour.
For most use cases, it is more convenient to chain
evt.pipe()
calls rather than using compose. However it is very useful for creating custom operators.Example composing type guards with fλ:
import { Evt, compose } from "evt";
const evtShape= Evt.create<Shape>();
evtShape.$attach(
compose(
matchCircle,
({ radius })=> [ radius ]
),
radius => console.log(radius)
);
//Prints nothing, Square does not matchCircle
evtShape.post({ "type": "SQUARE", "sideLength": 10 });
//Prints "12"
evtShape.post({ "type": "CIRCLE", "radius": 12 });
import { Evt, to, compose } from "evt";
const evt = Evt.create<
["text", string] |
["time", number]
>();
evt.$attach(
compose(
to("text"),
text => [ text.toUpperCase() ]
)
text => console.log(text)
);
evt.post(["text", "hi!"]); //Prints "HI!" ( uppercase )
Example composing three fλ to count the number of different words in a sentence:
import { Evt, compose } from "evt";
const evtSentence = Evt.create<string>();
evtSentence.$attach(
compose(
str=> [ str.toLowerCase().split(" ") ],
arr=> [ new Set(arr) ],
set=> [ set.size ]
),
numberOfUniqWordInSentence => console.log(numberOfUniqWordInSentence)
);
evtSentence.post("Hello World"); //Prints "2"
evtSentence.post("Boys will be boys"); //Prints "3", "boys" appears twice.
Using stateful fλ operators to implement
throttleTime(duration)
, an operator that let through at most one event every duration
milliseconds.import { Evt, compose } from "evt";
const throttleTime = <T>(duration: number) =>
compose<T, { data: T; lastClick: number; }, T>(
[
(data, { lastClick }) =>
Date.now() - lastClick < duration ?
null :
[{ data, "lastClick": Date.now() }],
{ "lastClick": 0, "data": null as any }
],
({ data }) => [data]
)
;
const evtText = Evt.create<string>();
evtText.$attach(
throttleTime(1000), //<= At most one event per second is handled.
text => console.log(text)
);
setTimeout(()=>evtText.post("A"), 0); //Prints "A"
//Prints nothing, the previous event was handled less than 1 second ago.
setTimeout(()=>evtText.post("B"), 500);
//Prints nothing, the previous event was handled less than 1 second ago.
setTimeout(()=>evtText.post("B"), 750);
setTimeout(()=>evtText.post("C"), 1001); //Prints "C"
setTimeout(()=>evtText.post("D"), 2500); //Prints "D"
Unless all the operators passed as arguments are stateless the operator returned by
compose
is not reusable.import { Evt, compose } from "evt";
//Never do that:
{
const op= compose<string,string, number>(
[(str, acc)=>[`${acc} ${str}`], ""],
str=> [str.length]
);
const evtText= Evt.create<string>();
evtText.$attach(op, n=> console.log(n));
evtText.$attach(op, n=> console.log(n));
evtText.post("Hello World"); //Prints "12 24" ❌
}
console.log("");
//Do that instead:
{
const getOp= ()=> compose<string,string, number>(
[(str, acc)=>[`${acc} ${str}`], ""],
str=> [str.length]
);
const evtText= Evt.create<string>();
evtText.$attach(getOp(), n=> console.log(n));
evtText.$attach(getOp(), n=> console.log(n));
evtText.post("Hello World"); //Prints "12 12" ✅
}
The
Operator
type alias defines what functions qualify as a valid EVT operaor. The type can be used as a scaffolder to write fλ.In
Operator<T, U>
, T
design the type of the event data and U
design the type of the data spitted out by the operator. For filters operator U=T
.import type { Operator } from "evt";
//A function that take an EVT operator as argument.
declare function f<T, U>(op: Operator<T, U>): void;
//Les's say you know you want to create an operator that take string
//and spit out number you can use the type alias as scaffolding.
const myStatelessFλOp: Operator.fλ<string, number> =
str => str.startsWith("H")? null : [ str.length ];
//The shape argument is inferred as being a string and TS control that you
//are returning a number (str.length) as you should.
f(myStatelessFλOp); //OK, f<Shape,number> Operator.fλ is assignable to Operator.
//An other example creating an stateful operator
const myStatefulFλOp: Operator.fλ<string, number> =
[
(data, prev) => [prev + data.length],
0
];
f(myStatefulFλOp); //OK, f<string, number>
//Filter and TypeGuard don't need scaffolding but they are valid Operator
f((data: string) => data.startsWith("H")); // OK, TS infer f<string, string>
f((n: number): n is 0 | 1 => n === 0 || n === 1); // OK, TS infer f<number, 0 | 1>
Generic operators such as
bufferTime
debounceTime
, skip
, take
, switchMap
, mergeMap
and reduce
Will be added later on alongside creators. To implement those we need a third type of operator called AutonomousOperators
that will ship in the next major release.Some generic operators are provided in
"evt/lib/util/genericOperators"
such as scan
, throttleTime
or to
but that's about it.//Importing custom operator chunksOf that is not exported by default.
import { chuncksOf } from "evt/lib/util/genericOperators";
Operators functions can be used with:
Last modified 10mo ago