Cerebral enables writing really declarative and readable code. You can express a lot, in terms of human spoken language with its syntax. But still Cerebral code can suffer from high level of implicitness. In this short post we will try to find a decent solution to the problem of API discoverability (in Cerebral meaning).
If you are new to Cerebral or you haven't seen my previous articles here's the list:
- Use your Cerebral
- Use your Cerebral - from imperative to declarative
- Use your Cerebral - match your patterns!
- Use your Cerebral - writing a game
NOTE: All the reasoning and code will be centered around usage of both Cerebral and TypeScript.
The context
You're working for Chatty company that brings people together by the platform called "The Chat". You're in the middle of the task for showing how many people answered specific user's question.
Boring technical stuff...
Suppose one of our team mates prepared a Cerebral provider that can be used for communication with REST API. He said that there's handy operator for getting all the answers and you just need to pass question id.
You immediately opened the sequence that will be triggered when user with a great curiosity checks how many answers were given. Then, you imported mentioned operator and started typing...
// ... somewhere in the middle of the Cerebral's sequence ...
getAnswers(props.questionId), {
// What to put here...?
}
The problem no. 1
As a decent programmer, you know that communication with REST API is effectful operation and can fail. So you can safely assume that there are two possible paths (in Cerebral's nomenclature). You vigorously continue typing...
// ... somewhere in the middle of the Cerebral's sequence ...
getAnswers(props.questionId), {
success: [],
error: []
}
You asked your team mate about the paths - just to be sure (it's good to double check things, right?). He confirmed - after using the operator one needs to put an object with to properties: one for successful path and another for failure-ish one.
You saved the file, HMR did the job and you see...
Runtime nightmare
What? Exception? Hm, you double checked that usage of the operator is correct, but you reached the author of that piece of code and... He says that he forgot to mention he used "success" and "failure" as path names. Ok, that's fine. There's no team convention so why not "failure"?
The problem no. 2
Immediately, after getting double confirmation: from reality and from your team mate, you corrected your lovely piece of code:
// ... somewhere in the middle of the Cerebral's sequence ...
getAnswers(props.questionId), {
success: [],
failure: []
}
You used holy CTRL+S combination and HMR silently reloaded the app. No runtime errors. You even had quick talk with a team mate and you convinced him that it's not obvious to have "failure" path. You both decided you will refactor his code and bake paths into operator's signature. After 20 minutes of refactoring, adding tests (of course!) you landed with such usage:
// ... somewhere in the middle of the Cerebral's sequence ...
getAnswers(props.questionId, {
success: [],
failure: []
}),
Now there's no way that anyone in the future (including yourself from the future) would use incorrect paths - they are encoded in operator's signature and TypeScript compiler will gently remind you about that. Awesome.
The problem no. 3
But wait. Probably each path contributes to Cerebral's props
differently. Does "success" path exposes HTTP response under props.response
? When request fails, does "failure" path adds reason and details of unsuccessful API call under props.error
? What's the probability of our team mate made up some new convention and it can be only discovered through usage?
One can say that having Cerebral Debugger on duty solves most/all of mentioned issues. Of course, such approach does the job, but question is - can we do better?
Operators/actions discoverability issue
In Cerebral, you write all sequences, actions, operators as if you describe what needs to be done, which is great. Mentally you have to remember about execution context - what's actually available via props
. Cerebral Debugger helps with that, but sometimes before you have something up & running and visible in Debugger, you need to play with design a bit to feel the best approach. As I personally love Cerebral - philosophy, concept and so on - next to reasoning about "regular" code you have to reason about the execution context (props
)
In fact, in JS discoverability is even harder, because of JS. TypeScript makes it easier, of course because of types, but this helps only for "regular" code. Can we increase discoverability of our operators that branch our code using paths? Let's look at the structure of typical operator.
Our hero - getAnswers
As you probably already saw, after smart refactoring, this operator accepts a Tag and object defining available paths. Typically such object maps path name to next action/sequence. Example:
// ... somewhere in the middle of the Cerebral's sequence ...
getAnswers(props.questionId, {
success: [
showSuccessNotification,
set(state.user.question.answers, props.result.response.answers),
highlightTopRatedAnswer(state.user.question.answers)
],
failure: [
showFailureNotification(props.result.error.reason),
sendError(props.result.error)
]
}),
So "success" path exposes props.result.response
path and "failure" path exposes props.result.error
path. At the top of the sequence's file you would probably have something like:
import { props as p } from "app.cerebral"
type ApiResult = {
response: {
answers: string[]
},
error: {
reason: string,
details: string
}
}
const props = p as {
questionId: Guid,
result: ApiResult
}
It gives some notion what is/would be available in a sequence (in fact in various moments of time!). We can try to pursue those moments when specific properties become available on props
, especially when working with operators.
One tool to rule them all
How might getAnswers
operator look like inside?
import { sequence } from "cerebral"
const requestAnswersFor = (questionId: Tag) => ({ resolve, theChatAPI, path }) =>
theChatAPI
.getAnswers(resolve.value(questionId))
.then((response) => path.success({ result: { response } })
.catch((error) => path.failure({ result: { error } })
type Paths = {
success: Action | Sequence,
failure: Action | Sequence
}
const getAnswers = (questionId: Tag, paths: Paths) => sequence("get answers for given question", [
requestAnswersFor(questionId), {
success: paths.success,
failure: paths.failure
}
])
As you can see, this operator is a regular function that maps given arguments to a sequence. Internally it branches the code in a typical Cerebral syntax. How can we capture different things exposed in props
for each path?
A function. That's it. But where? How? Let's start with types!
type ApiResult<T> = {
result: T
}
type SuccessPathProps = {
response: {
answers: string[]
}
}
type FailurePathProps = {
error: {
reason: string,
details: string
}
}
type SuccessPath = (props: ApiResult<SuccessPathProps>) => Action | Sequence
type FailurePath = (props: ApiResult<FailurePathProps>) => Action | Sequence
So we declared a helper ApiResult<T>
type. We also made each path have its own respective "output" props type. Finally, we used aforementioned function approach to capture different objects available in props
. And now we alter Paths
type:
type Paths = {
success: SuccessPath,
failure: FailurePath
}
Which now requires change in getAnswers
operator, because TypeScript gently reminds us that there's types mismatch:
const failureProps = p as ApiResult<FailurePathProps>
const successProps = p as ApiResult<SuccessPathProps>
const getAnswers = (questionId: Tag, paths: Paths) => sequence("get answers for given question", [
requestAnswersFor(questionId), {
success: paths.success(successProps),
failure: paths.failure(failureProps)
}
])
So instead of just using each path, e.g. paths.success
, we call it, because now it's a function that accepts "local" props and returns action or sequence. Last wave of refactoring is getAnswers
operator usage:
// ... somewhere in the middle of the Cerebral's sequence ...
getAnswers(props.questionId, {
success: (successProps) => [
showSuccessNotification,
set(state.user.question.answers, successProps.result.response.answers),
highlightTopRatedAnswer(state.user.question.answers)
],
failure: (failureProps) => [
showFailureNotification(failureProps.result.error.reason),
sendError(failureProps.result.error)
]
}),
On this level, refactoring process boiled down to turning sequences into functions returning them. Not bad, huh?
Runtime exception strikes back!
Routine CTRL+S shortcut hit and HMR magic spells...YOU HAVE NOT ENABLED THE BABEL-PLUGIN-CEREBRAL
. Wait, what?!
Cerebral 5 (version 5) uses Proxy browser feature to enable fluent Tag usage (e.g. props.result.response
). In fact, it utilizes babel-plugin-cerebral
to transform proxies into template tag literals (e.g. props'result.response'
). Turned out that without help from Cerebral's author, Christian Alfoni, I wouldn't solve. Christian does amazing job doing open source, always helps when any doubts. You can give your support to him by buying him a coffee.
Problem is with dynamic usage of proxy's properties here. Usually when we use proxy in our Cerebral code, we just access a property which is "static" usage. That's what babel-plugin-cerebral
is targeting - static usage of proxy - it can handle transformation. When we're passing, e.g. a successProps
, to a paths.success
function and then caller access its properties, plugin doesn't know how to handle that. So this is the root cause why it's not working.
Brave new world
Solution from Christian is to transfer all properties of a proxy into a new instance of an object. Deferred usage of proxy's property is to capture it in a closure.
type InputProps<T> = { [K in keyof T]: (() => T[K]) | InputProps<T[K]> }
function createOwnProps<T>(target: InputProps<T>) {
function convert<T>(obj: InputProps<T>) {
const newObj: any = {}
for (let key in obj) {
if (typeof obj[key] === "function") {
Object.defineProperty(newObj, key, { get: obj[key] as any })
} else {
newObj[key] = convert(obj[key] as any)
}
return newObj
}
}
return convert(target) as T
}
It looks scary, but in fact logic inside is simple. It does the properties transfer from a given target to a new object, exposing target's property as a getter. Now we should be able to convert our operator implementation to the following:
const failureProps = p as ApiResult<FailurePathProps>
const successProps = p as ApiResult<SuccessPathProps>
const wrappedSuccessProps = {
result: {
response: { answers: () => failureProps.result.response.answers }
}
}
const wrappedFailureProps = {
result: {
error: { reason: () => failureProps.result.error.reason }
}
}
const getAnswers = (questionId: Tag, paths: Paths) => sequence("get answers for given question", [
requestAnswersFor(questionId), {
success: paths.success(convertOwnProps(wrappedSuccessProps)),
failure: paths.failure(convertOwnProps(wrappedFailureProps))
}
])
Phew. Now it looks daunting as hell. Unfortunately some boilerplate is involved when nested properties are taken into account. Here we have two levels of nesting (excluding result
as a property in props
). Having larger and richer objects in props
would result in code really hard to read. So keeping "local" (or "own") props
exposed from an operator small and one level nested is fairly fine.
Conclusion
We found really interesting approach for exploring API discoverability. The process of going through the implementations looked a little bit intimidating, but in fact we would be able to reuse convertOwnProps
all over the application. In this particular case the trade off is between operator's API readability and operator's internal implementation. What's worth noticing, it's not a silver bullet, but rather a way of exploring expressiveness of Cerebral's syntax along with browser capabilities. To balance usage of such approach, a developer using this pattern would need to ask some questions:
- how often such operator would be used?
- do we really need that level of discoverability?
- does it really make sense to increase complexity of the implementation in favor of communication of intentions and possibilities of the operator?
Hopefully you liked the entire process of digging into Cerebral & TS. See you soon!
Top comments (0)