Skip to content

Instantly share code, notes, and snippets.

@oleksiilevzhynskyi
Last active July 30, 2023 08:58
Show Gist options
  • Save oleksiilevzhynskyi/c6b5e42f43ad0a615ec55a457aba6c39 to your computer and use it in GitHub Desktop.
Save oleksiilevzhynskyi/c6b5e42f43ad0a615ec55a457aba6c39 to your computer and use it in GitHub Desktop.
FP practices

FP practices

1. Composition

// 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

1.1 What is compose (pipe and flow)

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

2. Immutability

// 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
}

3. Pure functions

// 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)

4. Side-effects

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

5. Option

// 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]

Map

[1].map(value => value + 1) // [2]

pipe( O.some(1), O.map(value => value + 1) ) // Option<2>

FlatMap

[[1]].flatMap(value => [...value, 2]) // [1, 2]

pipe( O.some(1), O.flatMap(value => O.some([1, 2])) ) // Option<[1,2]>

5. Either

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 */ }
    )
)

5.1 What is Either

  • 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>

6. So, what's is monad?

It's like a container for value that works according to strict rules

7. Bonus part. What's wrong with Promise

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>>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment