Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow abstract Fx interfaces to omit their dependencies #99

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions examples/echo-console.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { EOL } from 'os'
import { createInterface } from 'readline'

import { Async, async, defaultEnv, doFx, Fx, runFx, sync, Sync } from '../src'
import { async, defaultEnv, doFx, Fx, FxInterface, runFx, sync, Sync, use } from '../src'

type Print = { print(s: string): Fx<Sync, void> }
type Print = { print(s: string): FxInterface<void> }

type Read = { read: Fx<Async, string> }
type Read = { read: FxInterface<string> }

const main = doFx(function* ({ print, read }: Print & Read) {
while (true) {
Expand All @@ -31,4 +31,6 @@ const capabilities = {
})
}

runFx(main, capabilities)
const m = use(main, capabilities)

runFx(m)
88 changes: 50 additions & 38 deletions examples/fp-to-the-max-1.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,41 @@
import { EOL } from 'os'
import { createInterface } from 'readline'

import { Async, async, attempt, defaultEnv, doFx, Fx, runFx, Sync, sync, timeout } from '../src'
import {
async, attempt, defaultEnv, doFx, Fx, FxInterface, get, runFx, Sync, sync, timeout, use
} from '../src'

// -------------------------------------------------------------------
// The number guessing game example from
// https://www.youtube.com/watch?v=sxudIMiOo68

// -------------------------------------------------------------------
// Capabilities the game will need
// Game domain model and interfaces. The game needs to be able
// to generate a secret within some bounds. The user will try to
// guess the secret, and the game needs to be able to check whether
// the user's guess matches the secret

type Print = { print(s: string): Fx<Sync, void> }

type Read = { read: Fx<Async, string> }

type RandomInt = { randomInt(min: number, max: number): Fx<Sync, number> }

// -------------------------------------------------------------------
// Basic operations that use the capabilites

const println = (s: string) => doFx(function* ({ print }: Print) {
return yield* print(`${s}${EOL}`)
})

const ask = (prompt: string) => doFx(function* ({ print, read }: Print & Read) {
yield* print(prompt)
return yield* read
})

const randomInt = (min: number, max: number) => doFx(function* ({ randomInt }: RandomInt) {
return yield* randomInt(min, max)
})

// -------------------------------------------------------------------
// The game

// Min/max range for the number guessing game
type GameConfig = {
type Game = {
min: number,
max: number
}

// Generate a secret
type GenerateSecret = { generateSecret(c: Game): FxInterface<number> }

// Core "business logic": evaluate the user's guess
const checkAnswer = (secret: number, guess: number): boolean =>
secret === guess

// -------------------------------------------------------------------
// The game

// Play one round of the game. Generate a number and ask the user
// to guess it.
const play = (name: string, min: number, max: number) => doFx(function* () {
const secret = yield* randomInt(min, max)
const play = (name: string, config: Game) => doFx(function* ({ generateSecret }: GenerateSecret) {
const secret = yield* generateSecret(config)
const result =
yield* attempt(timeout(3000, ask(`Dear ${name}, please guess a number from ${min} to ${max}: `)))
yield* attempt(timeout(3000, ask(`Dear ${name}, please guess a number from ${config.min} to ${config.max}: `)))

if (typeof result === 'string') {
const guess = Number(result)
Expand Down Expand Up @@ -79,17 +64,41 @@ const checkContinue = (name: string) => doFx(function* () {
})

// Main game loop. Play round after round until the user chooses to quit
const main = doFx(function* ({ min, max }: GameConfig) {
const main = doFx(function* () {
const name = yield* ask('What is your name? ')
yield* println(`Hello, ${name} welcome to the game!`)

const config = yield* get<Game>()

do {
yield* play(name, min, max)
yield* play(name, config)
} while (yield* checkContinue(name))

yield* println(`Thanks for playing, ${name}.`)
})

// -------------------------------------------------------------------
// Infrastructure capabilities the game needs to interact with
// the user, generate a secret, etc.

type Print = { print(s: string): FxInterface<void> }

type Read = { read: FxInterface<string> }

type Random = { random: FxInterface<number> }

// -------------------------------------------------------------------
// Basic operations that use the capabilites

const println = (s: string) => doFx(function* ({ print }: Print) {
return yield* print(`${s}${EOL}`)
})

const ask = (prompt: string) => doFx(function* ({ print, read }: Print & Read) {
yield* print(prompt)
return yield* read
})

// -------------------------------------------------------------------
// Implementations of all the capabilities the game needs.
// The type system will prevent running the game until implementations
Expand All @@ -98,7 +107,9 @@ const capabilities = {
min: 1,
max: 5,

...defaultEnv,
generateSecret: ({ min, max }: Game) => doFx(function* ({ random }: Random) {
return Math.floor(min + (yield* random) * max)
}),

print: (s: string): Fx<Sync, void> =>
sync(() => void process.stdout.write(s)),
Expand All @@ -112,8 +123,9 @@ const capabilities = {
return () => readline.removeListener('line', k).close()
}),

randomInt: (min: number, max: number): Fx<Sync, number> =>
sync(() => Math.floor(min + (Math.random() * (max - min))))
random: sync(Math.random),

...defaultEnv,
}

runFx(main, capabilities)
runFx(use(main, capabilities))
7 changes: 4 additions & 3 deletions examples/lambda-pets/cli.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { attempt, runFx, uncancelable } from '../../src'
import { runFx, uncancelable, use } from '../../src'
import { env } from './env'
import { tryGetAdoptablePetsNear } from './src/application/pets'

// Command line entry point of pets app

const ipAddress: string = process.argv[process.argv.length - 1]

const fx = attempt(tryGetAdoptablePetsNear(ipAddress))
runFx(fx, env, a => (console.log(a), uncancelable))
const fx = tryGetAdoptablePetsNear(ipAddress)
const t = use(fx, env)
runFx(t, a => (console.log(a), uncancelable))
6 changes: 5 additions & 1 deletion examples/lambda-pets/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,9 @@ export const env = {

getLocation,

getPets
getPets,

fail: (e: Error) => {
throw e
}
}
4 changes: 2 additions & 2 deletions examples/lambda-pets/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import 'source-map-support/register'

import { APIGatewayProxyHandler, APIGatewayProxyResult } from 'aws-lambda'

import { runFx, uncancelable } from '../../src'
import { runFx, uncancelable, use } from '../../src'
import { env } from './env'
import { getAdoptablePetsNear } from './src/application/pets'

Expand All @@ -11,5 +11,5 @@ import { getAdoptablePetsNear } from './src/application/pets'
export const handler: APIGatewayProxyHandler = event =>
new Promise<APIGatewayProxyResult>(resolve => {
const fx = getAdoptablePetsNear(event.requestContext.identity.sourceIp)
runFx(fx, env, a => (resolve(a), uncancelable))
runFx(use(fx, env), a => (resolve(a), uncancelable))
})
15 changes: 6 additions & 9 deletions examples/lambda-pets/src/application/pets.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { attempt, catchAll, Delay, doFx, Fx, pure, Sync, timeout } from '../../../../src'
import { AdoptablePets, defaultLocation } from '../domain/model'
import { HttpEnv } from '../infrastructure/http'
import { getLocation, IpStackConfig } from '../infrastructure/ipstack'
import { getPets, PetfinderConfig } from '../infrastructure/petfinder'
import { attempt, catchAll, doFx, Fail, Fx, FxInterface, pure, timeout } from '../../../../src'
import { AdoptablePets, defaultLocation, GetLocation, GetPets } from '../domain/model'
import { renderError, renderPets } from './render'

export type PetsEnv = {
getPets: typeof getPets,
getLocation: typeof getLocation
log: (s: string) => Fx<Sync, void>
getPets: GetPets,
getLocation: GetLocation
log: (s: string) => FxInterface<void>

radiusMiles: number,
locationTimeout: number,
Expand All @@ -24,7 +21,7 @@ export const getAdoptablePetsNear = (ip: string) => doFx(function* () {
: { statusCode: 200, body: renderPets(petsOrError), headers: HEADERS }
})

export const tryGetAdoptablePetsNear = (ip: string): Fx<PetsEnv & HttpEnv & Delay & Sync & IpStackConfig & PetfinderConfig, AdoptablePets> => doFx(function* ({ radiusMiles, locationTimeout, petsTimeout, log, getLocation, getPets }) {
export const tryGetAdoptablePetsNear = (ip: string): Fx<Fail & PetsEnv, AdoptablePets> => doFx(function* ({ radiusMiles, locationTimeout, petsTimeout, log, getLocation, getPets }: PetsEnv) {
const location = yield* catchAll(timeout(locationTimeout, getLocation(ip)), () => pure(defaultLocation))

yield* log(`Geo location for ${ip}: ${location.latitude} ${location.longitude}`)
Expand Down
6 changes: 3 additions & 3 deletions examples/lambda-pets/src/domain/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fx } from '../../../../src'
import { FxInterface } from '../../../../src'

// Domain model

Expand Down Expand Up @@ -34,6 +34,6 @@ export const defaultLocation: Location = {
// Domain model access interfaces.
// These are implemented by infrastructure

export type GetPets<Effects> = (l: GeoLocation, radiusMiles: number) => Fx<Effects, Pets>
export type GetPets = (l: GeoLocation, radiusMiles: number) => FxInterface<Pets>

export type GetLocation<Effects> = (host: string) => Fx<Effects, Location>
export type GetLocation = (host: string) => FxInterface<Location>
14 changes: 7 additions & 7 deletions examples/lambda-pets/src/infrastructure/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { IncomingMessage, request } from 'http'
import { request as httpsRequest } from 'https'
import { parse as parseUrl } from 'url'

import { Async, doFx, fail, Fail, Fx, pure, tryAsync } from '../../../../src'
import { Async, doFx, fail, Fail, Fx, FxInterface, pure, tryAsync } from '../../../../src'

// Abstract Http capability interface
export type Http<Effects, Req, Res> = { http(r: Req): Fx<Effects, Res> }
export type Http<Req, Res> = { http(r: Req): FxInterface<Res> }

// Concrete Http implementation for Node
// Normally this would be separate from the abstract capability interface
Expand All @@ -17,15 +17,15 @@ type Req = { url: string, headers: { [name: string]: string } }

type Response = [IncomingMessage, string]

export type HttpEnv = Http<Async & Fail, Request, Response> & Async & Fail
export type HttpEnv = Http<Request, Response>

export const getJson = <R>(url: string, headers: { [name: string]: string } = {}): Fx<HttpEnv, R> =>
export const getJson = <R>(url: string, headers: { [name: string]: string } = {}): Fx<HttpEnv & Fail, R> =>
doFx(function* ({ http }: HttpEnv) {
const result = yield* http({ method: 'GET', url, headers })
return yield* interpretResponse<R>(url, result)
})

export const postJson = <A, R>(url: string, a: A, headers: { [name: string]: string } = {}): Fx<HttpEnv, R> =>
export const postJson = <A, R>(url: string, a: A, headers: { [name: string]: string } = {}): Fx<HttpEnv & Fail, R> =>
doFx(function* ({ http }: HttpEnv) {
const result = yield* http({ method: 'POST', url, headers, body: JSON.stringify(a) })
return yield* interpretResponse<R>(url, result)
Expand All @@ -36,8 +36,8 @@ const interpretResponse = <R>(url: string, [response, body]: Response): Fx<Fail,
? pure(JSON.parse(body) as R)
: fail(new Error(`Request failed ${response.statusCode}: ${url} ${body}`))

export const httpEnv: Http<Async & Fail, Request, Response> = {
http: (r: Request) => tryAsync(k => {
export const httpEnv = {
http: (r: Request): Fx<Async & Fail, Response> => tryAsync(k => {
const options = { method: r.method, ...parseUrl(r.url), headers: r.headers }
const req = options.protocol === 'https:' ? httpsRequest(options) : request(options)

Expand Down
6 changes: 3 additions & 3 deletions examples/lambda-pets/src/infrastructure/ipstack.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { doFx, fail } from '../../../../src'
import { GetLocation, Location } from '../domain/model'
import { getJson, HttpEnv } from './http'
import { Location } from '../domain/model'
import { getJson } from './http'

// ipstack types and API
// Note that getLocation is a concrete implementation of
Expand All @@ -11,7 +11,7 @@ export type IpStackConfig = {
ipstackKey: string
}

export const getLocation: GetLocation<HttpEnv & IpStackConfig> = (host: string) => doFx(function* ({ ipstackKey }: IpStackConfig) {
export const getLocation = (host: string) => doFx(function* ({ ipstackKey }: IpStackConfig) {
const location = yield* getJson<Partial<Location>>(`http://api.ipstack.com/${host}?hostname=1&access_key=${ipstackKey}`)
if (location.latitude == null || location.longitude == null) return yield* fail(new Error('Invalid location'))
return location as Location
Expand Down
8 changes: 4 additions & 4 deletions examples/lambda-pets/src/infrastructure/petfinder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { doFx, Fx } from '../../../../src'
import { GeoLocation, GetPets, Pets } from '../domain/model'
import { getJson, HttpEnv, postJson } from './http'
import { doFx } from '../../../../src'
import { GeoLocation } from '../domain/model'
import { getJson, postJson } from './http'

// Petfinder types and APIs
// Note that getPets is a concrete implementation of
Expand Down Expand Up @@ -35,7 +35,7 @@ export type PetfinderConfig = {
petfinderAuth: PetfinderAuth
}

export const getPets: GetPets<HttpEnv & PetfinderConfig> = (l: GeoLocation, radiusMiles: number): Fx<HttpEnv & PetfinderConfig, Pets> => doFx(function* ({ petfinderAuth }: PetfinderConfig) {
export const getPets = (l: GeoLocation, radiusMiles: number) => doFx(function* ({ petfinderAuth }: PetfinderConfig) {
const token = yield* postJson<PetfinderAuth, PetfinderToken>('https://api.petfinder.com/v2/oauth2/token', petfinderAuth)

const { animals } = yield* getJson<PetfinderPets>(`https://api.petfinder.com/v2/animals?location=${l.latitude},${l.longitude}&distance=${Math.ceil(radiusMiles)}`, {
Expand Down
4 changes: 2 additions & 2 deletions src/array.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Intersect, resume, uncancelable } from './env'
import { Effects, Fx, op, Return, runFx } from './fx'
import { Effects, Fx, op, Return, unsafeRunFxWith } from './fx'

export type AllEffects<Fxs extends readonly Fx<any, any>[]> = Intersect<Effects<Fxs[number]>>

Expand All @@ -16,7 +16,7 @@ export const zip = <Fxs extends readonly Fx<any, any>[]>(...fxs: Fxs): Fx<AllEff
const results = Array(remaining) as Writeable<ZipResults<Fxs>>

const cancels = fxs.map((fx: Fxs[typeof i], i) =>
runFx(fx, c, (x: AnyResult<Fxs>) => {
unsafeRunFxWith(fx, c, (x: AnyResult<Fxs>) => {
results[i] = x
return --remaining === 0 ? k(results) : uncancelable
}))
Expand Down
8 changes: 4 additions & 4 deletions src/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { AllEffects, AnyResult } from './array'
import { Cancel, Resume, resume } from './env'
import { fail, Fail } from './fail'
import { doFx, Fx, get, op, runFx } from './fx'
import { doFx, Fx, get, op, unsafeRunFxWith } from './fx'

export type AsyncTask<A> = (k: (a: A) => Cancel) => Cancel

Expand Down Expand Up @@ -30,7 +30,7 @@ export const delay = (ms: number): Fx<Delay & Async, void> => doFx(function* ()
return yield* delay(ms)
})

export const timeout = <C extends Async, A>(ms: number, fx: Fx<C, A>): Fx<C & Async & Delay & Fail, A> =>
export const timeout = <C, A>(ms: number, fx: Fx<C, A>): Fx<C & Async & Delay & Fail, A> =>
race(fx, delayFail(ms))

const delayFail = (ms: number): Fx<Delay & Async & Fail, never> => doFx(function* () {
Expand All @@ -39,13 +39,13 @@ const delayFail = (ms: number): Fx<Delay & Async & Fail, never> => doFx(function
})

// Return computation equivalent to the input computation that produces the earliest result
export const race = <C1 extends Async, C2 extends Async, A, B, Fxs extends readonly Fx<any, any>[]>(fx1: Fx<C1, A>, fx2: Fx<C2, B>, ...fxs: Fxs): Fx<C1 & C2 & AllEffects<Fxs>, A | B | AnyResult<Fxs>> =>
export const race = <C1, C2, A, B, Fxs extends readonly Fx<any, any>[]>(fx1: Fx<C1, A>, fx2: Fx<C2, B>, ...fxs: Fxs): Fx<C1 & C2 & AllEffects<Fxs>, A | B | AnyResult<Fxs>> =>
raceArray([fx1, fx2, ...fxs])

const raceArray = <Fxs extends readonly Fx<any, any>[]>(fxs: Fxs): Fx<AllEffects<Fxs>, AnyResult<Fxs>> =>
op(c => resume(k => {
const cancels = fxs.map((fx: Fxs[number]) =>
runFx(fx, c, (x: AnyResult<Fxs>) => {
unsafeRunFxWith(fx, c, (x: AnyResult<Fxs>) => {
cancelAll()
return k(x)
}))
Expand Down
Loading