attain
- Streams 🐍
- Data Modelling and Persistence 💾
- Sum Types 🐱🏍
- Queries 👓
- View Layer 💨
This libraries optimizes for composition and safety without forgetting that this is ultimately still Javascript.
attain.harth.io
Installation
npm install attain
Quick Start
Queries:
import { $ } from 'attain'
const d = $.a.b.c.d
let state = {}
state = d( 2 ) (state)
state
Sum Types:
import * as A from 'attain'
const User = A.type('User', ['LoggedIn', 'LoggedOut'])
const message =
User.fold({
LoggedIn: ({ username }) => `Welcome ${username}!`,
LoggedOut: () => 'Bye!'
})
message ( User.LoggedIn({ username: 'Bernie' }) )
message ( User.LoggedIn() )
Either
const success = A.Y('Success')
const failure = A.N('Oh no!')
A.isY(success)
A.getOr(null) (success)
A.getOr(null) (failure)
A.getWith(null, x => x => x.toUpperCase()) (success)
A.getOr(null) (failure)
A.fold({ N: () => false, Y: () => true ) (success)
A.bifold( () => false, () => true ) (success)
A.map( x => x.toUpperCase() ) (success)
A.map( x => x.toUpperCase() ) (failure)
API
Utils
You'll see the usage of some utilities in the example code. Here's a quick breakdown.
run
One day in the hopefully not so distant future we'll have a feature in javascript called the pipeline operator.
The pipeline operator allows you to compose and apply functions in one step. It makes for some very nice, reusable and decoupled code.
run
is our interim solution until |>
lands.
It simply takes some input data as the first argument, and some functions as the remaining arguments and applies each function in order to the previous result.
run(
2
, x => x * 2
)
run(
2
, x => x * 2
, x => x + ''
)
This is handy for attain
because all of our functions are static and do not rely on this
for context.
Eventually, if/when |>
lands, run
will likely be deprecated and replaced and all examples will use |>
instead.
🎁 If you're a adventurous you can try |>
today via the babel plugin
Queries
What are queries? Well to be honest, they are a thing I made up.
But... they are basically the same thing as lenses but with some JS magical shortcuts.
Lenses can be a little tricky to learn, so think of queries as jquery
but for your traversing your normal JS object.
Quick Start
Here's an example of $
which creates a querie bound to a store. Sounds fancy!
import { $, Z } from 'attain'
let state = {}
const $d = Z({
,read: () => state
,write: f => state = f(state)
})
.a.b.c.d
$d('hello')
$d( x => x.toUpperCase() )
$d()
state
$d.$delete()
$d.$stream.map(
d => console.log(d)
)
$d.$removed.map(
d => console.log(d)
)
attain
queries are just objects that allow you to get/set/transform a particular location in an object.
Lenses are a far more generic abstraction, but are generally used to do the above, so this library optimizes for that approach.
$
$
lets you get/set and transform a value that you've queries.
const set =
$.a.b.c( 2 )
const update =
$.a.b.c( x => x * 2 )
const get =
$.a.b.c()
let state = {}
state = set(state)
state = update(state)
get(state)
Notice get(state)
returns a list, that's because queries can return multiple results.
You can use .$values
to query against an iterable, or use $union
to merge two queries into one query.
If you want to use queries but also be notified of changes, check out Z
Stream
Streams are useful for modelling relationships in your business logic. Think of them like excel formulas. A Cell in Excel can be either raw data or a formula that automatically updates the computed value when the underlying data changes.
Streams are incredibly useful for modelling user interfaces because it allows us to create reliable persistent relationships that abstract away the complexities of async event dispatches changing our underlying state.
attain
uses the mithril-stream
library which was inspired by the library stream library flyd
. attain
adds a few handy composable utils on top of the rather barebones mithril implementation.
of
Create a stream
import { stream } from 'attain'
const s = stream(1)
s()
s(2)
s()
map
map
allows us to create streams that respond to another streams data changing. Think of them like Excel formulas.
map
is the most important function streams offer.
import { stream as s } from 'attain'
const cell = s.of(1)
const formula =
s.map ( x => x * 2 ) (cell)
const formula2 =
s.map ( x => x * 3 ) (formula)
formula()
formula2()
cell(10)
formula()
formula2()
log
log
allows you to quickly log multiple streams, it takes advantage of object shorthand, internally subscribes to each stream and uses the key as the prefix in the log
s.log({ cell, formula, formula2 })
Which will log (from the previous example)
cell: 1
formula: 2
formula2: 6
cell: 10
formula: 20
formula2: 60
Note you can easily log streams yourself like so:
s.map ( x => console.log('cell:', x) ) (cell)
s.log
just does this for you, and for multiple streams at once.
merge
merge
is like map
but allows you to respond to a list of streams changing.
import { stream as s } from 'attain'
const a = s.of()
const b = s.of()
const c = s.of()
setTimeout(a, 1000, 'a')
setTimeout(b, 2000, 'b')
setTimeout(c, 3000, 'c')
setTimeout(a, 4000, 'A')
s.log({ combined: s.merge([a,b,c]) })
dropRepeats
dropRepeats
allows you to copy a stream and simultaneously remove duplicate values.
import { stream as s } from 'attain'
const a = s.of()
const b = s.dropRepeats (a)
s.log({ a, b })
a(1)
a(1)
a(2)
a(2)
a(3)
Logs:
a: 1
b: 1
a: 1
a: 2
b: 2
a: 2
a: 3
b: 3
interval
interval
creates a stream that emits the current time on an interval.
import { stream as s } from 'attain'
s.log({ now: s.interval(1000) })
Logs the time every 1000 ms:
now: 1583292884807
now: 1583292885807
now: 1583292886807
afterSilence
afterSilence
copies a stream but it will ignore multiple values emitted within a duration that you set.
import { stream as s } from 'attain'
const a = s.of()
const b = s.afterSilence (1000) (a)
setTimeout(a, 0, 'first')
setTimeout(a, 100, 'second')
setTimeout(a, 500, 'third')
setTimeout(a, 2000, 'fourth')
setTimeout(a, 2500, 'fifth')
setTimeout(a, 2501, 'sixth')
s.log({ b })
Logs:
b: 'third'
b: 'fourth'
b: 'sixth'
scan
scan
allows you to access the previously emitted value of a stream, and then decide on the next value by transforming it in a reducer function.
🤓 scan
can be used to create something like Redux or the Elm Architecture
import { stream as s } from 'attain'
const action = s.of()
const state = {
count: 0
}
const update = (state, action) => {
if( action == 'INC') {
return { count: state.count + 1 }
} else if ('DEC') {
return { count: state.count - 1 }
} else {
return state
}
}
const model = s.scan (state) (update) (action)
action('INC')
action('INC')
action('INC')
action('DEC')
s.log({ action, model })
Logs:
model: { count: 0 }
action: 'INC'
model: { count: 1 }
action: 'INC'
model: { count: 2 }
action: 'INC'
model { count: 3 }
action: 'DEC'
model: { count: 2 }
⚠ attain
has other stream utilities but leaves them undocumented for now as a sign until they've been used more in production. Explore the source at your own peril 🦜
Sum Types
attain
relies upon and provides a super powerful yet simple sum type API.
What is a Sum Type?
Sum Types are used to model when a single type has multiple shapes, and each shape has a semantic meaning. You've probably used Sum Types out in the wild without realising it. Here's a real world example: Promises.
A promise can be in 3 states: Pending
| Resolved
| Rejected
.
A Pending
Promise has no value, a Rejected
Promise has a value, but that value is intended to represent an Error
or failure. And a resolve promise has a value but is intended to represent a successful computation.
We can describe sum types with the following syntax:
data Promise = Pending | Resolved a | Rejected b
The above means, there's a type of data called Promise
, and it has 3 states, Pending
which has no value. Resolved
which has a value of type a
and Rejected which has a value of type b
.
The types a
and b
are kind of like <T>
and <U>
in typescript. It just means, those types are allowed to be different but can be the same.
Sometimes we call a sum type a tagged union because the type of the data is a union of all the listed states, and each state is like the data was tagged with a label.
How do I create my own Sum Type?
attain
comes with a utility tags
which is used to define new sum types:
import * as A from 'attain'
const Promise =
A.type('Promise', ['Pending', 'Resolved', 'Rejected'])
const pending = Promise.Pending()
const resolved = Promise.Resolved('Hello')
const rejected = Promise.Rejected(new Error('Oh no!'))
Promise.isRejected( rejected )
Promise.isResolved( rejected )
How do I traverse all possible states?
When you create a type, a lot of static functions are generated and attached to the type object.
The most basic and most important is fold
. It takes an object where each key must be exactly the tags specified in the definition of your type. Each value is a function that will receive the value of that specific tag.
const Promise =
A.type('Promise', ['Pending', 'Resolved', 'Rejected'])
const log =
Promise.fold({
Pending: () => null,
Resolved: data => console.log('attain', data),
Rejected: error => console.error('error', error)
})
log( Promise.Pending() )
log( Promise.Resolved('Hello') )
log( Promise.Rejected('Oh No!') )
How do I interact with a particular state?
If you want to transform the data inside a particular state you can use map<TagName>
. So if you wanted to map over only the Rejected
state you could use the mapRejected
function.
const Promise =
A.type('Promise', ['Pending', 'Resolved', 'Rejected'])
const logFailure =
Promise.mapRejected( err => console.error('Rejected', err) )
logFailure( Promise.Resolved() )
logFailure( Promise.Pending() )
logFailure( Promise.Rejected('Oh No') )
If you just want to get the value out, you can use get<TagName>Or
:
const Promise =
A.type('Promise', ['Pending', 'Resolved', 'Rejected'])
const gerError =
Promise.getRejectedOr('No Error')
getError( Promise.Resolved() )
getError( Project.Rejected('Oh No') )
If you want to transform the value before extracting it, you can use get<TagName>With
:
const Promise =
A.type('Promise', ['Pending', 'Resolved', 'Rejected'])
const getStack =
Promise.getRejectedWith('', err => err.stack )
getStack( Promise.Resolved() )
getError( Project.Rejected(new Error()) )
Either
attain
uses a sum type to represent success and failure: Either
data Either = Y a | N b
We use the tag Y
to represent success ( Y for Yes
) and the tag N
to represent failure ( N for No
).
It's the same as any other manually defined sum type via tags
, but it has a few extra powers because we can infer usage.
E.g. mapY
is aliased to map
. Either is also a bifunctor, which means, it has two states that can be mapped over.
Because we know that: Either
has functions available like Either.bifold
and Either.bimap
.
🤔 Extension to the base functionality of A.type
is via specs
. You can read about a standard way to extend functionality here
You'll see Either pop up as you use attain
, but just know, it's just another sum type with all the same functionality as our Promise examples.
How do I serialize / deserialize my sum type?
attain
's sum types were designed to be 100% serializable out of the box. Because we don't store methods on an instance, the sum types are just data, and there's no marshalling/unmarshalling to do!
Each sum-type instance has a special toString which helps with debugging, but beyond that, it's just an object that looks like this:
{ type: 'Promise', tag: 'Resolved', value: 'Hello' }
Here's an example.
const Promise =
A.type('Promise', ['Pending', 'Resolved', 'Rejected'])
Promise.isRejected(JSON.parse(JSON.stringify(rejected)))
const { type, tag, value } = rejected
Promise.isRejected(
JSON.parse(JSON.stringify({ type, tag, value }))
)
This means you can store your sum type data in LocalStorage
, IndexedDB
, in your server somewhere! It's up to you.
It's also such a simple data format that's it's easy to build custom utilities on top of it, but data has a bunch built in.
Z
Z
is the voltron, or captain planet, or power rangers... of attain
.
I recommend reading this a little later when you've internalised all the various core parts of attain
. Z
is a great example of the value of having an out of the box stream/lens/sum type library.
When you start building apps using attain
: Z
is unbeatable for model persistence.
So what is it?
Z
gives you a read/update/delete interface to an item in a stream of A. It then let's subscribe to changes/deletions. It's like queries you can subscribe to!
import { $, Z }, * as A from 'attain'
const todos = A.stream.of([
{ id: 1, title: 'Learn FP' }
])
const $id =
$
.$(R.map)
.$filter( x => x.id == 5 )
const firstTodo =
Z({ stream: todos }).$values.$filter( x => x.id == 5)
firstTodo()
A.stream.log({
'updated': firstTodo.$stream,
'throttled': firstTodo.$throttled(1000),
'removed': firstTodo.$removed
})
firstTodo.title( x => x.toUpperCase() )
todos()
firstTodo.title( x => x+'!' )
firstTodo.title( x => x+'!' )
firstTodo.title( x => x+'!' )
firstTodo.remove()
todos()
You can take advantage of the .throttled
and .deleted
streams for making server side changes.
firstTodo.$throttled.map(
({ id, title }) => sql`
update todos
set title = ${title}
where id = ${id}
`
)
firstTodo.$removed.map(
({ id, title }) => sql`
delete from todos where id = ${id}
`
)
Acknowledgements
Thank you to the mithril community for adopting streams and providing a readily available MIT implementation.
(More to come this project!)