// very bad
interface Alert {
readonly start: number,
readonly end: number,
readonly sduiTransform?: Delta
}
interface Transformation {
readonly start: number
readonly length: number
}
function rebaseAlert(alert: Alert, range: Transformation) {
if (alert.start > range.start && alert.end < range.start) {
alert.end += range.length
}
if (alert.sduiTransform) {
alert.sduiTransform = rebaseSduiTransform(alert, range)
}
const config = getDomainInfo()
getGnar().trackAlertUpdatedWithDomainInfo(alert.type)
alert.domain = config.domain
}
Why?
- too many things bottled together
- hard to maintain, hard to read
- hard to cover with units, as there are too many cases
- global side-effects
// better
const rebaseSDUITransform = (range: Transformation) => (alert: Alert) {
if (alert.sduiTransform) {
alert.sduiTransform = rebaseSduiTransform(alert, range)
}
return alert
}
const rebaseRange = (range: Transformation) => (alert: Alert) {
if (alert.start > range.start && alert.end < range.start) {
alert.end += range.length
}
return alert
}
const extendWithDomainInfo = (domain: string) => (alert: Alert) => {
alert.domain = domain
return alert
}
const rebaseAlert = compose(
rebaseRange(range),
rebaseSDUITransform(range),
extendWithDomainInfo(getDomainInfo().domain)
)
rebaseAlert(alert)
- easy to add new functions without modifying existing code
- easy to cover with tests
- easier to read, as each function express
Article: Practical Guide to Fp-ts P1: Pipe and Flow
compose(f, g)(x) = f(g(x))
function compose(f,g) {
return (x) => f(g(x))
}
// pipe invokes the sequence
const pipeResult = pipe(
alert,
alert => { /* do something with alert */ }
) // new alert
// flow - it's a compose
const flowResult = flow(
(alert: Alert): Alert => { /* do something with alert */ },
(alert: Alert): AlertId => alert.id
) // (alert: Alert) => AlertId
// bad
const rebaseRange = (range: Transformation) => (alert: Alert) {
if (alert.start > range.start && alert.end < range.start) {
alert.end += range.length
}
return alert
}
if (shouldRebase) {
rebaseRange(alert) // What is the goal of this function, what are side-effects?
}
// better?
if (shouldRebase) {
alert = rebaseRange(alert) // We just hide the mutation
}
// Test example. Will it work?
if ('should not change alert', () => {
expect(rebaseRange(alert, { start: 10, length: 12 }).toEqual(alert))
})
Why?
- unexpected results, especially when called async
- hard to read overall
- the units may be error-prone
// better
const rebaseRange = (range: Transformation) => (alert: Alert) {
const newAlert = deepMerge({}, alert)
if (alert.start > range.start && alert.end < range.start) {
alert.end += range.length
}
return alert
}
// bad
const extendWithDomainInfo = (alert: Alert) => {
const config = getDomainInfo()
getGnar().trackAlertUpdatedWithDomainInfo(alert.type)
alert.domain = config.domain
}
Why?
- unexpected side-effects that are not possible to control
- hard to test. need to mock global functions
// better
const extendWithDomainInfo = (domain: string, track: () => void) => (alert: Alert) => {
const config = getDomainInfo()
alert.domain = config.domain
}
extendsWithDomainInfo(
domain,
() => getGnar().trackAlertUpdatedWithDomainInfo(alert.type)
)(alert)
TL;DR move side-effects to the very top. Prefer pure functions on lower levels.
// bad
class Editor {
function changeText(replacement: Replacement): boolean {
if (!this.canChangeText()) {
this.getTracking().trackTextChange(TextChangeResult.cannotChangeText)
return
}
await this._performTextChange()
}
async function _performTextChange(replacement: Replacement): boolean {
if (!isValidReplacement(replacement)) {
// TODO: track validation fail
return false
}
const replacementResult = await doTextChange(replacement)
if (replacementResult) {
this.getTracking().trackTextChange(TextChangeResult.success)
return true
} else {
this.getTracking().trackTextChange(TextChangeResult.failedOnTextChange)
return false
}
}
}
Why?
- to modify tracking, you need to modify multiple methods
- easy to miss a fail case
- need to read the implementation of all methods to know how tracking work
- hard to test track, as there will be too many combinations that should be covered
// better
class Editor {
function changeText(replacement: Replacement): TextChangeResult {
const changeTextResult = await _changeText(replacement)
this.getTracking().trackTextChange(changeTextResult)
}
function _changeText(replacement: Replacement): Promise<TextChangeResult> {
if (!this.canChangeText()) {
return Promise.resolve(TextChangeResult.cannotChangeText)
} else {
return this._performTextChange()
}
}
async function _performTextChange(replacement: Replacement): TextChangeResult {
if (!isValidReplacement(replacement)) {
// The TS will require to return TextChangeResult type
return TextChangeResult.replacementIsInvalid
}
const replacementResult = await doTextChange(replacement)
if (replacementResult) {
return TextChangeResult.success
} else {
return TextChangeResult.failedOnTextChange
}
}
}
Why?
- All methods return the reason for the operation
- One place to process the results
- Types won't let you miss return time - e.g. every case will be tracked
- You need only one tests to validate that tracking works; all other will check the text change logic
// bad
const rebaseRange = (range: Transformation) => (alert: Alert): Alert | null {
if (alert.start > range.start && alert.end < range.start) {
return null
} else {
return alert
}
}
const rebaseAlert = compose(
rebaseRange(range),
rebaseSDUITransform(range), // alert | null
extendWithDomainInfo()
)
Why?
- need to update the code to handle null value in every single function
- undefined vs null?
rebaseRange = (range: Transformation) => (alert: Alert): Option<Alert> {
// ...
}
const rebaseAlert: (alert: Alert): Option<Alert> = flow(
rebaseRange(range),
O.map(rebaseSDUITransform(range))
O.map(extendWithDomainInfo())
)
// how to handel?
if (O.isEmpty(alert)) {
// throw new Error()
} else {
// render alert
}
// even better
pipe(
rebaseAlert(alert),
O.fold(
err => { /* handle error */ },
alert => { /* process alert */ }
)
)
5.1 What is Option
Article: Practical Guide to Fp-ts P2: Option, Map, Flatten, Chain
It's like an array; it may be empty or contain some values.
O.none = [] O.some = [1,2,3]
[1].map(value => value + 1) // [2]
pipe( O.some(1), O.map(value => value + 1) ) // Option<2>
[[1]].flatMap(value => [...value, 2]) // [1, 2]
pipe( O.some(1), O.flatMap(value => O.some([1, 2])) ) // Option<[1,2]>
Article: Either vs Validation
// bad
const rebaseRange = (range: Transformation) => (alert: Alert): Alert | { kind: 'error', reason: 'cannot resize alert' } {
if (alert.start > range.start && alert.end < range.start) {
return { kind: 'error', reason: 'cannot resize alert' }
} else {
return alert
}
}
const rebaseAlert: (alert: Alert): Option<Alert> = flow(
rebaseRange(range),
rebaseSDUITransform(range)
extendWithDomainInfo()
)
// good
const rebaseRange = (range: Transformation) => (alert: Alert): Alert | { kind: 'error', reason: 'cannot resize alert' } {
if (alert.start > range.start && alert.end < range.start) {
return E.left(new Error('cannot resize alert'))
} else {
return E.right(alert)
}
}
//
const rebaseAlert: (alert: Alert): Option<Alert> = flow(
rebaseRange(range),
E.map(rebaseSDUITransform(range))
E.map(extendWithDomainInfo())
)
// even better
pipe(
rebaseAlert(alert),
O.fold(
err => { /* handle error "cannot resize alert" */ },
alert => { /* process alert */ }
)
)
- Either is like Option, but "empty" usually stands for "error"
- Either has two states - left and right. Left is "error", right is "value"
map
works for "right" value (like in Option)
E.left <=> O.none E.right <=> O.some
pipe( O.some(1), O.map(value => value + 1) ) // Option<2>
pipe( E.right(1), E.map(value => value + 1) ) // Either<never, 2>
It's like a container for value that works according to strict rules
-
identity
-
associativity
Article: Practical Guide to Fp-ts P3: Task, Either, TaskEither
// not that bad
function fetchUserInfo(): Promise<UserInfo> {
// do request
}
fetchUserInfo.catch((error: any) => {
// trying to guess the kinds of error
})
// can be better
interface UserInfoFetchError {
kind: 'networkError' | 'userNotFoundError' | 'authError'
}
function fetchUserInfo(): TE.TaskEither<UserInfoFetchError, UserInfo> {
// do request
}
const result = pipe(
fetchUserInfo(),
TE.map(
r => { /* process result */ }
)
)()
result // Promise<Either<UserInfoFetchError, UserInfo>>