-
Notifications
You must be signed in to change notification settings - Fork 31
/
001-basic.ts
296 lines (266 loc) · 10.1 KB
/
001-basic.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
import { Effect, Either, Layer, Context, pipe } from "effect";
/*
* The unique insight of Effect is that errors and requirements/dependencies
* should be modeled in your program's control flow.
*
* This is in contrast to your typical TypeScript code, where a function can
* either return a "success" value or throw an untyped exception.
*
* The data type of Effect looks like the following:
*
* Effect<A, E, R>
*
* The computation can succeed (A), fail (E), and can have requirements (R)
*
* You can loosely think of Effect<A, E, R> as the following type:
*
* (r: R) => Promise<Either<E, A>> | Either<E, A>
*
* Similarly to a function, when you define an Effect nothing happens.
* It's just a value representing some code.
*
* This is different from i.e. Promises which are started immediately as they
* are defined.
*
* When you do decide to run an Effect (for example using Effect.runPromise),
* it will either:
*
* - run successfully and return a value of type A
* - fail and throw an error of type E
*
* The R geniric type will be covered in detail in future chapters.
* It's meant to provide some form of dependency injection that you will love.
* (Trust me it's not the enterprisey stuff you expect from Java)
*
* Effect is inspired by ZIO (a Scala library)
*/
/*
* Notes while going through the rest of this crash course:
* 1. Effect has excellent type inference. You rarely need to specify types manually.
* 2. There are explicit type annotations in several parts of this crash course
* to make it easier for you to follow.
*/
/* Basic constructors
* ==================
*
* The point of these functions is to demonstrate a couple basic ways to
* create an "Effect" value.
*
* Notice how the types change based on the function you call.
* */
/*
* succeed creates an Effect value that includes it's argument in the
* success channel (A in Effect<A, E, R>)
*/
export const succeed = Effect.succeed(7);
// ^ Effect.Effect<number, never, never>;
/*
* fail creates an Effect value that includes it's argument in the
* failure channel (E in Effect<A, E, R>)
*/
export const fail = Effect.fail(3);
// ^ Effect.Effect<never, never, number>;
/*
* sync can be thought as a lazy alternative to succeed.
* A is built lazily only when the Effect is run.
*/
export const sync = Effect.sync(() => new Date());
// ^ Effect.Effect<Date, never, never>;
/*
* NOTE: if we used Effect.succeed(new Date()), the date stored in the success
* channel would be the one when the javascript virtual machine initially
* loads and executes our code.
*
* For values that do not change like a number, it doesn't make any difference.
*/
/*
* failSync can be thought as a lazy alternative to fail.
* E is built lazily only when the Effect is run.
*/
export const failSync = Effect.failSync(() => new Date());
// ^ Effect.Effect<never, Date, never>;
/* suspend allows to lazily build an Effect value.
*
* While sync builds A lazily, and failSync builds E lazily, suspend builds
* the whole Effect<A, E, R> lazily!
*/
export const suspend =
// ^ Effect.Effect<Date, '<.5', never>;
Effect.suspend(() =>
Math.random() > 0.5
? Effect.succeed(new Date())
: Effect.fail("<.5" as const),
);
/*
* Some basic control flow
* =======================
*
* The following is an example of a computation that can fail. We will look at
* more error handling in a later chapter.
*/
function eitherFromRandom(random: number): Either.Either<number, "fail"> {
return random > 0.5 ? Either.right(random) : Either.left("fail" as const);
}
// This will fail sometimes
export const flakyEffect = pipe(
Effect.sync(() => Math.random()), // Effect.Effect<number, never, never>
Effect.flatMap(eitherFromRandom), // Effect.Effect<number, 'fail', never>
);
// Same thing but using the number generator provided by Effect
export const flakyEffectRandom = pipe(
Effect.random, // Effect.Effect<Random, never, never>
Effect.flatMap(random => random.next), // Effect.Effect<number, never, never>
Effect.flatMap(eitherFromRandom), // Effect.Effect<number, 'fail', never>
);
/* NOTE about Effect.flatMap(eitherFromRandom)
*
* Through some black magic, Either and Option are defined as sub types of the
* Effect type. That means every function in the Effect module can also accept
* Either or Option and tread them accordingly.
*
* Effect.flatMap(() => Either.right(1)) will turn into an Effect.succeed(1)
* Effect.flatMap(() => Either.left(2)) will turn into an Effect.fail(2)
*/
/* Up to this point we only constructed Effect values, none of the computations
* that we defined have been executed. Effects are just objects that
* wrap your computations as they are, for example `pipe(a, flatMap(f))` is
* represented as `new FlatMap(a, f)`.
*
* This allows us to modify computations until we are happy with what they
* do (using map, flatMap, etc), and then execute them.
* Think of it as defining a workflow, and then running it only when you are ready.
*/
Effect.runPromise(flakyEffectRandom); // executes flakyEffectRandom
/* As an alternative, instead of using eitherFromRandom and dealing with an
* Either that we later lift into an Effect, we can write that conditional
* Effect directly.
*
* Both are valid alternatives and the choice on which to use comes down to
* preference.
*
* By using Option/Either and lifting to Effect only when necessary you can
* keep large portions of code side effect free, stricly syncronous, and not
* require the Effect runtime to run.
*
* Using Effect directly you lose some purity but gain in convenience.
* It may be warranted if you are using the dependency injection features a
* lot (especially in non library code).
*/
// This is an Effect native implementation of eitherFromRandom defined above
function effectFromRandom(random: number) {
return random > 0.5 ? Effect.succeed(random) : Effect.fail("fail" as const);
}
export const flakyEffectNative = pipe(
Effect.random, // Effect.Effect<never, never, Random>
Effect.flatMap(random => random.next), // Effect.Effect<number, never, never>
Effect.flatMap(effectFromRandom), // Effect.Effect<number, 'fail', never>
);
/* Context
* =======
*
* Up until now we only dealt with Effects that have no dependencies.
*
* The R in Effect<A, E, R> has always been never, meaning that that the
* Effects we've defined don't depend on anything.
*
* Suppose we want to implement our own custom random generator, and use it in
* our code as a dependency, similar to how we used the one provided by Effect
* (the Effect.random() above)
*/
class CustomRandom extends Context.Tag("CustomRandom")<
CustomRandom,
{ readonly next: Effect.Effect<number> }
>() {}
/* To provide us with dependency injection features, Effect uses a data
* structure called Context. It is a table mapping Tags to their
* implementation (called Service).
*
* Think of it as the following type: Map<Tag, Service>.
*
* An interesting property of Tag is it is a subtype of Effect, so you can for
* example map and flatMap over it to get to the service.
*
* In our case we can do something like:
*
* Effect.map(CustomRandom, (service) => ...)
*
* Doing so will introduce a dependency on CustomRandom in our code.
* That will be reflected in the Effect<A, E, R> datatype, where the
* requirements channel (R) will become of type CustomRandom.
*/
export const serviceExample = pipe(
CustomRandom, // Context.Tag<CustomRandom, CustomRandom>
Effect.flatMap(random => random.next), // Effect.Effect<CustomRandom, never, number>
Effect.flatMap(effectFromRandom), // Effect.Effect<CustomRandom, 'fail', number>
);
/*
* Notice how R above is now CustomRandom, meaning that our Effect depends on it.
* However CustomRandom is just an interface and we haven't provided an
* implementation for it... yet.
*
* How to do that?
*
* Taking a step back and trying to compile the following:
*
* Effect.runPromise(serviceExample);
*
* Would lead to the following type error:
*
* Argument of type 'Effect<number, "fail", CustomRandom>' is not assignable
* to parameter of type 'Effect<number, "fail", never>'.
* Type 'CustomRandom' is not assignable to type 'never'.
*
* To run an Effect we need it to have no missing dependencies, in other
* words R must be never.
*
* By providing an implementation, we turn the R in Effect<A, E, R> into a
* `never`, so we end up with a Effect<A, E, never> which we can run.
*
*/
const CustomRandomServiceLive = {
// Note: in Effect jargon a Service is the implementation for a required Tag
next: Effect.sync(() => Math.random()),
};
// Providing an implementation with provideService
// (handy for Effects that depend on a single service)
export const provideServiceExample = serviceExample.pipe(
Effect.provideService(CustomRandom, CustomRandomServiceLive),
);
// Providing an implementation with provideContext
// (handy for Effects that depend on multiple services)
const context = pipe(
Context.empty(),
Context.add(CustomRandom, CustomRandomServiceLive),
// Context.add(Foo)({ foo: 'foo' })
);
export const provideContextExample = pipe(
serviceExample, // Effect.Effect<number, 'fail', CustomRandom>
Effect.provide(context), // Effect.Effect<number, 'fail', never>
);
// Providing an implementation with Layer
// (handy for real world systems with complex dependency trees)
// (will go more in depth about layers in a future chapter)
export const liveProgram = pipe(
serviceExample,
Effect.provide(Layer.succeed(CustomRandom, CustomRandomServiceLive)),
);
/*
* The powerful part of Effect is you can have multiple implementations for
* the services you depend on.
*
* This can be useful for i.e. mocking:
*
* For example, you can use a mocked implementation of CustomRandom in your
* tests, and a real one in production.
*
* You can define these implementations without having to change any of the
* core logic of your program. Notice how serviceExample doesn't change, but
* the implementation of CustomRandom can be changed later.
*/
const CustomRandomServiceTest = {
next: Effect.succeed(0.3),
};
export const testProgram = pipe(
serviceExample,
Effect.provideService(CustomRandom, CustomRandomServiceTest),
);