Ideas
Spell out "magic" functions:
arg => first(...)(arg)
Do "simple" Prague:
Prague
A rule system handy for games and conversational user interfaces. I thought of it as I walked around the city of Prague on a sunny Spring day. This is not an official Microsoft project.
Major features of Prague:
- strongly-typed when using TypeScript (but you don't have to use TypeScript)
- deeply asynchronous via RxJS (but you don't have to use RxJS)
- utilizes and promotes functional programming (you do actually have to use functional programming)
Some types of applications you could build with Prague:
Building Prague
- clone this repo
npm install
npm run build
(or npm run watch
to build on file changes)
To add to your app
Prague Essentials
Transform
The fundamental unit of Prague is a special type of function called a Transform
:
type Transform<ARGS extends any[], OUTPUT extends Result | null> = (...args: ARGS) => Observable<OUTPUT>;
A Transform
function is called with arguments as normal. But instead of returning a result directly, it returns an object called an Observable
. You subscribe to that object to get the result. If you're new to Observable
s, you may want to read Observables and Promises, which has both a quick introduction to Observable
s and also shows how to ignore them and just work with Promise
s.
A Transform
emits either null
or a subclass of Result
.
Result
Result
is an abstract base class. The following three subclasses of Result
are "core" to Prague:
Value<VALUE>
- contains a value of type VALUEAction
- contains an action (function) to potentially execute at a future time
Prague also includes and makes use of these subclasses of Result
:
ActionReference
- contains the (serializable) name and arguments of a function to potentially execute at a future timeMultiple
- contains an array of Result
s
from
The from
function allows you to write Tranform
s more simply, by returning a value instead a Value
, a function instead of an Action
, undefined
instead of null
, or a Promise
or a synchronous result instead of an Observable
:
const repeat = from((a: string) => a.repeat(5))
const confirm = from((a: number) => () => console.log(`You picked ${a.toString()}`));
const getName = from((a: string) => fetch(`url/${a}`).then(r => r.json()).then(r => r.name));
are equivalent to:
const repeat = (a: string) => Rx.of(new Value(a.repeat(5)));
const confirm = (a: number) => Rx.of(new Action(() => console.log(`You picked ${a.toString()}`)));
const getName = (a: string) => Rx.from(fetch(`url/${a}`).then(r => r.json()).then(r => new Value(r.name)));
For your convenience, from
is automatically called every place a Transform
is expected. For example:
first(
(t: string) => t === "Bill" ? "Bill Barnes" : null,
t => t === "Hao" ? "Hao Luo" : null,
t => t === "Kevin" ? "Kevin Leung" : null,
)
is equivalent to:
first(
from((t: string) => t === "Bill" ? "Bill Barnes" : null),
from(t => t === "Hao" ? "Hao Luo" : null),
from(t => t === "Kevin" ? "Kevin Leung" : null),
)
As a result you never need to explicitly call from
unless you are writing your own helper function.
Composition via helpers
You can compose Transform
s together into a new Transform
using a variety of high-order functions included in Prague, or you can create your own.
first
first
returns a new Transform
which calls each of the supplied Transform
s in turn. If one emits a Result
, it stops and emits that. If all emit null
, it emits null
.
import { first } from 'prague';
const fullName = first(
(t: string) => t === "Bill" ? "Bill Barnes" : null,
t => t === "Hao" ? "Hao Luo" : null,
t => t === "Kevin" ? "Kevin Leung" : null,
);
fullName("Bill").subscribe(console.log);
fullName("Hao").subscribe(console.log);
fullName("Yomi").subscribe(console.log);
Note that all the Transform
s have the same argument types. However you only need to declare the argument types for the first Transform
. TypeScript will use those for the rest, and for the resultant Transform
, automatically. It will also complain if your Transforms
have incompatibile argument types.
pipe
pipe
returns a new Transform
which calls each of the supplied Transform
s in turn. You supply the arguments for the first. If it emits a Result
, that becomes the argument for the second, and so on. If any of the Transform
s emit null
, the new Transform
stops and emits null
. Otherwise the new Transform
emits the Result
emitted by the last Transform
.
import { pipe } from 'prague';
const someAssemblyRequired = pipe(
(a: string, b: string) => a + b,
fullName,
);
someAssemblyRequired("Kev", "in").subscribe(console.log);
someAssemblyRequired("Yo", "mi").subscribe(console.log);
Note that you only need to declare the argument types for the first transform. TypeScript will infer the argument types for the rest (and for the resultant Transform
) automatically.
match
match(getValue, onValue, onNull)
returns a new Transform
that calls getValue
. If that emits a Value
, it calls onValue
with that value, and emits its output. If getValue
emits null
, onNull
is called with no arguments, and the new Transform
emits its output. If onNull
is omitted, the new Transform
emits null
when getValue
emits null
.
import { match } from 'prague';
const greet = match(
fullName,
m => `Nice to meet you, ${m.value}.`,
() => `I don't know you.`,
);
greet("Kevin").subscribe(console.log);
greet("Yomi").subscribe(console.log);
matchIf
matchIf
is a special case of match
for the common case of testing a "truthy" predicate.
import { matchIf } from 'prague';
const greet = matchIf(
(t: string) => t === "Bill",
() => `I greet you, my creator!`,
() => `Meh.`,
);
greet("Bill").subscribe(console.log);
greet("Yomi").subscribe(console.log);
tap
tap
returns a Transform
that executes a function but ignores its output, returning the original input. This is a great way to debug:
pipe(
(t: string) => t === "Bill" ? "Bill Barnes" : null,
tap(console.log),
t => t.repeat(2),
).("Bill")
.subscribe();
This is common enough that Prague provides a helper called log
which is equivalent to tap(console.log)
.
Action
and run
Imagine we're creating a chatbot that can respond to several phrases:
const bot = from((t: string) => {
if (t === "current time")
console.log(`The time is ${new Date().toLocaleTimeString()}`);
else if (t === "I'm hungry")
console.log(`You shoud eat some protein.`);
else if (t === "Wassup")
console.log(`WAAAASSSUUUUUUP!`);
});
bot("Wassup").subscribe();
This works, but it isn't the Prague way. Rather than executing code immediately, we prefer to return Action
s:
const bot = from((t: string) => {
if (t === "current time")
return () => console.log(`The time is ${new Date().toLocaleTimeString()}`);
else if (t === "I'm hungry")
return () => console.log(`You shoud eat some protein.`);
else if (t === "Wassup")
return () => console.log(`WAAAASSSUUUUUUP!`);
})
Now we can use tap
to call the action:
pipe(
bot,
tap(m => {
if (m instanceof Action)
return m.action();
}),
)("Wassup").subscribe();
This is common enough that Prague provides a helper called run
:
pipe(
bot,
run,
)("Wassup").subscribe();
Obviously actions can do much more than console.log
. This approach of waiting to executing side effects until you're done is a classic functional programming pattern, and makes for much more declarative code.
Scoring: best
, sorted
, and top
Something we have not touched on is that every Result
has a score
, a floating point numeric value between 0 and 1, inclusive. By default this score is 1, but you can specify a different score when creating any Result
:
new Value("Bill", .5);
Scores are useful when the situation is ambiguous. Say our chatbot asks the user for their name. The user's response might be their name, or they might be ignoring your question and giving a command. How can you know for sure? Certain responses are more likely than others to mean "I am telling you my name". One strategy is to assign a score to each outcome, and choose the highest-scoring outcome. That's where scoring comes in.
In this example we'll first score two different potential responses to a request for a name, then we'll choose the highest scoring one. If there is one, we'll create an action with that score. Finally we'll put that against a differently scored action.
import { best } from 'prague';
const bot = best(
match(
best(
pipe(
(t: string) => /My name is (.*)/i.exec(t),
matches => matches.value[1],
),
t => new Value(t, .5),
),
m => new Action(() => console.log(`Nice to meet you, ${m.value}`), m.score)
),
matchIf(
t => t === "current time",
() => new Action(() => console.log(`The time is ${new Date().toLocaleTimeString()}`), .9),
),
);
const test = (a: string) => pipe(
bot,
run
)(a).subscribe();
test("Bill");
test("My name is Bill");
test("current time");
test("My name is current time")
So far, so good. But consider this case:
const transforms = [
() => new Value("hi", .75),
() => new Value("hello", .75),
() => new Value("aloha", .70),
() => new Value("wassup", .65),
];
best(
...transforms
)().subscribe(console.log)
Calling best
can be unsatisfactory when there is a tie at the top. Things get even more challenging if you want to program in some wiggle room, say 5%, so that "aloha" becomes a third valid result.
It turns out that best
is a special case of a helper called sorted
, which returns a Transform
which calls each supplied Transform
with the supplied arguments. If none emit, neither does it. If one returns a Result
, it returns that. If two or more return a Result
, it returns a Multiple
, which is a Result
containing an array of all the Result
s.
const sortme = sorted(
...transforms
);
sortme().subscribe(console.log);
We can narrow down this result using a helper called top
.
To retrieve just the high scoring result(s):
pipe(
sortme,
top(),
)().subscribe(console.log);
To include "aloha" we can add a tolerance
of 5%:
pipe(
sortme,
top({
tolerance: .05,
}),
)().subscribe(console.log);
We can set a tolerance
of 1 (include all the results) but set the maximum results to 3. This will have the same effect as the above:
pipe(
sortme,
top({
maxResults: 3,
tolerance: 1,
}),
)()
.subscribe(console.log);
Increasing tolerance
includes more items in the "high score". It defaults to 0
and has a maximum value of 1
.
Decreasing maxResults
limits of the number of "high score" results retrieved. It defaults to Number.POSITIVE_INFINITY
and has a minimum value of 1
.
In fact, best
is just a special case of piping the results of sorted
into top
:
const best = (...transforms) => pipe(
sorted(...transforms),
top({
maxResults: 1,
}),
);
top
is just one way to narrow down multiple results. There are others. You may apply multiple heuristics. You may even ask for human intervention. For instance, in a chatbot you may wish to ask the user to do the disambiguation ("Are you asking the time, or telling me your name?"). Of course their reply to that may also be ambiguous...
ActionReference
and ActionReferences
tk
Observable
s and Promise
s
Observable
s are a powerful and flexible approach to writing asynchronous code, but you don't have to go all the way down that rabbit hole to use Prague. All you need to knoe is that an Observable
emits zero or more values, and then either throws an error or completes. Prague Transforms
never emit more than one value, which will always be a Result
.
Calling a Transform
fullName("Bill")
.subscribe(
result => {
},
err => {
},
)
Using Promise
s instead
If you think this looks similar to writing resolve/reject handlers for a Promise
, you're right. In fact, you can easily convert an Observable
to a Promise
as follows:
fullName("Bill")
.toPromise()
.then(
result => {
},
err => {
},
)
Reference
tk
Samples
tk