From a25a8dac7a3124cb054a1a064a472cb736b85bda Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 31 Jan 2025 09:55:14 +0000 Subject: [PATCH 01/28] Add Michael's feedback that my use of pipe was wrong. --- content/src/content/docs/learn/tutorials/introduction.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index 888b1e2c5..ce77c6fb3 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -254,8 +254,8 @@ HttpRouter.get("/health", Effect.sync(health))( // vs HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)), - HttpRouter.get("/status", Effect.sync(status)), HttpRouter.get("/version", Effect.sync(version)), + HttpRouter.get("/status", Effect.sync(status)), + HttpRouter.get("/health", Effect.sync(health)), ) ``` From 4af077edf2e8c660948319fe40ca9a958e48fe2b Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 31 Jan 2025 10:04:50 +0000 Subject: [PATCH 02/28] Twoslashify tutorial introduction. --- .../docs/learn/tutorials/introduction.mdx | 76 +++++++++++++++---- 1 file changed, 63 insertions(+), 13 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index ce77c6fb3..0aabb570f 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -57,7 +57,7 @@ with Effect instead of the traditional `app.listen`. Because the Express.js `app` is a function that returns a node `http.Server` under the hood, it slots nicely into Effect's `HttpServer` abstraction. -```typescript +```ts twoslash import express from "express" // New imports @@ -104,13 +104,17 @@ above. First, let's break out our router, `HttpRouter.empty`, out into its own variable so it's easier to add to. -```typescript +```ts twoslash +import { HttpRouter } from "@effect/platform" +// ---cut--- const router = HttpRouter.empty ``` And let's define the function we want to run when we hit the `/health` endpoint: -```typescript +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +// ---cut--- function health() { return HttpServerResponse.text("ok") } @@ -122,7 +126,10 @@ use it for any responses returned from our endpoints defined with Effect. The way we wire these things together is going to look a bit strange, but bear with me. -```typescript +```ts twoslash +import { HttpServerResponse, HttpRouter } from "@effect/platform" +import { Effect } from "effect" +// ---cut--- function health() { return HttpServerResponse.text("ok") } @@ -142,7 +149,9 @@ until you run it. Here are some examples of simple `Effect`s: -```typescript +```ts twoslash +import { Effect } from "effect" +// ---cut--- const sync = Effect.sync(() => "Hello, world!") const promise = Effect.promise(async () => "Hello, world!") const succeed = Effect.succeed("Hello, world!") @@ -152,7 +161,12 @@ Above we have examples of synchronous, asynchronous, and static Effects. None of them will do anything as defined there, they need to be run. There are several ways to run Effects. -```typescript +```ts twoslash +import { Effect } from "effect" +const sync = Effect.sync(() => "Hello, world!") +const promise = Effect.promise(async () => "Hello, world!") +const succeed = Effect.succeed("Hello, world!") +// ---cut--- const resultSync = Effect.runSync(sync) const resultPromise = await Effect.runPromise(promise); const resultSucceed = Effect.runSync(succeed) @@ -172,7 +186,10 @@ respectively. What if you want to run an Effect that calls another Effect? You can use `Effect.gen` to do that: -```typescript +```ts twoslash +import { Effect } from "effect" +const promise = Effect.promise(async () => "Hello, world!") +// ---cut--- const gen = Effect.gen(function* () { const result = yield* promise return result.toUpperCase() @@ -187,7 +204,10 @@ Given that all we really want to do above is call `.toUpperCase()` on the result of `promise`, the scaffolding of `Effect.gen` feels a bit heavy. Effect gives us plenty of tools to work with `Effect`s, one of which is `pipe`: -```typescript +```ts twoslash +import { Effect } from "effect" +const promise = Effect.promise(async () => "Hello, world!") +// ---cut--- const upper = promise.pipe(Effect.map((s) => s.toUpperCase())) const result = await Effect.runPromise(upper) console.log(result) @@ -200,7 +220,10 @@ provided. We can `pipe` as many `Effect`s together as we like: -```typescript +```ts twoslash +import { Effect } from "effect" +const promise = Effect.promise(async () => "Hello, world!") +// ---cut--- const upper = promise.pipe( Effect.map((s) => s.toUpperCase()), Effect.map((s) => s.split("")), @@ -216,7 +239,13 @@ console.log(result) Looking back at `HttpRouter`, we used `pipe` to add a new endpoint to our app: -```typescript +```ts twoslash +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +function health() { + return HttpServerResponse.text("ok") +} +// ---cut--- const router = HttpRouter.empty.pipe( HttpRouter.get("/health", Effect.sync(health)), ); @@ -226,7 +255,10 @@ const router = HttpRouter.empty.pipe( simplest router is one with no routes at all, and Effect exposes that to us as the `HttpRouter.empty` value. Under the hood, this is itself an `Effect`: -```typescript +```ts twoslash +import { HttpRouter } from "@effect/platform" +import { Effect } from "effect" +// ---cut--- console.log(Effect.isEffect(HttpRouter.empty)) // true ``` @@ -235,14 +267,32 @@ The helper `HttpRouter.get` takes as input an `HttpRouter` and returns a new `HttpRouter` with the given route added. If we wanted to, we could have done this much more directly: -```typescript +```ts twoslash +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +function health() { + return HttpServerResponse.text("ok") +} +// ---cut--- const router = HttpRouter.get("/health", Effect.sync(health))(HttpRouter.empty) ``` This is exactly the same as the `pipe` version, except that if we wanted to add multiple routes it gets unwieldy quickly: -```typescript +```ts twoslash +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +function health() { + return HttpServerResponse.text("ok") +} +function status() { + return HttpServerResponse.text("ok") +} +function version() { + return HttpServerResponse.text("ok") +} +// ---cut--- HttpRouter.get("/health", Effect.sync(health))( HttpRouter.get("/status", Effect.sync(status))( HttpRouter.get("/version", Effect.sync(version))( From cc97fea0db6ce911af47ad453c692cdc86412480 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 31 Jan 2025 11:09:01 +0000 Subject: [PATCH 03/28] Did a pass on the existing material to try and simplify it. --- .../docs/learn/tutorials/introduction.mdx | 101 +++++++++--------- 1 file changed, 48 insertions(+), 53 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index 0aabb570f..18c878bdf 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -57,10 +57,9 @@ with Effect instead of the traditional `app.listen`. Because the Express.js `app` is a function that returns a node `http.Server` under the hood, it slots nicely into Effect's `HttpServer` abstraction. -```ts twoslash +```ts twoslash {3-6, 14-21} import express from "express" -// New imports import { HttpRouter, HttpServer } from "@effect/platform" import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" import { Layer } from "effect" @@ -72,7 +71,6 @@ app.get("/health", (req, res) => { res.type("text/plain").send("ok") }) -// New server runner NodeRuntime.runMain( Layer.launch( Layer.provide( @@ -93,7 +91,7 @@ bun run index.ts We haven't changed anything about how the server behaves. It still exposes a single endpoint, `http://localhost:3000/health`, and that still returns `ok`. But we've wrapped the Express.js app in a way that will allow us to define new -endpoints using Express while still responding to our existing endpoints in +endpoints using Effect while still responding to our existing endpoints in Express.js. There's a lot of new things happening at the bottom of our file. For the time @@ -143,75 +141,65 @@ What on earth are `Effect.sync` and `.pipe`? ## The Effect Type -At the core of Effect is... well, the `Effect`. An `Effect` is what's called a -"thunk", which you can think of as a lazy computation. It doesn't do anything -until you run it. +At the core of Effect is... well, the `Effect`. You can think of an `Effect` as +a lazy computation, similar to a function that hasn't been called yet. -Here are some examples of simple `Effect`s: +Here's an example of an `Effect` that returns the string `"Hello, world!"`: ```ts twoslash import { Effect } from "effect" // ---cut--- -const sync = Effect.sync(() => "Hello, world!") -const promise = Effect.promise(async () => "Hello, world!") -const succeed = Effect.succeed("Hello, world!") +Effect.promise(async () => "Hello, world!") ``` -Above we have examples of synchronous, asynchronous, and static Effects. None of -them will do anything as defined there, they need to be run. There are several -ways to run Effects. +`Effect.promise` takes a function that returns a `Promise` and creates an +`Effect` out of it. `Effect.sync` works exactly the same, except it takes a +synchronous function instead of an asynchronous one. -```ts twoslash +If we want to run this Effect, we can use `Effect.runPromise`: + +```ts twoslash {2} import { Effect } from "effect" -const sync = Effect.sync(() => "Hello, world!") -const promise = Effect.promise(async () => "Hello, world!") -const succeed = Effect.succeed("Hello, world!") // ---cut--- -const resultSync = Effect.runSync(sync) -const resultPromise = await Effect.runPromise(promise); -const resultSucceed = Effect.runSync(succeed) - -console.log(resultSync) -// "Hello, world!" -console.log(resultPromise) -// "Hello, world!" -console.log(resultSucceed) -// "Hello, world!" +const promise = Effect.promise(async () => "Hello, world!") +const result = await Effect.runPromise(promise); +console.log(result) +// => "Hello, world!" ``` -This prints out `"Hello, world!"` three times. Note that you have to run -synchronous and asynchronous Effects differently, `runSync` and `runPromise` -respectively. - What if you want to run an Effect that calls another Effect? You can use `Effect.gen` to do that: -```ts twoslash +```ts twoslash {2-5} import { Effect } from "effect" -const promise = Effect.promise(async () => "Hello, world!") // ---cut--- +const promise = Effect.promise(async () => "Hello, world!") const gen = Effect.gen(function* () { - const result = yield* promise - return result.toUpperCase() + const str = yield* promise + return str.toUpperCase() }) - -const resultGen = await Effect.runPromise(gen) -console.log(resultGen) -// "HELLO, WORLD!" +const result = await Effect.runPromise(gen) +console.log(result) +// => "HELLO, WORLD!" ``` -Given that all we really want to do above is call `.toUpperCase()` on the result -of `promise`, the scaffolding of `Effect.gen` feels a bit heavy. Effect gives us -plenty of tools to work with `Effect`s, one of which is `pipe`: +This makes use of JavaScript's [generator functions][1]. You can think of the +`yield*` keyword as being very similar to `await`ing a `Promise`. Under the +hood, all `Effect`s are generators, and `yield*`ing one passes it to the Effect +runtime and waits for the result. -```ts twoslash +Given that all we want to do above is call `.toUpperCase()` on the result +of `promise`, the scaffolding of `Effect.gen` may feel heavy. Effect gives us +a suite of tools to work with `Effect`s, and one of those tools is `pipe`: + +```ts twoslash {2} import { Effect } from "effect" -const promise = Effect.promise(async () => "Hello, world!") // ---cut--- +const promise = Effect.promise(async () => "Hello, world!") const upper = promise.pipe(Effect.map((s) => s.toUpperCase())) const result = await Effect.runPromise(upper) console.log(result) -// "HELLO, WORLD!" +// => "HELLO, WORLD!" ``` `pipe` passes the result of one computation as input to another. Here, @@ -220,10 +208,10 @@ provided. We can `pipe` as many `Effect`s together as we like: -```ts twoslash +```ts twoslash {2-7} import { Effect } from "effect" -const promise = Effect.promise(async () => "Hello, world!") // ---cut--- +const promise = Effect.promise(async () => "Hello, world!") const upper = promise.pipe( Effect.map((s) => s.toUpperCase()), Effect.map((s) => s.split("")), @@ -232,12 +220,17 @@ const upper = promise.pipe( ) const result = await Effect.runPromise(upper) console.log(result) -// "!DLROW ,OLLEH" +// => "!DLROW ,OLLEH" ``` +This should give us just enough to carry on with migrating our first endpoint +to Effect. As we continue through this tutorial, we'll introduce more and more +things you can do with the `Effect` type. + ## Understanding `HttpRouter` -Looking back at `HttpRouter`, we used `pipe` to add a new endpoint to our app: +Looking back at our `HttpRouter`, we used `pipe` to add a new endpoint to our +app: ```ts twoslash import { HttpRouter, HttpServerResponse } from "@effect/platform" @@ -260,11 +253,11 @@ import { HttpRouter } from "@effect/platform" import { Effect } from "effect" // ---cut--- console.log(Effect.isEffect(HttpRouter.empty)) -// true +// => true ``` -The helper `HttpRouter.get` takes as input an `HttpRouter` and returns a new -`HttpRouter` with the given route added. If we wanted to, we could have done +The helper `HttpRouter.get` takes an `HttpRouter` as an argument and returns a +new `HttpRouter` with the given route added. If we wanted to, we could have done this much more directly: ```ts twoslash @@ -309,3 +302,5 @@ HttpRouter.empty.pipe( HttpRouter.get("/health", Effect.sync(health)), ) ``` + +[1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function* From 04e21035f0430ab84d6b13a645c079bd72dd5bae Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 31 Jan 2025 11:25:13 +0000 Subject: [PATCH 04/28] Split out intro from migrating first endpoint. --- .../docs/learn/tutorials/first-endpoint.mdx | 262 ++++++++++++++++++ .../docs/learn/tutorials/introduction.mdx | 257 +---------------- 2 files changed, 264 insertions(+), 255 deletions(-) create mode 100644 content/src/content/docs/learn/tutorials/first-endpoint.mdx diff --git a/content/src/content/docs/learn/tutorials/first-endpoint.mdx b/content/src/content/docs/learn/tutorials/first-endpoint.mdx new file mode 100644 index 000000000..157e0103f --- /dev/null +++ b/content/src/content/docs/learn/tutorials/first-endpoint.mdx @@ -0,0 +1,262 @@ +--- +type: Tutorial +title: Migrating our first endpoint +tags: + - some + - tags +sidebar: + order: 2 +--- + +The first step to migrating this to Effect is to launch the Express.js server +with Effect instead of the traditional `app.listen`. Because the Express.js +`app` is a function that returns a node `http.Server` under the hood, it slots +nicely into Effect's `HttpServer` abstraction. + +```ts twoslash {3-6, 14-21} +import express from "express" + +import { HttpRouter, HttpServer } from "@effect/platform" +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { Layer } from "effect" +import { createServer } from "node:http" + +const app = express() + +app.get("/health", (req, res) => { + res.type("text/plain").send("ok") +}) + +NodeRuntime.runMain( + Layer.launch( + Layer.provide( + HttpServer.serve(HttpRouter.empty), + NodeHttpServer.layer(() => createServer(app), { port: 3000 }), + ), + ), +) +``` + +And we can run the resulting code like so: + +```shell +bun add @effect/platform @effect/platform-node +bun run index.ts +``` + +We haven't changed anything about how the server behaves. It still exposes a +single endpoint, `http://localhost:3000/health`, and that still returns `ok`. +But we've wrapped the Express.js app in a way that will allow us to define new +endpoints using Effect while still responding to our existing endpoints in +Express.js. + +There's a lot of new things happening at the bottom of our file. For the time +being we're going to ignore most of it and focus on migrating an endpoint to +Effect. I promise by the time we're done, you'll understand every line we added +above. + +First, let's break out our router, `HttpRouter.empty`, out into its own variable +so it's easier to add to. + +```ts twoslash +import { HttpRouter } from "@effect/platform" +// ---cut--- +const router = HttpRouter.empty +``` + +And let's define the function we want to run when we hit the `/health` endpoint: + +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +// ---cut--- +function health() { + return HttpServerResponse.text("ok") +} +``` + +`HttpServerResponse` is Effect's class for generating HTTP responses, we need to +use it for any responses returned from our endpoints defined with Effect. + +The way we wire these things together is going to look a bit strange, but bear +with me. + +```ts twoslash +import { HttpServerResponse, HttpRouter } from "@effect/platform" +import { Effect } from "effect" +// ---cut--- +function health() { + return HttpServerResponse.text("ok") +} + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)), +) +``` + +What on earth are `Effect.sync` and `.pipe`? + +## The Effect Type + +At the core of Effect is... well, the `Effect`. You can think of an `Effect` as +a lazy computation, similar to a function that hasn't been called yet. + +Here's an example of an `Effect` that returns the string `"Hello, world!"`: + +```ts twoslash +import { Effect } from "effect" +// ---cut--- +Effect.promise(async () => "Hello, world!") +``` + +`Effect.promise` takes a function that returns a `Promise` and creates an +`Effect` out of it. `Effect.sync` works exactly the same, except it takes a +synchronous function instead of an asynchronous one. + +If we want to run this Effect, we can use `Effect.runPromise`: + +```ts twoslash {2} +import { Effect } from "effect" +// ---cut--- +const promise = Effect.promise(async () => "Hello, world!") +const result = await Effect.runPromise(promise); +console.log(result) +// => "Hello, world!" +``` + +What if you want to run an Effect that calls another Effect? You can use +`Effect.gen` to do that: + +```ts twoslash {2-5} +import { Effect } from "effect" +// ---cut--- +const promise = Effect.promise(async () => "Hello, world!") +const gen = Effect.gen(function* () { + const str = yield* promise + return str.toUpperCase() +}) +const result = await Effect.runPromise(gen) +console.log(result) +// => "HELLO, WORLD!" +``` + +This makes use of JavaScript's [generator functions][1]. You can think of the +`yield*` keyword as being very similar to `await`ing a `Promise`. Under the +hood, all `Effect`s are generators, and `yield*`ing one passes it to the Effect +runtime and waits for the result. + +Given that all we want to do above is call `.toUpperCase()` on the result +of `promise`, the scaffolding of `Effect.gen` may feel heavy. Effect gives us +a suite of tools to work with `Effect`s, and one of those tools is `pipe`: + +```ts twoslash {2} +import { Effect } from "effect" +// ---cut--- +const promise = Effect.promise(async () => "Hello, world!") +const upper = promise.pipe(Effect.map((s) => s.toUpperCase())) +const result = await Effect.runPromise(upper) +console.log(result) +// => "HELLO, WORLD!" +``` + +`pipe` passes the result of one computation as input to another. Here, +`Effect.map` transforms the result of the first `Effect` with the function +provided. + +We can `pipe` as many `Effect`s together as we like: + +```ts twoslash {2-7} +import { Effect } from "effect" +// ---cut--- +const promise = Effect.promise(async () => "Hello, world!") +const upper = promise.pipe( + Effect.map((s) => s.toUpperCase()), + Effect.map((s) => s.split("")), + Effect.map((s) => s.reverse()), + Effect.map((s) => s.join("")), +) +const result = await Effect.runPromise(upper) +console.log(result) +// => "!DLROW ,OLLEH" +``` + +This should give us just enough to carry on with migrating our first endpoint +to Effect. As we continue through this tutorial, we'll introduce more and more +things you can do with the `Effect` type. + +## Understanding `HttpRouter` + +Looking back at our `HttpRouter`, we used `pipe` to add a new endpoint to our +app: + +```ts twoslash +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +function health() { + return HttpServerResponse.text("ok") +} +// ---cut--- +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)), +); +``` + +`HttpRouter` is a data structure that represents a collection of routes. The +simplest router is one with no routes at all, and Effect exposes that to us as +the `HttpRouter.empty` value. Under the hood, this is itself an `Effect`: + +```ts twoslash +import { HttpRouter } from "@effect/platform" +import { Effect } from "effect" +// ---cut--- +console.log(Effect.isEffect(HttpRouter.empty)) +// => true +``` + +The helper `HttpRouter.get` takes an `HttpRouter` as an argument and returns a +new `HttpRouter` with the given route added. If we wanted to, we could have done +this much more directly: + +```ts twoslash +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +function health() { + return HttpServerResponse.text("ok") +} +// ---cut--- +const router = HttpRouter.get("/health", Effect.sync(health))(HttpRouter.empty) +``` + +This is exactly the same as the `pipe` version, except that if we wanted to add +multiple routes it gets unwieldy quickly: + +```ts twoslash +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +function health() { + return HttpServerResponse.text("ok") +} +function status() { + return HttpServerResponse.text("ok") +} +function version() { + return HttpServerResponse.text("ok") +} +// ---cut--- +HttpRouter.get("/health", Effect.sync(health))( + HttpRouter.get("/status", Effect.sync(status))( + HttpRouter.get("/version", Effect.sync(version))( + HttpRouter.empty, + ), + ), +) + +// vs + +HttpRouter.empty.pipe( + HttpRouter.get("/version", Effect.sync(version)), + HttpRouter.get("/status", Effect.sync(status)), + HttpRouter.get("/health", Effect.sync(health)), +) +``` + +[1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function* diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index 18c878bdf..905fbd6a1 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -4,6 +4,8 @@ title: Introduction tags: - some - tags +sidebar: + order: 1 --- In this tutorial we're going to migrate an Express.js app to Effect. We'll learn @@ -49,258 +51,3 @@ bun run index.ts You should see `Server is running on http://localhost:3000` in your terminal, and visiting `http://localhost:3000/health` should return `ok`. - -## Migrating to Effect - -The first step to migrating this to Effect is to launch the Express.js server -with Effect instead of the traditional `app.listen`. Because the Express.js -`app` is a function that returns a node `http.Server` under the hood, it slots -nicely into Effect's `HttpServer` abstraction. - -```ts twoslash {3-6, 14-21} -import express from "express" - -import { HttpRouter, HttpServer } from "@effect/platform" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" -import { Layer } from "effect" -import { createServer } from "node:http" - -const app = express() - -app.get("/health", (req, res) => { - res.type("text/plain").send("ok") -}) - -NodeRuntime.runMain( - Layer.launch( - Layer.provide( - HttpServer.serve(HttpRouter.empty), - NodeHttpServer.layer(() => createServer(app), { port: 3000 }), - ), - ), -) -``` - -And we can run the resulting code like so: - -```shell -bun add @effect/platform @effect/platform-node -bun run index.ts -``` - -We haven't changed anything about how the server behaves. It still exposes a -single endpoint, `http://localhost:3000/health`, and that still returns `ok`. -But we've wrapped the Express.js app in a way that will allow us to define new -endpoints using Effect while still responding to our existing endpoints in -Express.js. - -There's a lot of new things happening at the bottom of our file. For the time -being we're going to ignore most of it and focus on migrating an endpoint to -Effect. I promise by the time we're done, you'll understand every line we added -above. - -First, let's break out our router, `HttpRouter.empty`, out into its own variable -so it's easier to add to. - -```ts twoslash -import { HttpRouter } from "@effect/platform" -// ---cut--- -const router = HttpRouter.empty -``` - -And let's define the function we want to run when we hit the `/health` endpoint: - -```ts twoslash -import { HttpServerResponse } from "@effect/platform" -// ---cut--- -function health() { - return HttpServerResponse.text("ok") -} -``` - -`HttpServerResponse` is Effect's class for generating HTTP responses, we need to -use it for any responses returned from our endpoints defined with Effect. - -The way we wire these things together is going to look a bit strange, but bear -with me. - -```ts twoslash -import { HttpServerResponse, HttpRouter } from "@effect/platform" -import { Effect } from "effect" -// ---cut--- -function health() { - return HttpServerResponse.text("ok") -} - -const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)), -) -``` - -What on earth are `Effect.sync` and `.pipe`? - -## The Effect Type - -At the core of Effect is... well, the `Effect`. You can think of an `Effect` as -a lazy computation, similar to a function that hasn't been called yet. - -Here's an example of an `Effect` that returns the string `"Hello, world!"`: - -```ts twoslash -import { Effect } from "effect" -// ---cut--- -Effect.promise(async () => "Hello, world!") -``` - -`Effect.promise` takes a function that returns a `Promise` and creates an -`Effect` out of it. `Effect.sync` works exactly the same, except it takes a -synchronous function instead of an asynchronous one. - -If we want to run this Effect, we can use `Effect.runPromise`: - -```ts twoslash {2} -import { Effect } from "effect" -// ---cut--- -const promise = Effect.promise(async () => "Hello, world!") -const result = await Effect.runPromise(promise); -console.log(result) -// => "Hello, world!" -``` - -What if you want to run an Effect that calls another Effect? You can use -`Effect.gen` to do that: - -```ts twoslash {2-5} -import { Effect } from "effect" -// ---cut--- -const promise = Effect.promise(async () => "Hello, world!") -const gen = Effect.gen(function* () { - const str = yield* promise - return str.toUpperCase() -}) -const result = await Effect.runPromise(gen) -console.log(result) -// => "HELLO, WORLD!" -``` - -This makes use of JavaScript's [generator functions][1]. You can think of the -`yield*` keyword as being very similar to `await`ing a `Promise`. Under the -hood, all `Effect`s are generators, and `yield*`ing one passes it to the Effect -runtime and waits for the result. - -Given that all we want to do above is call `.toUpperCase()` on the result -of `promise`, the scaffolding of `Effect.gen` may feel heavy. Effect gives us -a suite of tools to work with `Effect`s, and one of those tools is `pipe`: - -```ts twoslash {2} -import { Effect } from "effect" -// ---cut--- -const promise = Effect.promise(async () => "Hello, world!") -const upper = promise.pipe(Effect.map((s) => s.toUpperCase())) -const result = await Effect.runPromise(upper) -console.log(result) -// => "HELLO, WORLD!" -``` - -`pipe` passes the result of one computation as input to another. Here, -`Effect.map` transforms the result of the first `Effect` with the function -provided. - -We can `pipe` as many `Effect`s together as we like: - -```ts twoslash {2-7} -import { Effect } from "effect" -// ---cut--- -const promise = Effect.promise(async () => "Hello, world!") -const upper = promise.pipe( - Effect.map((s) => s.toUpperCase()), - Effect.map((s) => s.split("")), - Effect.map((s) => s.reverse()), - Effect.map((s) => s.join("")), -) -const result = await Effect.runPromise(upper) -console.log(result) -// => "!DLROW ,OLLEH" -``` - -This should give us just enough to carry on with migrating our first endpoint -to Effect. As we continue through this tutorial, we'll introduce more and more -things you can do with the `Effect` type. - -## Understanding `HttpRouter` - -Looking back at our `HttpRouter`, we used `pipe` to add a new endpoint to our -app: - -```ts twoslash -import { HttpRouter, HttpServerResponse } from "@effect/platform" -import { Effect } from "effect" -function health() { - return HttpServerResponse.text("ok") -} -// ---cut--- -const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)), -); -``` - -`HttpRouter` is a data structure that represents a collection of routes. The -simplest router is one with no routes at all, and Effect exposes that to us as -the `HttpRouter.empty` value. Under the hood, this is itself an `Effect`: - -```ts twoslash -import { HttpRouter } from "@effect/platform" -import { Effect } from "effect" -// ---cut--- -console.log(Effect.isEffect(HttpRouter.empty)) -// => true -``` - -The helper `HttpRouter.get` takes an `HttpRouter` as an argument and returns a -new `HttpRouter` with the given route added. If we wanted to, we could have done -this much more directly: - -```ts twoslash -import { HttpRouter, HttpServerResponse } from "@effect/platform" -import { Effect } from "effect" -function health() { - return HttpServerResponse.text("ok") -} -// ---cut--- -const router = HttpRouter.get("/health", Effect.sync(health))(HttpRouter.empty) -``` - -This is exactly the same as the `pipe` version, except that if we wanted to add -multiple routes it gets unwieldy quickly: - -```ts twoslash -import { HttpRouter, HttpServerResponse } from "@effect/platform" -import { Effect } from "effect" -function health() { - return HttpServerResponse.text("ok") -} -function status() { - return HttpServerResponse.text("ok") -} -function version() { - return HttpServerResponse.text("ok") -} -// ---cut--- -HttpRouter.get("/health", Effect.sync(health))( - HttpRouter.get("/status", Effect.sync(status))( - HttpRouter.get("/version", Effect.sync(version))( - HttpRouter.empty, - ), - ), -) - -// vs - -HttpRouter.empty.pipe( - HttpRouter.get("/version", Effect.sync(version)), - HttpRouter.get("/status", Effect.sync(status)), - HttpRouter.get("/health", Effect.sync(health)), -) -``` - -[1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function* From df082a42a7c75543c463cae7fa46f5097b5da3a9 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 31 Jan 2025 16:27:23 +0000 Subject: [PATCH 05/28] Begin writing about migrating an endpoint using a database. --- content/package.json | 1 + content/pnpm-lock.yaml | 25 +++ .../docs/learn/tutorials/first-endpoint.mdx | 6 +- .../learn/tutorials/hooking-up-a-database.mdx | 195 ++++++++++++++++++ .../docs/learn/tutorials/introduction.mdx | 13 +- 5 files changed, 231 insertions(+), 9 deletions(-) create mode 100644 content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx diff --git a/content/package.json b/content/package.json index 8527fa441..a36fc0ca5 100644 --- a/content/package.json +++ b/content/package.json @@ -46,6 +46,7 @@ "@radix-ui/react-tooltip": "^1.1.4", "@sentry/opentelemetry": "^8.50.0", "@tanstack/react-table": "^8.20.5", + "@types/bun": "^1.2.1", "@types/express": "^5.0.0", "@types/json-schema": "^7.0.15", "@types/node": "^22.9.3", diff --git a/content/pnpm-lock.yaml b/content/pnpm-lock.yaml index eee07fae7..009900d9b 100644 --- a/content/pnpm-lock.yaml +++ b/content/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: '@tanstack/react-table': specifier: ^8.20.5 version: 8.20.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/bun': + specifier: ^1.2.1 + version: 1.2.1 '@types/express': specifier: ^5.0.0 version: 5.0.0 @@ -2278,6 +2281,9 @@ packages: '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + '@types/bun@1.2.1': + resolution: {integrity: sha512-iiCeMAKMkft8EPQJxSbpVRD0DKqrh91w40zunNajce3nMNNFd/LnAquVisSZC+UpTMjDwtcdyzbWct08IvEqRA==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -2365,6 +2371,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/ws@8.5.14': + resolution: {integrity: sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==} + '@typescript/vfs@1.6.0': resolution: {integrity: sha512-hvJUjNVeBMp77qPINuUvYXj4FyWeeMMKZkxEATEU3hqBAQ7qdTBCUFT7Sp0Zu0faeEtFf+ldXxMEDr/bk73ISg==} peerDependencies: @@ -2652,6 +2661,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bun-types@1.2.1: + resolution: {integrity: sha512-p7bmXUWmrPWxhcbFVk7oUXM5jAGt94URaoa3qf4mz43MEhNAo/ot1urzBqctgvuq7y9YxkuN51u+/qm4BiIsHw==} + bundle-require@5.0.0: resolution: {integrity: sha512-GuziW3fSSmopcx4KRymQEJVbZUfqlCqcq7dvs6TYwKRZiegK/2buMxQTPs6MGlNv50wms1699qYO54R8XfRX4w==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -7072,6 +7084,10 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.9.3 + '@types/bun@1.2.1': + dependencies: + bun-types: 1.2.1 + '@types/connect@3.4.38': dependencies: '@types/node': 22.9.3 @@ -7168,6 +7184,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/ws@8.5.14': + dependencies: + '@types/node': 22.9.3 + '@typescript/vfs@1.6.0(typescript@5.7.2)': dependencies: debug: 4.3.7 @@ -7553,6 +7573,11 @@ snapshots: node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.2) + bun-types@1.2.1: + dependencies: + '@types/node': 22.9.3 + '@types/ws': 8.5.14 + bundle-require@5.0.0(esbuild@0.24.0): dependencies: esbuild: 0.24.0 diff --git a/content/src/content/docs/learn/tutorials/first-endpoint.mdx b/content/src/content/docs/learn/tutorials/first-endpoint.mdx index 157e0103f..d93eff02a 100644 --- a/content/src/content/docs/learn/tutorials/first-endpoint.mdx +++ b/content/src/content/docs/learn/tutorials/first-endpoint.mdx @@ -13,7 +13,7 @@ with Effect instead of the traditional `app.listen`. Because the Express.js `app` is a function that returns a node `http.Server` under the hood, it slots nicely into Effect's `HttpServer` abstraction. -```ts twoslash {3-6, 14-21} +```ts twoslash {3-6, 14-21} title=index.ts import express from "express" import { HttpRouter, HttpServer } from "@effect/platform" @@ -40,8 +40,8 @@ NodeRuntime.runMain( And we can run the resulting code like so: ```shell -bun add @effect/platform @effect/platform-node -bun run index.ts +$ bun add @effect/platform @effect/platform-node +$ bun index.ts ``` We haven't changed anything about how the server behaves. It still exposes a diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx new file mode 100644 index 000000000..20f8fae30 --- /dev/null +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -0,0 +1,195 @@ +--- +type: Tutorial +title: Hooking up a database +tags: + - some + - tags +sidebar: + order: 3 +--- + +The next endpoint we're going to migrate is one that lists all of the users in a +database. For illustrative purposes, we're going to use SQLite using Bun's +built-in connector, but the technique we'll cover will work for +whatever database you're using, and however you're connecting to it. + +First, let's take a look at our app in full with our new endpoint added to +it: + +```ts twoslash {8-9,13-17} title=index.ts +import express from "express" + +import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform" +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" + +import { Database } from "bun:sqlite"; +const db = new Database("sqlite.db"); + +const app = express(); + +app.get("/users", (req, res) => { + res.send({ + users: db.prepare("SELECT * FROM users").all(), + }); +}); + +export const health = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", health), +) + +NodeRuntime.runMain( + Layer.launch( + Layer.provide( + HttpServer.serve(router), + NodeHttpServer.layer(() => createServer(app), { port: 3000 }), + ), + ), +) +``` + +To create our example `sqlite.db` file, you can run the following commands: + +```shell +$ sqlite3 sqlite.db +sqlite> CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT); +sqlite> INSERT INTO users (name) VALUES ("Alice"); +sqlite> INSERT INTO users (name) VALUES ("Bob"); +``` + +Then you can run the server with: + +```shell +$ bun index.ts +``` + +And in other shell we can test the endpoint: + +```shell +$ curl http://localhost:3000/users +{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}⏎ +``` + +## The direct approach + +If we were to copy what we did with the `/health` endpoint, we'd end up with +code that looked like this: + +```ts twoslash +import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform" +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" + +import { Database } from "bun:sqlite"; +const db = new Database("sqlite.db"); +export const health = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) +//---cut--- +// @errors: 2379 +const users = Effect.sync(() => { + return HttpServerResponse.json({ + users: db.prepare("SELECT * FROM users").all(), + }) +}) + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", health), + HttpRouter.get("/users", users), +) +``` + +This doesn't work, because converting a JavaScript object to JSON might fail, so +`HttpServerResponse.json` returns an Effect that can fail, unlike +`HttpServerResponse.text` which directly returns an `HttpServerResponse` because +it cannot fail. + +## Error handling in Effect + +When we introduced the `Effect` type in the previous section, we dealt +exclusively with Effects that couldn't fail. That won't get us very far if +we're building anything non-trivial, so let's talk about error handling. + +`Effect` is a generic type that takes 3 type arguments: + +```typescript +Effect +``` + +`Success` is the type the Effect returns when it succeeds, `Error` is the type +it returns when it fails, and we'll talk about `Requirements` a bit later. + +When we created our example Effects in the previous section, these all took on +the type `Effect`. + +```ts twoslash +import { Effect } from "effect" +//---cut--- +Effect.sync(() => "Hello, world!") +//=> Effect.Effect +``` + +This means they will return a `string` on success, and never fail. + +The Effect that `HttpServerResponse.json` returns is of type +`Effect`. So when we try to return +it from our `users` handler, we end up wrapping one Effect in another. + +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" + +import { Database } from "bun:sqlite"; +const db = new Database("sqlite.db"); +//---cut--- +const users = Effect.sync(() => { +// ^? + return HttpServerResponse.json({ + users: db.prepare("SELECT * FROM users").all(), + }) +}) +``` + +And this causes the type mismatch error we saw earlier. Let's fix it. + +## More pipes + +We saw `pipe` used in our `HttpRouter`, and we're going to use it again here. +The `pipe` function is fundamental to Effect, you're going to see it a lot. + +Let's express our `users` handler in a different way: + +```ts twoslash +import { HttpServerResponse, HttpRouter } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import { Database } from "bun:sqlite"; +const db = new Database("sqlite.db"); +export const health = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) +//---cut--- +const users = Effect.sync(() => { + return { users: db.prepare("SELECT * FROM users").all() } +}).pipe( + (body) => HttpServerResponse.json(body) +) + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", health), + HttpRouter.get("/users", users), +) +``` + +This no longer gives us a type error. We've split our computation into two +parts: fetching the data with `Effect.sync`, and serializing it with +`HttpServerResponse.json`. The call to `pipe` takes the result of the first +Effect and passes it to `HttpServerResponse.json`, flattening any nesting in +the process. diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index 905fbd6a1..8621c33f7 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -19,7 +19,7 @@ introduce new concepts as we migrate more complex endpoints. We'll start with a simple Express.js app that has a single health checking endpoint. -```ts twoslash +```ts twoslash title=index.ts import express from "express" const app = express() @@ -37,16 +37,17 @@ Create a new TypeScript project using your favourite package manager. I'm using `bun`: ```shell -mkdir express-to-effect -cd express-to-effect -bun init +$ mkdir express-to-effect +$ cd express-to-effect +$ bun init ``` Save our Express.js app into `index.ts` and run it to make sure it works: ```shell -bun add express -bun run index.ts +$ bun add express +$ bun add -D @types/express +$ bun index.ts ``` You should see `Server is running on http://localhost:3000` in your terminal, From b66883e9932ed5a4b9f2f6825629d6adf4860061 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 7 Feb 2025 11:25:47 +0000 Subject: [PATCH 06/28] Respond to Max's PR feedback. --- .../docs/learn/tutorials/first-endpoint.mdx | 18 +- .../learn/tutorials/hooking-up-a-database.mdx | 180 +++++++++++++++--- 2 files changed, 163 insertions(+), 35 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/first-endpoint.mdx b/content/src/content/docs/learn/tutorials/first-endpoint.mdx index d93eff02a..ffba1d9cf 100644 --- a/content/src/content/docs/learn/tutorials/first-endpoint.mdx +++ b/content/src/content/docs/learn/tutorials/first-endpoint.mdx @@ -117,8 +117,8 @@ If we want to run this Effect, we can use `Effect.runPromise`: ```ts twoslash {2} import { Effect } from "effect" // ---cut--- -const promise = Effect.promise(async () => "Hello, world!") -const result = await Effect.runPromise(promise); +const effect = Effect.promise(async () => "Hello, world!") +const result = await Effect.runPromise(effect); console.log(result) // => "Hello, world!" ``` @@ -129,9 +129,9 @@ What if you want to run an Effect that calls another Effect? You can use ```ts twoslash {2-5} import { Effect } from "effect" // ---cut--- -const promise = Effect.promise(async () => "Hello, world!") +const effect = Effect.promise(async () => "Hello, world!") const gen = Effect.gen(function* () { - const str = yield* promise + const str = yield* effect return str.toUpperCase() }) const result = await Effect.runPromise(gen) @@ -145,14 +145,14 @@ hood, all `Effect`s are generators, and `yield*`ing one passes it to the Effect runtime and waits for the result. Given that all we want to do above is call `.toUpperCase()` on the result -of `promise`, the scaffolding of `Effect.gen` may feel heavy. Effect gives us +of `effect`, the scaffolding of `Effect.gen` may feel heavy. Effect gives us a suite of tools to work with `Effect`s, and one of those tools is `pipe`: ```ts twoslash {2} import { Effect } from "effect" // ---cut--- -const promise = Effect.promise(async () => "Hello, world!") -const upper = promise.pipe(Effect.map((s) => s.toUpperCase())) +const effect = Effect.promise(async () => "Hello, world!") +const upper = effect.pipe(Effect.map((s) => s.toUpperCase())) const result = await Effect.runPromise(upper) console.log(result) // => "HELLO, WORLD!" @@ -167,8 +167,8 @@ We can `pipe` as many `Effect`s together as we like: ```ts twoslash {2-7} import { Effect } from "effect" // ---cut--- -const promise = Effect.promise(async () => "Hello, world!") -const upper = promise.pipe( +const effect = Effect.promise(async () => "Hello, world!") +const upper = effect.pipe( Effect.map((s) => s.toUpperCase()), Effect.map((s) => s.split("")), Effect.map((s) => s.reverse()), diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index 20f8fae30..b8e50f058 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -53,7 +53,113 @@ NodeRuntime.runMain( ) ``` -To create our example `sqlite.db` file, you can run the following commands: +Before we continue, I'm going to make a modification to this since we learned +about `pipe` in the previous section. We can replace the expression at the +bottom of the file with this: + +```ts twoslash title=index.ts +import express from "express" + +import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform" +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" + +import { Database } from "bun:sqlite"; +const db = new Database("sqlite.db"); + +const app = express(); + +app.get("/users", (req, res) => { + res.send({ + users: db.prepare("SELECT * FROM users").all(), + }); +}); + +export const health = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", health), +) +//---cut--- +HttpServer.serve(router).pipe( + Layer.provide(NodeHttpServer.layer(() => createServer(app), { port: 3000 })), + Layer.launch, + NodeRuntime.runMain +) +``` + +If `pipe` isn't quite clicking for you yet, one way to visualise it is that +you're passing some data through a sequence of functions. + +Take this example: + +```ts twoslash +import { pipe } from "effect" + +const add = (x: number) => (y: number) => y + x; +const mul = (x: number) => (y: number) => y * x; +const sub = (x: number) => (y: number) => y - x; + +const result = pipe( + 1, + add(2), + mul(3), + sub(1) +) +console.log(result) +//=> 8 +``` + +`add`, `mul`, and `sub` are all functions that return functions. So `add(2)` +returns a function that can add 2 to a given number: `add(2)(1)` returns 3. +Our pipeline starts with 1, then passes it through `add(2)`, `mul(3)`, and +`sub(1)`, resulting in 8. + +One of the powers this gives you is the ability to add things easily into +the pipeline. + +```ts twoslash +import { pipe } from "effect" + +const add = (x: number) => (y: number) => y + x; +const mul = (x: number) => (y: number) => y * x; +const sub = (x: number) => (y: number) => y - x; +const log = (s: string) => (y: number) => { + console.log(s, y); + return y; +}; + +const result = pipe( + 1, + add(2), + log("1 + 2 ="), + mul(3), + log(" * 3 ="), + sub(1), + log(" - 1 ="), +); +``` + +This prints the following, with `result` still being 8: + +``` +1 + 2 = 3 + * 3 = 9 + - 1 = 8 +``` + +As we migrate more complex endpoints, we'll see how this ability to "tap into" +the pipeline is really useful. + +## Creating our data + +To create our example `sqlite.db` file, make sure you have the `sqlite3` CLI +command installed. You can this either by downloading binaries from the +[official website](https://www.sqlite.org/download.html) or by using a package +manager like `brew` on macOS or `apt` on Ubuntu. ```shell $ sqlite3 sqlite.db @@ -68,7 +174,7 @@ Then you can run the server with: $ bun index.ts ``` -And in other shell we can test the endpoint: +And in another shell we can test the endpoint: ```shell $ curl http://localhost:3000/users @@ -105,10 +211,18 @@ const router = HttpRouter.empty.pipe( ) ``` -This doesn't work, because converting a JavaScript object to JSON might fail, so -`HttpServerResponse.json` returns an Effect that can fail, unlike -`HttpServerResponse.text` which directly returns an `HttpServerResponse` because -it cannot fail. +This doesn't work, as we can see from the error. The reason is that +`HttpServerResponse.json` is an operation that might fail. Not all JavaScript +objects can be converted to JSON. + +```ts twoslash +JSON.stringify({ big: 10n }) +//=> TypeError: JSON.stringify cannot serialize BigInt. +``` + +For this reason, `HttpServerResponse.json` returns an `Effect` that can fail. +We didn't see this earlier because `HttpServerResponse.text` can't fail, and +so just returns an `HttpServerResponse` directly. ## Error handling in Effect @@ -157,39 +271,53 @@ const users = Effect.sync(() => { }) ``` -And this causes the type mismatch error we saw earlier. Let's fix it. +This is what the type error from earlier was trying to tell us. Here's the +error again: -## More pipes +```text wrap +Argument of type 'Effect, never, never>' is not assignable to parameter of type 'Handler' +``` -We saw `pipe` used in our `HttpRouter`, and we're going to use it again here. -The `pipe` function is fundamental to Effect, you're going to see it a lot. +Under the hood, a `Handler` is an `Effect`. Because `Effect` is +not an `HttpServerResponse`, the type checker gets upset with us. -Let's express our `users` handler in a different way: +It's possible to fix this with a call to `Effect.flatMap`: ```ts twoslash -import { HttpServerResponse, HttpRouter } from "@effect/platform" +import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" + import { Database } from "bun:sqlite"; const db = new Database("sqlite.db"); -export const health = Effect.sync(() => { - return HttpServerResponse.text("ok") -}) //---cut--- const users = Effect.sync(() => { - return { users: db.prepare("SELECT * FROM users").all() } -}).pipe( - (body) => HttpServerResponse.json(body) -) + return Effect.flatMap( + Effect.succeed({ users: db.prepare("SELECT * FROM users").all() }), + HttpServerResponse.json, + ); +}); +``` -const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", health), - HttpRouter.get("/users", users), -) +But it is more idiomatic in Effect to use `pipe`: + +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" + +import { Database } from "bun:sqlite"; +const db = new Database("sqlite.db"); +//---cut--- +const users = Effect.sync(() => { + return { + users: db.prepare("SELECT * FROM users").all(), + }; +}).pipe(Effect.flatMap(HttpServerResponse.json)); ``` This no longer gives us a type error. We've split our computation into two parts: fetching the data with `Effect.sync`, and serializing it with -`HttpServerResponse.json`. The call to `pipe` takes the result of the first -Effect and passes it to `HttpServerResponse.json`, flattening any nesting in -the process. +`HttpServerResponse.json`. The `Effect.flatMap` in the middle of these two +takes care of the accidental `Effect` nesting. From 2ea679eeb42bdab7b50db7ad8bc34c20dddfcd67 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 7 Feb 2025 12:41:17 +0000 Subject: [PATCH 07/28] Flesh out the hooking up a database section a bit more. --- .../learn/tutorials/hooking-up-a-database.mdx | 180 ++++++++++++++---- 1 file changed, 145 insertions(+), 35 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index b8e50f058..88fa0b6b8 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -24,16 +24,16 @@ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Database } from "bun:sqlite"; -const db = new Database("sqlite.db"); +import { Database } from "bun:sqlite" +const db = new Database("sqlite.db") -const app = express(); +const app = express() app.get("/users", (req, res) => { res.send({ users: db.prepare("SELECT * FROM users").all(), - }); -}); + }) +}) export const health = Effect.sync(() => { return HttpServerResponse.text("ok") @@ -65,16 +65,16 @@ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Database } from "bun:sqlite"; -const db = new Database("sqlite.db"); +import { Database } from "bun:sqlite" +const db = new Database("sqlite.db") -const app = express(); +const app = express() app.get("/users", (req, res) => { res.send({ users: db.prepare("SELECT * FROM users").all(), - }); -}); + }) +}) export const health = Effect.sync(() => { return HttpServerResponse.text("ok") @@ -99,9 +99,9 @@ Take this example: ```ts twoslash import { pipe } from "effect" -const add = (x: number) => (y: number) => y + x; -const mul = (x: number) => (y: number) => y * x; -const sub = (x: number) => (y: number) => y - x; +const add = (x: number) => (y: number) => y + x +const mul = (x: number) => (y: number) => y * x +const sub = (x: number) => (y: number) => y - x const result = pipe( 1, @@ -124,13 +124,13 @@ the pipeline. ```ts twoslash import { pipe } from "effect" -const add = (x: number) => (y: number) => y + x; -const mul = (x: number) => (y: number) => y * x; -const sub = (x: number) => (y: number) => y - x; +const add = (x: number) => (y: number) => y + x +const mul = (x: number) => (y: number) => y * x +const sub = (x: number) => (y: number) => y - x const log = (s: string) => (y: number) => { - console.log(s, y); - return y; -}; + console.log(s, y) + return y +} const result = pipe( 1, @@ -140,7 +140,7 @@ const result = pipe( log(" * 3 ="), sub(1), log(" - 1 ="), -); +) ``` This prints the following, with `result` still being 8: @@ -174,11 +174,31 @@ Then you can run the server with: $ bun index.ts ``` -And in another shell we can test the endpoint: +And in another shell we can test the endpoint. I'm using +[HTTPie](https://httpie.io/cli) throughout this tutorial, because the output is +nicer than `curl`. ```shell -$ curl http://localhost:3000/users -{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}⏎ +$ http get localhost:3000/users +HTTP/1.1 200 OK +Content-Length: 57 +Content-Type: application/json charset=utf-8 +Date: Fri, 07 Feb 2025 11:36:19 GMT +ETag: W/"39-6Qu85qIU12mhgGYWwHErQVfJRxI" +X-Powered-By: Express + +{ + "users": [ + { + "id": 1, + "name": "Alice" + }, + { + "id": 2, + "name": "Bob" + } + ] +} ``` ## The direct approach @@ -192,8 +212,8 @@ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Database } from "bun:sqlite"; -const db = new Database("sqlite.db"); +import { Database } from "bun:sqlite" +const db = new Database("sqlite.db") export const health = Effect.sync(() => { return HttpServerResponse.text("ok") }) @@ -260,8 +280,8 @@ import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Database } from "bun:sqlite"; -const db = new Database("sqlite.db"); +import { Database } from "bun:sqlite" +const db = new Database("sqlite.db") //---cut--- const users = Effect.sync(() => { // ^? @@ -282,6 +302,8 @@ Under the hood, a `Handler` is an `Effect`. Because `Effect` is not an `HttpServerResponse`, the type checker gets upset with us. +## Fixing our nested Effects with `flatMap` + It's possible to fix this with a call to `Effect.flatMap`: ```ts twoslash @@ -289,15 +311,15 @@ import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Database } from "bun:sqlite"; -const db = new Database("sqlite.db"); +import { Database } from "bun:sqlite" +const db = new Database("sqlite.db") //---cut--- const users = Effect.sync(() => { return Effect.flatMap( Effect.succeed({ users: db.prepare("SELECT * FROM users").all() }), HttpServerResponse.json, - ); -}); + ) +}) ``` But it is more idiomatic in Effect to use `pipe`: @@ -307,17 +329,105 @@ import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Database } from "bun:sqlite"; -const db = new Database("sqlite.db"); +import { Database } from "bun:sqlite" +const db = new Database("sqlite.db") //---cut--- const users = Effect.sync(() => { return { users: db.prepare("SELECT * FROM users").all(), - }; -}).pipe(Effect.flatMap(HttpServerResponse.json)); + } +}).pipe(Effect.flatMap(HttpServerResponse.json)) ``` This no longer gives us a type error. We've split our computation into two parts: fetching the data with `Effect.sync`, and serializing it with `HttpServerResponse.json`. The `Effect.flatMap` in the middle of these two takes care of the accidental `Effect` nesting. + +## Adding error handling to our database query + +All this talk of error handling might have gotten you thinking: can't our +database query fail? It can! If the database file doesn't exist, or the query +is malformed, the `db.prepare` call will throw an error. When that happens, +at the moment our endpoint will return a 500 error to the client. + +```shell +$ mv sqlite.db sqlite.db.bak +$ http get "localhost:3000/users" +HTTP/1.1 500 Internal Server Error +Content-Length: 0 +Date: Fri, 07 Feb 2025 11:29:30 GMT +X-Powered-By: Express +``` + +Strictly speaking, a 500 error is correct here. Our server encountered an error +because of a server-side problem, which is what the 500-class errors are for. +But it's not good Effect to let exceptions bubble up like this. + +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" + +import { Database, SQLiteError } from "bun:sqlite" +const db = new Database("sqlite.db") +//---cut--- +const users = Effect.suspend(() => { + try { + return Effect.succeed({ + users: db.prepare("SELECT * FROM users").all(), + }); + } catch (e) { + if (e instanceof SQLiteError) { + return Effect.fail(e); + } + return Effect.die(e); + } +}).pipe(Effect.flatMap(HttpServerResponse.json)); +``` + +We've made a lot of changes here, let's go through them one at a time. + +1. We're using `Effect.suspend` instead of `Effect.sync`. This is because + `Effect.sync` is not allowed to fail. If it does, it's treated as what Effect + calls a "defect", which are a special category of error that represent a bug + in your program. You can read more about them + [here](https://effect.website/docs/error-management/unexpected-errors/). +2. Where `Effect.sync` allowed us to return a value directly, `Effect.suspend` + wants us to return an `Effect`. That's why we're using `Effect.succeed` to + wrap our users object, and `Effect.fail` to wrap our caught exception. +3. We use `Effect.die` to signal a defect explicitly. Because the only type + of exception thrown by `db.prepare` is an `SQLiteError`, any other exception + is unexpected and likely a bug. + +What we get in return for all of these changes it **type safety**. We have told +Effect that our database query can fail, and that information is now encoded +into the type of our `users` handler. + +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" + +import { Database, SQLiteError } from "bun:sqlite" +const db = new Database("sqlite.db") +//---cut--- +const users = Effect.suspend(() => { +// ^? + try { + return Effect.succeed({ + users: db.prepare("SELECT * FROM users").all(), + }); + } catch (e) { + if (e instanceof SQLiteError) { + return Effect.fail(e); + } + return Effect.die(e); + } +}).pipe(Effect.flatMap(HttpServerResponse.json)); +``` + +This is one of the most powerful features of Effect: type safe error handling. +To know that Bun's SQLite connector can only throw an `SQLiteError`, I had to +read the source code. But with our `users` handler, that information is not +only available to us as developers, but to the type checker as well. From 2942edabbb543b178826509c61e98b3c9ffc628c Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 7 Feb 2025 16:24:05 +0000 Subject: [PATCH 08/28] Respond to some more of Max's comments. --- .../docs/learn/tutorials/hooking-up-a-database.mdx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index 88fa0b6b8..0d02224a2 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -314,12 +314,10 @@ import { createServer } from "node:http" import { Database } from "bun:sqlite" const db = new Database("sqlite.db") //---cut--- -const users = Effect.sync(() => { - return Effect.flatMap( - Effect.succeed({ users: db.prepare("SELECT * FROM users").all() }), - HttpServerResponse.json, - ) -}) +const users = Effect.flatMap( + Effect.sync(() => ({ users: db.prepare("SELECT * FROM users").all() })), + HttpServerResponse.json, +); ``` But it is more idiomatic in Effect to use `pipe`: From c80459c048a69db36cd796270ee1cd299c509aca Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 7 Feb 2025 17:20:35 +0000 Subject: [PATCH 09/28] Checkpointing my work for the day. --- .../learn/tutorials/hooking-up-a-database.mdx | 46 ++----------------- 1 file changed, 4 insertions(+), 42 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index 0d02224a2..30ea12f40 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -43,47 +43,6 @@ const router = HttpRouter.empty.pipe( HttpRouter.get("/health", health), ) -NodeRuntime.runMain( - Layer.launch( - Layer.provide( - HttpServer.serve(router), - NodeHttpServer.layer(() => createServer(app), { port: 3000 }), - ), - ), -) -``` - -Before we continue, I'm going to make a modification to this since we learned -about `pipe` in the previous section. We can replace the expression at the -bottom of the file with this: - -```ts twoslash title=index.ts -import express from "express" - -import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" -import { Effect, Layer } from "effect" -import { createServer } from "node:http" - -import { Database } from "bun:sqlite" -const db = new Database("sqlite.db") - -const app = express() - -app.get("/users", (req, res) => { - res.send({ - users: db.prepare("SELECT * FROM users").all(), - }) -}) - -export const health = Effect.sync(() => { - return HttpServerResponse.text("ok") -}) - -const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", health), -) -//---cut--- HttpServer.serve(router).pipe( Layer.provide(NodeHttpServer.layer(() => createServer(app), { port: 3000 })), Layer.launch, @@ -91,6 +50,9 @@ HttpServer.serve(router).pipe( ) ``` +Notice that the `HttpServer.serve` expression at the bottom has been converted +to use `pipe` instead of nesting function calls. + If `pipe` isn't quite clicking for you yet, one way to visualise it is that you're passing some data through a sequence of functions. @@ -121,7 +83,7 @@ Our pipeline starts with 1, then passes it through `add(2)`, `mul(3)`, and One of the powers this gives you is the ability to add things easily into the pipeline. -```ts twoslash +```ts twoslash {6-9,14,16,18} import { pipe } from "effect" const add = (x: number) => (y: number) => y + x From 7aaf3fcd4f033c09fd0e49c435667a07407666cc Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 14 Feb 2025 14:48:31 +0000 Subject: [PATCH 10/28] Convert tutorial to use Postgres. --- content/package.json | 1 + content/pnpm-lock.yaml | 79 +++++ .../docs/learn/tutorials/first-endpoint.mdx | 139 +++++--- .../learn/tutorials/hooking-up-a-database.mdx | 316 ++++++++++-------- .../docs/learn/tutorials/introduction.mdx | 27 +- 5 files changed, 370 insertions(+), 192 deletions(-) diff --git a/content/package.json b/content/package.json index a36fc0ca5..a92792c7a 100644 --- a/content/package.json +++ b/content/package.json @@ -102,6 +102,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-tabs": "^1.1.1", + "@types/pg": "^8.11.11", "cmdk": "1.0.4" } } diff --git a/content/pnpm-lock.yaml b/content/pnpm-lock.yaml index 009900d9b..9af6925a3 100644 --- a/content/pnpm-lock.yaml +++ b/content/pnpm-lock.yaml @@ -28,6 +28,9 @@ importers: '@radix-ui/react-tabs': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/pg': + specifier: ^8.11.11 + version: 8.11.11 cmdk: specifier: 1.0.4 version: 1.0.4(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2335,6 +2338,9 @@ packages: '@types/node@22.9.3': resolution: {integrity: sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==} + '@types/pg@8.11.11': + resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==} + '@types/picomatch@2.3.3': resolution: {integrity: sha512-Yll76ZHikRFCyz/pffKGjrCwe/le2CDwOP5F210KQo27kpRE46U2rDnzikNlVn6/ezH3Mhn46bJMTfeVTtcYMg==} @@ -4104,6 +4110,9 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + obuf@1.1.2: + resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -4195,6 +4204,21 @@ packages: pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-numeric@1.0.2: + resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==} + engines: {node: '>=4'} + + pg-protocol@1.7.1: + resolution: {integrity: sha512-gjTHWGYWsEgy9MsY0Gp6ZJxV24IjDqdpTW7Eh0x+WfJLFsm/TJx1MzL6T0D88mBvkpxotCQ6TwW6N+Kko7lhgQ==} + + pg-types@4.0.2: + resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==} + engines: {node: '>=10'} + picocolors@1.1.0: resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} @@ -4307,6 +4331,25 @@ packages: resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} engines: {node: ^10 || ^12 || >=14} + postgres-array@3.0.2: + resolution: {integrity: sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==} + engines: {node: '>=12'} + + postgres-bytea@3.0.0: + resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==} + engines: {node: '>= 6'} + + postgres-date@2.1.0: + resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==} + engines: {node: '>=12'} + + postgres-interval@3.0.0: + resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==} + engines: {node: '>=12'} + + postgres-range@1.1.4: + resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} + preferred-pm@4.0.0: resolution: {integrity: sha512-gYBeFTZLu055D8Vv3cSPox/0iTPtkzxpLroSYYA7WXgRi31WCJ51Uyl8ZiPeUUjyvs2MBzK+S8v9JVUgHU/Sqw==} engines: {node: '>=18.12'} @@ -7146,6 +7189,12 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/pg@8.11.11': + dependencies: + '@types/node': 22.9.3 + pg-protocol: 1.7.1 + pg-types: 4.0.2 + '@types/picomatch@2.3.3': {} '@types/prop-types@15.7.13': {} @@ -9497,6 +9546,8 @@ snapshots: object-hash@3.0.0: {} + obuf@1.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -9607,6 +9658,22 @@ snapshots: pathe@1.1.2: {} + pg-int8@1.0.1: {} + + pg-numeric@1.0.2: {} + + pg-protocol@1.7.1: {} + + pg-types@4.0.2: + dependencies: + pg-int8: 1.0.1 + pg-numeric: 1.0.2 + postgres-array: 3.0.2 + postgres-bytea: 3.0.0 + postgres-date: 2.1.0 + postgres-interval: 3.0.0 + postgres-range: 1.1.4 + picocolors@1.1.0: {} picocolors@1.1.1: {} @@ -9705,6 +9772,18 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@3.0.2: {} + + postgres-bytea@3.0.0: + dependencies: + obuf: 1.1.2 + + postgres-date@2.1.0: {} + + postgres-interval@3.0.0: {} + + postgres-range@1.1.4: {} + preferred-pm@4.0.0: dependencies: find-up-simple: 1.0.0 diff --git a/content/src/content/docs/learn/tutorials/first-endpoint.mdx b/content/src/content/docs/learn/tutorials/first-endpoint.mdx index ffba1d9cf..4db41e18e 100644 --- a/content/src/content/docs/learn/tutorials/first-endpoint.mdx +++ b/content/src/content/docs/learn/tutorials/first-endpoint.mdx @@ -8,63 +8,54 @@ sidebar: order: 2 --- -The first step to migrating this to Effect is to launch the Express.js server -with Effect instead of the traditional `app.listen`. Because the Express.js -`app` is a function that returns a node `http.Server` under the hood, it slots -nicely into Effect's `HttpServer` abstraction. +The first step to migrating our Express.js app to Effect is to add some +boilerplate that will allow us to write Effect endpoints that live alongside +Express.js. -```ts twoslash {3-6, 14-21} title=index.ts -import express from "express" +```ts twoslash {3-5,13-21} title=index.ts +import express from "express"; -import { HttpRouter, HttpServer } from "@effect/platform" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" -import { Layer } from "effect" -import { createServer } from "node:http" +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { HttpRouter } from "@effect/platform"; +import { Effect } from "effect"; -const app = express() +const app = express(); app.get("/health", (req, res) => { - res.type("text/plain").send("ok") -}) + res.type("text/plain").send("ok"); +}); -NodeRuntime.runMain( - Layer.launch( - Layer.provide( - HttpServer.serve(HttpRouter.empty), - NodeHttpServer.layer(() => createServer(app), { port: 3000 }), - ), - ), -) +const router = HttpRouter.empty; + +const main = Effect.gen(function* () { + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000); + yield* Effect.never; +}); + +NodeRuntime.runMain(main); ``` And we can run the resulting code like so: ```shell -$ bun add @effect/platform @effect/platform-node -$ bun index.ts +bun add @effect/platform @effect/platform-node +bun index.ts ``` -We haven't changed anything about how the server behaves. It still exposes a -single endpoint, `http://localhost:3000/health`, and that still returns `ok`. -But we've wrapped the Express.js app in a way that will allow us to define new -endpoints using Effect while still responding to our existing endpoints in -Express.js. +Our Express.js `/health` handler still works as expected, but we've wrapped the +Express.js app in a way that will allow us to define endpoints using Effect +while still responding to our existing endpoints in Express.js. -There's a lot of new things happening at the bottom of our file. For the time -being we're going to ignore most of it and focus on migrating an endpoint to -Effect. I promise by the time we're done, you'll understand every line we added -above. +There's a lot of new things happening at the bottom of our file. What is +`Effect.gen`, what are those `yield*`s doing, what is `Effect.never`. For the +time being we're going to set these questions aside. It looks intimidating now, +but if you stick with this tutorial to the end, it will not only make sense, but +start to become second nature. -First, let's break out our router, `HttpRouter.empty`, out into its own variable -so it's easier to add to. - -```ts twoslash -import { HttpRouter } from "@effect/platform" -// ---cut--- -const router = HttpRouter.empty -``` +## Our new `/health` handler -And let's define the function we want to run when we hit the `/health` endpoint: +The Effect equivalent of our Express.js `/health` handler looks like this: ```ts twoslash import { HttpServerResponse } from "@effect/platform" @@ -75,10 +66,10 @@ function health() { ``` `HttpServerResponse` is Effect's class for generating HTTP responses, we need to -use it for any responses returned from our endpoints defined with Effect. +use it for any responses returned from Effect. -The way we wire these things together is going to look a bit strange, but bear -with me. +The way we wire this into our Effect `router` is going to look a little bit +strange, but please stay with me: ```ts twoslash import { HttpServerResponse, HttpRouter } from "@effect/platform" @@ -114,7 +105,7 @@ synchronous function instead of an asynchronous one. If we want to run this Effect, we can use `Effect.runPromise`: -```ts twoslash {2} +```ts twoslash import { Effect } from "effect" // ---cut--- const effect = Effect.promise(async () => "Hello, world!") @@ -126,7 +117,7 @@ console.log(result) What if you want to run an Effect that calls another Effect? You can use `Effect.gen` to do that: -```ts twoslash {2-5} +```ts twoslash {2-5,8} import { Effect } from "effect" // ---cut--- const effect = Effect.promise(async () => "Hello, world!") @@ -164,7 +155,7 @@ provided. We can `pipe` as many `Effect`s together as we like: -```ts twoslash {2-7} +```ts twoslash {2-7,10} import { Effect } from "effect" // ---cut--- const effect = Effect.promise(async () => "Hello, world!") @@ -259,4 +250,58 @@ HttpRouter.empty.pipe( ) ``` +## The end result + +Here's the full code of our app with our `/health` endpoint being served by +Effect: + +```ts twoslash {9-15} +import express from "express"; + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { HttpRouter, HttpServerResponse } from "@effect/platform"; +import { Effect } from "effect"; + +const app = express(); + +function health() { + return HttpServerResponse.text("ok"); +} + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)) +); + +const main = Effect.gen(function* () { + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000); + yield* Effect.never; +}); + +NodeRuntime.runMain(main); +``` + +And the output of the endpoint: + +```shell +http get localhost:3000/health +``` + +```http +HTTP/1.1 200 OK +Content-Length: 2 +Content-Type: text/plain +Date: Fri, 14 Feb 2025 13:51:39 GMT +X-Powered-By: Express + +ok +``` + +This output has 2 small differences from the Express.js version: + +1. The `Content-Type` header is `text/plain` instead of `text/plain; charset=utf-8`. +2. The `ETag` header is missing. + +TODO(samwho): explain how to close this gap. + [1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function* diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index 30ea12f40..36a6f1aee 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -9,143 +9,102 @@ sidebar: --- The next endpoint we're going to migrate is one that lists all of the users in a -database. For illustrative purposes, we're going to use SQLite using Bun's -built-in connector, but the technique we'll cover will work for -whatever database you're using, and however you're connecting to it. +database. For this tutorial I'm going to use Postgres running in Docker as our +database. If you don't have Docker installed, I recommend following the +[official getting started guide](https://www.docker.com/get-started/). -First, let's take a look at our app in full with our new endpoint added to -it: +When you're ready, you can start a Postgres container with: -```ts twoslash {8-9,13-17} title=index.ts -import express from "express" - -import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" -import { Effect, Layer } from "effect" -import { createServer } from "node:http" - -import { Database } from "bun:sqlite" -const db = new Database("sqlite.db") - -const app = express() - -app.get("/users", (req, res) => { - res.send({ - users: db.prepare("SELECT * FROM users").all(), - }) -}) +```shell +docker run -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 postgres +``` -export const health = Effect.sync(() => { - return HttpServerResponse.text("ok") -}) +## Creating our data -const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", health), -) +To create our `users` table and sample data, we're going to use the `psql` +tool that's present inside of the Docker container we created. Open a new +terminal and run: -HttpServer.serve(router).pipe( - Layer.provide(NodeHttpServer.layer(() => createServer(app), { port: 3000 })), - Layer.launch, - NodeRuntime.runMain -) +```shell +docker exec -it $(docker ps -qf ancestor=postgres) psql -U postgres ``` -Notice that the `HttpServer.serve` expression at the bottom has been converted -to use `pipe` instead of nesting function calls. +This will drop you into a `psql` shell, with a prompt that starts `postgres=#`. +From here you can run the following SQL commands: -If `pipe` isn't quite clicking for you yet, one way to visualise it is that -you're passing some data through a sequence of functions. +```sql +CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT); +INSERT INTO users (name) VALUES ('Alice'); +INSERT INTO users (name) VALUES ('Bob'); +``` -Take this example: +## The `/users` endpoint -```ts twoslash -import { pipe } from "effect" +Let's take a look at our app in full with new Express.js `/users` endpoint added +to it: -const add = (x: number) => (y: number) => y + x -const mul = (x: number) => (y: number) => y * x -const sub = (x: number) => (y: number) => y - x +```ts twoslash {6,8-12,16-19,30} title=index.ts +import express from "express"; -const result = pipe( - 1, - add(2), - mul(3), - sub(1) -) -console.log(result) -//=> 8 -``` +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { HttpRouter, HttpServerResponse } from "@effect/platform"; +import { Effect } from "effect"; +import { Client } from "pg"; -`add`, `mul`, and `sub` are all functions that return functions. So `add(2)` -returns a function that can add 2 to a given number: `add(2)(1)` returns 3. -Our pipeline starts with 1, then passes it through `add(2)`, `mul(3)`, and -`sub(1)`, resulting in 8. +const db = new Client({ + host: "localhost", + user: "postgres", + password: "mysecretpassword", +}); -One of the powers this gives you is the ability to add things easily into -the pipeline. +const app = express(); -```ts twoslash {6-9,14,16,18} -import { pipe } from "effect" +app.get("/users", async (req, res) => { + const { rows } = await db.query("SELECT * FROM users"); + res.send({ users: rows }); +}); -const add = (x: number) => (y: number) => y + x -const mul = (x: number) => (y: number) => y * x -const sub = (x: number) => (y: number) => y - x -const log = (s: string) => (y: number) => { - console.log(s, y) - return y +function health() { + return HttpServerResponse.text("ok"); } -const result = pipe( - 1, - add(2), - log("1 + 2 ="), - mul(3), - log(" * 3 ="), - sub(1), - log(" - 1 ="), -) -``` +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)) +); -This prints the following, with `result` still being 8: +const main = Effect.gen(function* () { + yield* Effect.promise(() => db.connect()); + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000); + yield* Effect.never; +}); -``` -1 + 2 = 3 - * 3 = 9 - - 1 = 8 +NodeRuntime.runMain(main); ``` -As we migrate more complex endpoints, we'll see how this ability to "tap into" -the pipeline is really useful. - -## Creating our data - -To create our example `sqlite.db` file, make sure you have the `sqlite3` CLI -command installed. You can this either by downloading binaries from the -[official website](https://www.sqlite.org/download.html) or by using a package -manager like `brew` on macOS or `apt` on Ubuntu. +We'll also need to add some new dependencies to our project: ```shell -$ sqlite3 sqlite.db -sqlite> CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT); -sqlite> INSERT INTO users (name) VALUES ("Alice"); -sqlite> INSERT INTO users (name) VALUES ("Bob"); +bun add pg @types/pg ``` Then you can run the server with: ```shell -$ bun index.ts +bun index.ts ``` -And in another shell we can test the endpoint. I'm using -[HTTPie](https://httpie.io/cli) throughout this tutorial, because the output is -nicer than `curl`. +And in another shell we can test the endpoint. ```shell -$ http get localhost:3000/users +http get localhost:3000/users +``` + +```http HTTP/1.1 200 OK Content-Length: 57 -Content-Type: application/json charset=utf-8 -Date: Fri, 07 Feb 2025 11:36:19 GMT +Content-Type: application/json; charset=utf-8 +Date: Fri, 14 Feb 2025 14:28:02 GMT ETag: W/"39-6Qu85qIU12mhgGYWwHErQVfJRxI" X-Powered-By: Express @@ -173,19 +132,21 @@ import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform" import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" import { Effect, Layer } from "effect" import { createServer } from "node:http" - -import { Database } from "bun:sqlite" -const db = new Database("sqlite.db") -export const health = Effect.sync(() => { +import { Client } from "pg"; +const db = new Client({ + host: "localhost", + user: "postgres", + password: "mysecretpassword", +}); +const health = Effect.sync(() => { return HttpServerResponse.text("ok") }) //---cut--- // @errors: 2379 -const users = Effect.sync(() => { - return HttpServerResponse.json({ - users: db.prepare("SELECT * FROM users").all(), - }) -}) +const users = Effect.promise(async () => { + const { rows } = await db.query("SELECT * FROM users"); + return HttpServerResponse.json({ users: rows }); +}); const router = HttpRouter.empty.pipe( HttpRouter.get("/health", health), @@ -210,9 +171,9 @@ so just returns an `HttpServerResponse` directly. When we introduced the `Effect` type in the previous section, we dealt exclusively with Effects that couldn't fail. That won't get us very far if -we're building anything non-trivial, so let's talk about error handling. +we're building anything non-trivial. -`Effect` is a generic type that takes 3 type arguments: +`Effect` is a generic type that takes 3 arguments: ```typescript Effect @@ -241,16 +202,18 @@ it from our `users` handler, we end up wrapping one Effect in another. import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" - -import { Database } from "bun:sqlite" -const db = new Database("sqlite.db") +import { Client } from "pg"; +const db = new Client({ + host: "localhost", + user: "postgres", + password: "mysecretpassword", +}); //---cut--- -const users = Effect.sync(() => { +const users = Effect.promise(async () => { // ^? - return HttpServerResponse.json({ - users: db.prepare("SELECT * FROM users").all(), - }) -}) + const { rows } = await db.query("SELECT * FROM users"); + return HttpServerResponse.json({ users: rows }); +}); ``` This is what the type error from earlier was trying to tell us. Here's the @@ -272,13 +235,19 @@ It's possible to fix this with a call to `Effect.flatMap`: import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" - -import { Database } from "bun:sqlite" -const db = new Database("sqlite.db") +import { Client } from "pg"; +const db = new Client({ + host: "localhost", + user: "postgres", + password: "mysecretpassword", +}); //---cut--- const users = Effect.flatMap( - Effect.sync(() => ({ users: db.prepare("SELECT * FROM users").all() })), - HttpServerResponse.json, + Effect.promise(async () => { + const { rows } = await db.query("SELECT * FROM users"); + return { users: rows }; + }), + HttpServerResponse.json ); ``` @@ -288,21 +257,90 @@ But it is more idiomatic in Effect to use `pipe`: import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" - -import { Database } from "bun:sqlite" -const db = new Database("sqlite.db") +import { Client } from "pg"; +const db = new Client({ + host: "localhost", + user: "postgres", + password: "mysecretpassword", +}); //---cut--- -const users = Effect.sync(() => { - return { - users: db.prepare("SELECT * FROM users").all(), - } -}).pipe(Effect.flatMap(HttpServerResponse.json)) +const users = Effect.promise( + () => db.query("SELECT * FROM users") +).pipe( + Effect.map(({ rows }) => ({ users: rows })), + Effect.flatMap(HttpServerResponse.json) +); +``` + +This no longer gives us a type error. We've split our computation into parts: +fetching the data with `Effect.promise`, manipulating its shape with +`Effect.map`, and serializing it with `HttpServerResponse.json`. The +`Effect.flatMap` at the end care of the accidental `Effect` nesting. + +## An aside on pipes + +If `pipe` isn't quite clicking for you yet, one way to visualise it is that +you're passing some data through a sequence of functions. + +Take this example: + +```ts twoslash +import { pipe } from "effect" + +const add = (x: number) => (y: number) => y + x +const mul = (x: number) => (y: number) => y * x +const sub = (x: number) => (y: number) => y - x + +const result = pipe( + 1, + add(2), + mul(3), + sub(1) +) +console.log(result) +//=> 8 +``` + +`add`, `mul`, and `sub` are all functions that return functions. So `add(2)` +returns a function that can add 2 to a given number: `add(2)(1)` returns 3. +Our pipeline starts with 1, then passes it through `add(2)`, `mul(3)`, and +`sub(1)`, resulting in 8. + +One of the powers this gives you is the ability to add things easily into +the pipeline. + +```ts twoslash {6-9,14,16,18} +import { pipe } from "effect" + +const add = (x: number) => (y: number) => y + x +const mul = (x: number) => (y: number) => y * x +const sub = (x: number) => (y: number) => y - x +const log = (s: string) => (y: number) => { + console.log(s, y) + return y +} + +const result = pipe( + 1, + add(2), + log("1 + 2 ="), + mul(3), + log(" * 3 ="), + sub(1), + log(" - 1 ="), +) ``` -This no longer gives us a type error. We've split our computation into two -parts: fetching the data with `Effect.sync`, and serializing it with -`HttpServerResponse.json`. The `Effect.flatMap` in the middle of these two -takes care of the accidental `Effect` nesting. +This prints the following, with `result` still being 8: + +``` +1 + 2 = 3 + * 3 = 9 + - 1 = 8 +``` + +As we migrate more complex endpoints, we'll see how this ability to "tap into" +the pipeline is really useful. ## Adding error handling to our database query @@ -390,4 +428,4 @@ const users = Effect.suspend(() => { This is one of the most powerful features of Effect: type safe error handling. To know that Bun's SQLite connector can only throw an `SQLiteError`, I had to read the source code. But with our `users` handler, that information is not -only available to us as developers, but to the type checker as well. +only available to us as developers, but to the type checker as well. \ No newline at end of file diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index 8621c33f7..3afaf03fa 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -37,18 +37,33 @@ Create a new TypeScript project using your favourite package manager. I'm using `bun`: ```shell -$ mkdir express-to-effect -$ cd express-to-effect -$ bun init +mkdir express-to-effect +cd express-to-effect +bun init ``` Save our Express.js app into `index.ts` and run it to make sure it works: ```shell -$ bun add express -$ bun add -D @types/express -$ bun index.ts +bun add express +bun add -D @types/express +bun index.ts ``` You should see `Server is running on http://localhost:3000` in your terminal, and visiting `http://localhost:3000/health` should return `ok`. + +```http +$ http get localhost:3000/health +HTTP/1.1 200 OK +Content-Length: 2 +Content-Type: text/plain; charset=utf-8 +Date: Fri, 14 Feb 2025 13:48:43 GMT +ETag: W/"2-eoX0dku9ba8cNUXvu/DyeabcC+s" +X-Powered-By: Express + +ok +``` + +I'm using [HTTPie](https://httpie.io/cli) throughout this tutorial, because the +output is nicer than `curl`. \ No newline at end of file From efbf92878377fa6cf3dc62f0759bcbfe967c69bb Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 14 Feb 2025 17:48:00 +0000 Subject: [PATCH 11/28] EOD checkpoint. --- .../learn/tutorials/hooking-up-a-database.mdx | 134 +++++++----------- 1 file changed, 49 insertions(+), 85 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index 36a6f1aee..ad9b1ec1b 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -16,7 +16,7 @@ database. If you don't have Docker installed, I recommend following the When you're ready, you can start a Postgres container with: ```shell -docker run -e POSTGRES_PASSWORD=mysecretpassword -p 5432:5432 postgres +docker run -e POSTGRES_HOST_AUTH_METHOD=trust -p 5432:5432 postgres ``` ## Creating our data @@ -43,7 +43,7 @@ INSERT INTO users (name) VALUES ('Bob'); Let's take a look at our app in full with new Express.js `/users` endpoint added to it: -```ts twoslash {6,8-12,16-19,30} title=index.ts +```ts twoslash {6,8,11-14,25} title=index.ts import express from "express"; import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; @@ -51,12 +51,7 @@ import { HttpRouter, HttpServerResponse } from "@effect/platform"; import { Effect } from "effect"; import { Client } from "pg"; -const db = new Client({ - host: "localhost", - user: "postgres", - password: "mysecretpassword", -}); - +const db = new Client({ user: "postgres" }); const app = express(); app.get("/users", async (req, res) => { @@ -133,11 +128,7 @@ import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" import { Effect, Layer } from "effect" import { createServer } from "node:http" import { Client } from "pg"; -const db = new Client({ - host: "localhost", - user: "postgres", - password: "mysecretpassword", -}); +const db = new Client({ user: "postgres" }); const health = Effect.sync(() => { return HttpServerResponse.text("ok") }) @@ -203,11 +194,7 @@ import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" import { Client } from "pg"; -const db = new Client({ - host: "localhost", - user: "postgres", - password: "mysecretpassword", -}); +const db = new Client({ user: "postgres" }); //---cut--- const users = Effect.promise(async () => { // ^? @@ -236,11 +223,7 @@ import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" import { Client } from "pg"; -const db = new Client({ - host: "localhost", - user: "postgres", - password: "mysecretpassword", -}); +const db = new Client({ user: "postgres" }); //---cut--- const users = Effect.flatMap( Effect.promise(async () => { @@ -258,11 +241,7 @@ import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" import { Client } from "pg"; -const db = new Client({ - host: "localhost", - user: "postgres", - password: "mysecretpassword", -}); +const db = new Client({ user: "postgres" }); //---cut--- const users = Effect.promise( () => db.query("SELECT * FROM users") @@ -345,16 +324,18 @@ the pipeline is really useful. ## Adding error handling to our database query All this talk of error handling might have gotten you thinking: can't our -database query fail? It can! If the database file doesn't exist, or the query -is malformed, the `db.prepare` call will throw an error. When that happens, -at the moment our endpoint will return a 500 error to the client. +database query fail? It can! If the query is malformed, `db.query` will throw an +error. When that happens, at the moment our endpoint will return a 500 error to +the client. Change the SQL query to `SELECT * FROM nonexistnet` and try it: ```shell -$ mv sqlite.db sqlite.db.bak -$ http get "localhost:3000/users" +http get "localhost:3000/users" +``` + +```http HTTP/1.1 500 Internal Server Error Content-Length: 0 -Date: Fri, 07 Feb 2025 11:29:30 GMT +Date: Fri, 14 Feb 2025 17:25:57 GMT X-Powered-By: Express ``` @@ -362,70 +343,53 @@ Strictly speaking, a 500 error is correct here. Our server encountered an error because of a server-side problem, which is what the 500-class errors are for. But it's not good Effect to let exceptions bubble up like this. +A simple thing we can do is changing `Effect.promise` to `Effect.tryPromise`: + ```ts twoslash import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" - -import { Database, SQLiteError } from "bun:sqlite" -const db = new Database("sqlite.db") +import { Client } from "pg"; +const db = new Client({ user: "postgres" }); //---cut--- -const users = Effect.suspend(() => { - try { - return Effect.succeed({ - users: db.prepare("SELECT * FROM users").all(), - }); - } catch (e) { - if (e instanceof SQLiteError) { - return Effect.fail(e); - } - return Effect.die(e); - } -}).pipe(Effect.flatMap(HttpServerResponse.json)); +const users = Effect.tryPromise( + () => db.query("SELECT * FROM users") +).pipe( + Effect.map(({ rows }) => ({ users: rows })), + Effect.flatMap(HttpServerResponse.json) +); +``` + +This changes the return type from: + +```typescript +Effect ``` -We've made a lot of changes here, let's go through them one at a time. +To: + +```typescript +Effect +``` -1. We're using `Effect.suspend` instead of `Effect.sync`. This is because - `Effect.sync` is not allowed to fail. If it does, it's treated as what Effect - calls a "defect", which are a special category of error that represent a bug - in your program. You can read more about them - [here](https://effect.website/docs/error-management/unexpected-errors/). -2. Where `Effect.sync` allowed us to return a value directly, `Effect.suspend` - wants us to return an `Effect`. That's why we're using `Effect.succeed` to - wrap our users object, and `Effect.fail` to wrap our caught exception. -3. We use `Effect.die` to signal a defect explicitly. Because the only type - of exception thrown by `db.prepare` is an `SQLiteError`, any other exception - is unexpected and likely a bug. +`Effect.promise` is assumed to always succeed. `Effect.tryPromise` is assumed +to maybe throw an exception, but because TypeScript doesn't give us any way +to know _what_ exception might be thrown, Effect defaults to `UnknownException`. -What we get in return for all of these changes it **type safety**. We have told -Effect that our database query can fail, and that information is now encoded -into the type of our `users` handler. +We can improve on this: ```ts twoslash import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" - -import { Database, SQLiteError } from "bun:sqlite" -const db = new Database("sqlite.db") +import { Client } from "pg"; +const db = new Client({ user: "postgres" }); //---cut--- -const users = Effect.suspend(() => { -// ^? - try { - return Effect.succeed({ - users: db.prepare("SELECT * FROM users").all(), - }); - } catch (e) { - if (e instanceof SQLiteError) { - return Effect.fail(e); - } - return Effect.die(e); - } -}).pipe(Effect.flatMap(HttpServerResponse.json)); +const users = Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (error) => error, +}).pipe( + Effect.map(({ rows }) => ({ users: rows })), + Effect.flatMap(HttpServerResponse.json) +); ``` - -This is one of the most powerful features of Effect: type safe error handling. -To know that Bun's SQLite connector can only throw an `SQLiteError`, I had to -read the source code. But with our `users` handler, that information is not -only available to us as developers, but to the type checker as well. \ No newline at end of file From d91694bf53afe2edac8e1c9ccb4eb3170c1dc34e Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 21 Feb 2025 13:21:29 +0000 Subject: [PATCH 12/28] Update content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx Co-authored-by: Maxwell Brown --- .../src/content/docs/learn/tutorials/hooking-up-a-database.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index ad9b1ec1b..d54b8b97b 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -210,7 +210,7 @@ error again: Argument of type 'Effect, never, never>' is not assignable to parameter of type 'Handler' ``` -Under the hood, a `Handler` is an `Effect` is just a type alias for `Effect`. Because `Effect` is not an `HttpServerResponse`, the type checker gets upset with us. From 4fe4477fd642b72df413da553597f562176642f0 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 21 Feb 2025 13:21:38 +0000 Subject: [PATCH 13/28] Update content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx Co-authored-by: Maxwell Brown --- .../content/docs/learn/tutorials/hooking-up-a-database.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index d54b8b97b..aa4f47743 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -325,8 +325,8 @@ the pipeline is really useful. All this talk of error handling might have gotten you thinking: can't our database query fail? It can! If the query is malformed, `db.query` will throw an -error. When that happens, at the moment our endpoint will return a 500 error to -the client. Change the SQL query to `SELECT * FROM nonexistnet` and try it: +error. When that happens, our endpoint will currently return a 500 error to +the client. Change the SQL query to `SELECT * FROM nonexistent` and try it: ```shell http get "localhost:3000/users" From 5605e97bf2dd21b2dcbe898b19adba3222d67732 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 21 Feb 2025 13:21:46 +0000 Subject: [PATCH 14/28] Update content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx Co-authored-by: Maxwell Brown --- .../content/docs/learn/tutorials/hooking-up-a-database.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index aa4f47743..3cd0ec05b 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -179,8 +179,8 @@ the type `Effect`. ```ts twoslash import { Effect } from "effect" //---cut--- -Effect.sync(() => "Hello, world!") -//=> Effect.Effect +const _ = Effect.sync(() => "Hello, world!") +// ^? ``` This means they will return a `string` on success, and never fail. From 093d1a102b5beab397388b29aa3da2bfb3a6ed6e Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 21 Feb 2025 13:21:53 +0000 Subject: [PATCH 15/28] Update content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx Co-authored-by: Maxwell Brown --- .../src/content/docs/learn/tutorials/hooking-up-a-database.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index 3cd0ec05b..a2a2e7845 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -254,7 +254,7 @@ const users = Effect.promise( This no longer gives us a type error. We've split our computation into parts: fetching the data with `Effect.promise`, manipulating its shape with `Effect.map`, and serializing it with `HttpServerResponse.json`. The -`Effect.flatMap` at the end care of the accidental `Effect` nesting. +`Effect.flatMap` at the end takes care of the accidental `Effect` nesting we had in our previous implementation. ## An aside on pipes From 4652c0aa535c4ed0ff440837d1939ee359d497b2 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 21 Feb 2025 15:41:49 +0000 Subject: [PATCH 16/28] Round off the 'hooking up a database' page. --- .../docs/learn/tutorials/first-endpoint.mdx | 54 ++--- .../learn/tutorials/hooking-up-a-database.mdx | 210 ++++++++++++++---- 2 files changed, 192 insertions(+), 72 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/first-endpoint.mdx b/content/src/content/docs/learn/tutorials/first-endpoint.mdx index 4db41e18e..a51545787 100644 --- a/content/src/content/docs/learn/tutorials/first-endpoint.mdx +++ b/content/src/content/docs/learn/tutorials/first-endpoint.mdx @@ -13,27 +13,27 @@ boilerplate that will allow us to write Effect endpoints that live alongside Express.js. ```ts twoslash {3-5,13-21} title=index.ts -import express from "express"; +import express from "express" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; -import { HttpRouter } from "@effect/platform"; -import { Effect } from "effect"; +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { HttpRouter } from "@effect/platform" +import { Effect } from "effect" -const app = express(); +const app = express() app.get("/health", (req, res) => { - res.type("text/plain").send("ok"); -}); + res.type("text/plain").send("ok") +}) -const router = HttpRouter.empty; +const router = HttpRouter.empty const main = Effect.gen(function* () { - app.use(yield* NodeHttpServer.makeHandler(router)); - app.listen(3000); - yield* Effect.never; -}); + app.use(yield* NodeHttpServer.makeHandler(router)) + app.listen(3000) + yield* Effect.never +}) -NodeRuntime.runMain(main); +NodeRuntime.runMain(main) ``` And we can run the resulting code like so: @@ -109,7 +109,7 @@ If we want to run this Effect, we can use `Effect.runPromise`: import { Effect } from "effect" // ---cut--- const effect = Effect.promise(async () => "Hello, world!") -const result = await Effect.runPromise(effect); +const result = await Effect.runPromise(effect) console.log(result) // => "Hello, world!" ``` @@ -188,7 +188,7 @@ function health() { // ---cut--- const router = HttpRouter.empty.pipe( HttpRouter.get("/health", Effect.sync(health)), -); +) ``` `HttpRouter` is a data structure that represents a collection of routes. The @@ -256,29 +256,29 @@ Here's the full code of our app with our `/health` endpoint being served by Effect: ```ts twoslash {9-15} -import express from "express"; +import express from "express" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; -import { HttpRouter, HttpServerResponse } from "@effect/platform"; -import { Effect } from "effect"; +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" -const app = express(); +const app = express() function health() { - return HttpServerResponse.text("ok"); + return HttpServerResponse.text("ok") } const router = HttpRouter.empty.pipe( HttpRouter.get("/health", Effect.sync(health)) -); +) const main = Effect.gen(function* () { - app.use(yield* NodeHttpServer.makeHandler(router)); - app.listen(3000); - yield* Effect.never; -}); + app.use(yield* NodeHttpServer.makeHandler(router)) + app.listen(3000) + yield* Effect.never +}) -NodeRuntime.runMain(main); +NodeRuntime.runMain(main) ``` And the output of the endpoint: diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index a2a2e7845..769c5dba9 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -44,37 +44,37 @@ Let's take a look at our app in full with new Express.js `/users` endpoint added to it: ```ts twoslash {6,8,11-14,25} title=index.ts -import express from "express"; +import express from "express" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; -import { HttpRouter, HttpServerResponse } from "@effect/platform"; -import { Effect } from "effect"; -import { Client } from "pg"; +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +import { Client } from "pg" -const db = new Client({ user: "postgres" }); -const app = express(); +const db = new Client({ user: "postgres" }) +const app = express() app.get("/users", async (req, res) => { - const { rows } = await db.query("SELECT * FROM users"); - res.send({ users: rows }); -}); + const { rows } = await db.query("SELECT * FROM users") + res.send({ users: rows }) +}) function health() { - return HttpServerResponse.text("ok"); + return HttpServerResponse.text("ok") } const router = HttpRouter.empty.pipe( HttpRouter.get("/health", Effect.sync(health)) -); +) const main = Effect.gen(function* () { - yield* Effect.promise(() => db.connect()); - app.use(yield* NodeHttpServer.makeHandler(router)); - app.listen(3000); - yield* Effect.never; -}); + yield* Effect.promise(() => db.connect()) + app.use(yield* NodeHttpServer.makeHandler(router)) + app.listen(3000) + yield* Effect.never +}) -NodeRuntime.runMain(main); +NodeRuntime.runMain(main) ``` We'll also need to add some new dependencies to our project: @@ -127,17 +127,17 @@ import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform" import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Client } from "pg"; -const db = new Client({ user: "postgres" }); +import { Client } from "pg" +const db = new Client({ user: "postgres" }) const health = Effect.sync(() => { return HttpServerResponse.text("ok") }) //---cut--- // @errors: 2379 const users = Effect.promise(async () => { - const { rows } = await db.query("SELECT * FROM users"); - return HttpServerResponse.json({ users: rows }); -}); + const { rows } = await db.query("SELECT * FROM users") + return HttpServerResponse.json({ users: rows }) +}) const router = HttpRouter.empty.pipe( HttpRouter.get("/health", health), @@ -180,7 +180,7 @@ the type `Effect`. import { Effect } from "effect" //---cut--- const _ = Effect.sync(() => "Hello, world!") -// ^? +// ^? ``` This means they will return a `string` on success, and never fail. @@ -193,14 +193,14 @@ it from our `users` handler, we end up wrapping one Effect in another. import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Client } from "pg"; -const db = new Client({ user: "postgres" }); +import { Client } from "pg" +const db = new Client({ user: "postgres" }) //---cut--- const users = Effect.promise(async () => { // ^? - const { rows } = await db.query("SELECT * FROM users"); - return HttpServerResponse.json({ users: rows }); -}); + const { rows } = await db.query("SELECT * FROM users") + return HttpServerResponse.json({ users: rows }) +}) ``` This is what the type error from earlier was trying to tell us. Here's the @@ -222,16 +222,16 @@ It's possible to fix this with a call to `Effect.flatMap`: import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Client } from "pg"; -const db = new Client({ user: "postgres" }); +import { Client } from "pg" +const db = new Client({ user: "postgres" }) //---cut--- const users = Effect.flatMap( Effect.promise(async () => { - const { rows } = await db.query("SELECT * FROM users"); - return { users: rows }; + const { rows } = await db.query("SELECT * FROM users") + return { users: rows } }), HttpServerResponse.json -); +) ``` But it is more idiomatic in Effect to use `pipe`: @@ -240,15 +240,15 @@ But it is more idiomatic in Effect to use `pipe`: import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Client } from "pg"; -const db = new Client({ user: "postgres" }); +import { Client } from "pg" +const db = new Client({ user: "postgres" }) //---cut--- const users = Effect.promise( () => db.query("SELECT * FROM users") ).pipe( Effect.map(({ rows }) => ({ users: rows })), Effect.flatMap(HttpServerResponse.json) -); +) ``` This no longer gives us a type error. We've split our computation into parts: @@ -341,7 +341,7 @@ X-Powered-By: Express Strictly speaking, a 500 error is correct here. Our server encountered an error because of a server-side problem, which is what the 500-class errors are for. -But it's not good Effect to let exceptions bubble up like this. +But it's not good Effect to let exceptions sneak by like this. A simple thing we can do is changing `Effect.promise` to `Effect.tryPromise`: @@ -349,15 +349,15 @@ A simple thing we can do is changing `Effect.promise` to `Effect.tryPromise`: import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Client } from "pg"; -const db = new Client({ user: "postgres" }); +import { Client } from "pg" +const db = new Client({ user: "postgres" }) //---cut--- const users = Effect.tryPromise( () => db.query("SELECT * FROM users") ).pipe( Effect.map(({ rows }) => ({ users: rows })), Effect.flatMap(HttpServerResponse.json) -); +) ``` This changes the return type from: @@ -378,18 +378,138 @@ to know _what_ exception might be thrown, Effect defaults to `UnknownException`. We can improve on this: -```ts twoslash +```ts twoslash {1,4-5} import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" -import { Client } from "pg"; -const db = new Client({ user: "postgres" }); +import { Client} from "pg" +const db = new Client({ user: "postgres" }) //---cut--- +class UserFetchError extends Error {} + const users = Effect.tryPromise({ try: () => db.query("SELECT * FROM users"), - catch: (error) => error, + catch: (cause) => new UserFetchError("failed to fetch users", { cause }), }).pipe( Effect.map(({ rows }) => ({ users: rows })), Effect.flatMap(HttpServerResponse.json) +) +``` + +We're using a different variant of `Effect.tryPromise` to give us more control +over the type of the error that is returned. Becuase it's difficult to know what +errors may be thrown by third-party libraries, it's idiomatic to create new +error types that wrap the original error. + +Now our effect has the type: + +```typescript +Effect +``` + +Now our program is aware of what can go wrong _at the type level_. This +information is completely missing if you're using exceptions in TypeScript. +With this information we can ensure, and get TypeScript to verify for us, +that things do not go wrong in unexpected ways. + +```ts twoslash +// @errors: 2375 +import { Effect } from "effect" + +const effect = Effect.succeed(1).pipe( + Effect.tryMap({ + try: (n) => n + 1, + catch: () => new Error(), + }), +) + +const noError: Effect.Effect = effect +``` + +The above code fails because we try to assign an Effect that can fail to a +variable that expects an Effect that can't fail. We can fix this error in a +few ways. + +By providing a default value: + +```ts twoslash {8} +import { Effect } from "effect" + +const effect = Effect.succeed(1).pipe( + Effect.tryMap({ + try: (n) => n + 1, + catch: () => new Error(), + }), + Effect.orElseSucceed(() => 0), +) + +const noError: Effect.Effect = effect +``` + +Or by converting the error into what Effect calls a "defect": + +```ts twoslash {8} +import { Effect } from "effect" + +const effect = Effect.succeed(1).pipe( + Effect.tryMap({ + try: (n) => n + 1, + catch: () => new Error(), + }), + Effect.orDie +) + +const noError: Effect.Effect = effect +``` + +This effectively recreates the behaviour of throwing an exception. Defects are +critical errors that you don't want to handle. If you were to create a defect in +an Effect handler, the result would be a 500 error returned to the user. + +## The end result + +Here's our full code so far: + +```ts twoslash title=index.ts +import express from "express" + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { HttpRouter, HttpServerResponse } from "@effect/platform"; +import { Effect } from "effect"; +import { Client } from "pg"; + +const db = new Client({ user: "postgres" }); + +const app = express(); + +class UserFetchError extends Error {} +const users = Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (cause) => new UserFetchError("failed to fetch users", { cause }), +}).pipe( + Effect.map(({ rows }) => ({ users: rows })), + Effect.flatMap(HttpServerResponse.json), + Effect.orDie +); + +function health() { + return HttpServerResponse.text("ok"); +} + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", Effect.sync(health)), + HttpRouter.get("/users", users), ); + +const main = Effect.gen(function* () { + yield* Effect.promise(() => db.connect()); + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000); + yield* Effect.never; +}); + +NodeRuntime.runMain(main); ``` + +Going forward, I'm going to trim older endpoints from the full code as we go so +that the length of these code samples doesn't get overwhelming. \ No newline at end of file From dfb2fabd69ebb2c0d4f3f776e80d137648b3f1f4 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 21 Feb 2025 17:03:08 +0000 Subject: [PATCH 17/28] Start writing the 'Calling an API' page. --- .../docs/learn/tutorials/calling-an-api.mdx | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 content/src/content/docs/learn/tutorials/calling-an-api.mdx diff --git a/content/src/content/docs/learn/tutorials/calling-an-api.mdx b/content/src/content/docs/learn/tutorials/calling-an-api.mdx new file mode 100644 index 000000000..748333134 --- /dev/null +++ b/content/src/content/docs/learn/tutorials/calling-an-api.mdx @@ -0,0 +1,201 @@ +--- +type: Tutorial +title: Calling an API +tags: + - some + - tags +sidebar: + order: 4 +--- + +Next up we're going to take a look at an endpoint that makes an API call to +another service. Here's the code: + +```ts twoslash title=index.ts {14-24} +import express from "express"; + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { HttpRouter } from "@effect/platform"; +import { Effect } from "effect"; +import { Client } from "pg"; + +const db = new Client({ user: "postgres" }); + +const app = express(); + +const BASE_URL = "https://owen-wilson-wow-api.onrender.com"; + +app.get("/wow", async (req, res) => { + const url = new URL(`${BASE_URL}/wows/random`); + + const year = req.query.year; + if (typeof year === "string") { + url.searchParams.set("year", year); + } + + const wow = await fetch(url.toString()); + res.json(await wow.json()); +}); + +const router = HttpRouter.empty + +const main = Effect.gen(function* () { + yield* Effect.promise(() => db.connect()); + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000); + yield* Effect.never; +}); + +NodeRuntime.runMain(main); +``` + +For a bit of fun I've chosen the [Owen Wilson Wow +API](https://owen-wilson-wow-api.onrender.com/) as our API to call. I've also +decided to use a query parameter to filter the results by year, to show how this +works in Effect. + +## Using `Effect.tryPromise` + +If we were to reach straight away for `Effect.tryPromise`, we would quickly +run into a problem: how do we reference the HTTP request object? We need to +get the `year` from the query parameters, but if you look back at our +`/users` endpoint, you'll see that there's no request object. + +Here's one way to do it. + +```ts twoslash +import { + HttpMiddleware, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +//---cut--- +const query = HttpServerRequest.HttpServerRequest.pipe( + Effect.map((req) => ({ url: req.url })), + Effect.flatMap(HttpServerResponse.json) +); + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/query", query), +); +``` + +We can pipe `HttpServerRequest` into a new `Effect` that extracts the URL from +it and displays the result in JSON. Requesting this endpoint works as you might +expect: + +```shell +http get "localhost:3000/query?year=2010" +``` + +```http +HTTP/1.1 200 OK +Content-Length: 16 +Content-Type: application/json +Date: Fri, 21 Feb 2025 16:41:03 GMT +X-Powered-By: Express + +{ + "url": "/query?year=2010" +} +``` + +But a simpler and more idiomatic way to do this is with `Effect.gen`: + +```ts twoslash +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +//---cut--- +const query = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + return yield* HttpServerResponse.json({ url: req.url }); +}); +``` + +## Using `Effect.gen` + +You've actually seen `Effect.gen` before this section of the tutorial. Right +at the very beginning, we defined our main function using `Effect.gen`: + +```ts twoslash +import express from "express"; +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +import { Client } from "pg"; +import { NodeHttpServer } from "@effect/platform-node"; +const db = new Client({ user: "postgres" }); +const app = express(); +const router = HttpRouter.empty +//---cut--- +const main = Effect.gen(function* () { + yield* Effect.promise(() => db.connect()); + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000); + yield* Effect.never; +}); +``` + +The power of `Effect.gen` is that it allows you to use Effects in a very similar +way to how you use Promises. You can think of `function*` as defining an async +function, and `yield*` as `await`. + +In reality, `function*` defines a [generator +function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*), +and `yield*` [yields +control](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/yield*) +to another generator function. This syntax allows Effect to create an +async/await like experience that can communicate with the runtime and propagate +types correctly. + +Speaking of types, let's take a look at the type returned from our `Effect.gen` +call: + +```ts twoslash +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +//---cut--- +const query = Effect.gen(function* () { + // ^? + const req = yield* HttpServerRequest.HttpServerRequest; + return yield* HttpServerResponse.json({ url: req.url }); +}); +``` + +An Effect that reutrns an `HttpServerResponse`, may throw an `HttpBodyError`, +but also, for the first time, has something other than `never` in that third +spot. As a reminder, this is the `Requirement` type. What it's saying here is +that, to execute, this Effect requires an `HttpServerRequest`. + +If we were to try and run this Effect directly, we would get a type error: + +```ts twoslash +// @errors: 2379 +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +//---cut--- +const query = Effect.gen(function* () { + // ^? + const req = yield* HttpServerRequest.HttpServerRequest; + return yield* HttpServerResponse.json({ url: req.url }); +}); + +Effect.runPromise(query); +``` From 8b1285ef75ba79c15fcd153a22c7c23fbb4e6928 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 21 Feb 2025 17:05:04 +0000 Subject: [PATCH 18/28] Minor tweak to display of last example. --- content/src/content/docs/learn/tutorials/calling-an-api.mdx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/calling-an-api.mdx b/content/src/content/docs/learn/tutorials/calling-an-api.mdx index 748333134..f441fcf57 100644 --- a/content/src/content/docs/learn/tutorials/calling-an-api.mdx +++ b/content/src/content/docs/learn/tutorials/calling-an-api.mdx @@ -190,12 +190,10 @@ import { HttpServerResponse, } from "@effect/platform"; import { Effect } from "effect"; -//---cut--- const query = Effect.gen(function* () { - // ^? const req = yield* HttpServerRequest.HttpServerRequest; return yield* HttpServerResponse.json({ url: req.url }); }); - +//---cut--- Effect.runPromise(query); ``` From c2cc1557fdb736bbf06c24bf0ba9b01e6e35785b Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 7 Mar 2025 12:20:14 +0000 Subject: [PATCH 19/28] First pass on responding to the recorded video feedback. --- .../docs/learn/tutorials/calling-an-api.mdx | 131 ++++------ .../docs/learn/tutorials/first-endpoint.mdx | 164 +++++++----- .../learn/tutorials/hooking-up-a-database.mdx | 237 ++++-------------- .../docs/learn/tutorials/introduction.mdx | 62 ++++- .../content/docs/learn/tutorials/scratch.mdx | 154 ++++++++++++ 5 files changed, 413 insertions(+), 335 deletions(-) create mode 100644 content/src/content/docs/learn/tutorials/scratch.mdx diff --git a/content/src/content/docs/learn/tutorials/calling-an-api.mdx b/content/src/content/docs/learn/tutorials/calling-an-api.mdx index f441fcf57..05fee7e51 100644 --- a/content/src/content/docs/learn/tutorials/calling-an-api.mdx +++ b/content/src/content/docs/learn/tutorials/calling-an-api.mdx @@ -11,22 +11,17 @@ sidebar: Next up we're going to take a look at an endpoint that makes an API call to another service. Here's the code: -```ts twoslash title=index.ts {14-24} +```ts twoslash title=index.ts ins={9-19} import express from "express"; import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; import { HttpRouter } from "@effect/platform"; import { Effect } from "effect"; -import { Client } from "pg"; - -const db = new Client({ user: "postgres" }); const app = express(); -const BASE_URL = "https://owen-wilson-wow-api.onrender.com"; - app.get("/wow", async (req, res) => { - const url = new URL(`${BASE_URL}/wows/random`); + const url = new URL(`https://owen-wilson-wow-api.onrender.com/wows/random`); const year = req.query.year; if (typeof year === "string") { @@ -40,51 +35,77 @@ app.get("/wow", async (req, res) => { const router = HttpRouter.empty const main = Effect.gen(function* () { - yield* Effect.promise(() => db.connect()); app.use(yield* NodeHttpServer.makeHandler(router)); - app.listen(3000); + app.listen(3000, () => { + console.log("Server is running on http://localhost:3000"); + }); yield* Effect.never; }); NodeRuntime.runMain(main); ``` +I've removed our `/health` and `/users` endpoints to keep the code to a +reasonable length. + For a bit of fun I've chosen the [Owen Wilson Wow API](https://owen-wilson-wow-api.onrender.com/) as our API to call. I've also decided to use a query parameter to filter the results by year, to show how this works in Effect. -## Using `Effect.tryPromise` +## Referencing the current request -If we were to reach straight away for `Effect.tryPromise`, we would quickly +If we were to reach straight away for `Effect.promise`, we would quickly run into a problem: how do we reference the HTTP request object? We need to get the `year` from the query parameters, but if you look back at our `/users` endpoint, you'll see that there's no request object. -Here's one way to do it. - ```ts twoslash -import { - HttpMiddleware, - HttpRouter, - HttpServerRequest, - HttpServerResponse, -} from "@effect/platform"; import { Effect } from "effect"; +import { HttpServerResponse } from "@effect/platform"; +import { Client } from "pg"; +const db = new Client({ user: "postgres" }); //---cut--- -const query = HttpServerRequest.HttpServerRequest.pipe( - Effect.map((req) => ({ url: req.url })), - Effect.flatMap(HttpServerResponse.json) +const users = Effect.promise( + () => db.query("SELECT * FROM users"), +).pipe( + Effect.map(({ rows }) => ({ users: rows })), + Effect.flatMap(HttpServerResponse.json), ); +``` +The way that Effect exposes the request object is through the +`HttpServerRequest` Effect. Near the start of this tutorial we used `Effect.gen` +to get the result of one Effect while inside another, and we can do the same +here. + +```ts twoslash +import { Effect } from "effect"; +//---cut--- +import { HttpServerRequest, HttpServerResponse } from "@effect/platform"; + +const query = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + return yield* HttpServerResponse.json({ url: req.url }); +}); +``` + +If we wire this handler up as a new `/query` endpoint in our application: + +```ts twoslash +import { Effect } from "effect"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform"; +const query = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + return yield* HttpServerResponse.json({ url: req.url }); +}); +//---cut--- const router = HttpRouter.empty.pipe( HttpRouter.get("/query", query), ); ``` -We can pipe `HttpServerRequest` into a new `Effect` that extracts the URL from -it and displays the result in JSON. Requesting this endpoint works as you might -expect: +We can call it like so: ```shell http get "localhost:3000/query?year=2010" @@ -102,63 +123,9 @@ X-Powered-By: Express } ``` -But a simpler and more idiomatic way to do this is with `Effect.gen`: - -```ts twoslash -import { - HttpRouter, - HttpServerRequest, - HttpServerResponse, -} from "@effect/platform"; -import { Effect } from "effect"; -//---cut--- -const query = Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest; - return yield* HttpServerResponse.json({ url: req.url }); -}); -``` - -## Using `Effect.gen` - -You've actually seen `Effect.gen` before this section of the tutorial. Right -at the very beginning, we defined our main function using `Effect.gen`: - -```ts twoslash -import express from "express"; -import { - HttpRouter, - HttpServerRequest, - HttpServerResponse, -} from "@effect/platform"; -import { Effect } from "effect"; -import { Client } from "pg"; -import { NodeHttpServer } from "@effect/platform-node"; -const db = new Client({ user: "postgres" }); -const app = express(); -const router = HttpRouter.empty -//---cut--- -const main = Effect.gen(function* () { - yield* Effect.promise(() => db.connect()); - app.use(yield* NodeHttpServer.makeHandler(router)); - app.listen(3000); - yield* Effect.never; -}); -``` - -The power of `Effect.gen` is that it allows you to use Effects in a very similar -way to how you use Promises. You can think of `function*` as defining an async -function, and `yield*` as `await`. - -In reality, `function*` defines a [generator -function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*), -and `yield*` [yields -control](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/yield*) -to another generator function. This syntax allows Effect to create an -async/await like experience that can communicate with the runtime and propagate -types correctly. +## Effect requirements -Speaking of types, let's take a look at the type returned from our `Effect.gen` -call: +Let's look a bit closer at our `query` Effect: ```ts twoslash import { @@ -175,7 +142,7 @@ const query = Effect.gen(function* () { }); ``` -An Effect that reutrns an `HttpServerResponse`, may throw an `HttpBodyError`, +An Effect that returns an `HttpServerResponse`, may throw an `HttpBodyError`, but also, for the first time, has something other than `never` in that third spot. As a reminder, this is the `Requirement` type. What it's saying here is that, to execute, this Effect requires an `HttpServerRequest`. diff --git a/content/src/content/docs/learn/tutorials/first-endpoint.mdx b/content/src/content/docs/learn/tutorials/first-endpoint.mdx index a51545787..7d38111f8 100644 --- a/content/src/content/docs/learn/tutorials/first-endpoint.mdx +++ b/content/src/content/docs/learn/tutorials/first-endpoint.mdx @@ -1,6 +1,6 @@ --- type: Tutorial -title: Migrating our first endpoint +title: Our first Effect endpoint tags: - some - tags @@ -8,11 +8,29 @@ sidebar: order: 2 --- -The first step to migrating our Express.js app to Effect is to add some -boilerplate that will allow us to write Effect endpoints that live alongside -Express.js. +import { Aside } from "@astrojs/starlight/components" -```ts twoslash {3-5,13-21} title=index.ts +The first step to writing Effect is to modify our Express app to allow it to +serve both Effect and Express endpoints at the same time. We need to first add +some dependencies to our project: + +```shell +bun add effect @effect/platform @effect/platform-node +``` + + + +Then make the highlighted changes to our Express app: + +```ts twoslash ins={3-5,16-26} del={13-15} title=index.ts import express from "express" import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" @@ -25,11 +43,16 @@ app.get("/health", (req, res) => { res.type("text/plain").send("ok") }) +app.listen(3000, () => { + console.log("Server is running on http://localhost:3000") +}) const router = HttpRouter.empty const main = Effect.gen(function* () { app.use(yield* NodeHttpServer.makeHandler(router)) - app.listen(3000) + app.listen(3000, () => { + console.log("Server is running on http://localhost:3000") + }) yield* Effect.never }) @@ -39,13 +62,12 @@ NodeRuntime.runMain(main) And we can run the resulting code like so: ```shell -bun add @effect/platform @effect/platform-node bun index.ts ``` -Our Express.js `/health` handler still works as expected, but we've wrapped the -Express.js app in a way that will allow us to define endpoints using Effect -while still responding to our existing endpoints in Express.js. +Our Express `/health` handler still works as expected, but we've wrapped the +Express app in a way that will allow us to define endpoints using Effect +while still responding to our existing endpoints in Express. There's a lot of new things happening at the bottom of our file. What is `Effect.gen`, what are those `yield*`s doing, what is `Effect.never`. For the @@ -55,14 +77,16 @@ start to become second nature. ## Our new `/health` handler -The Effect equivalent of our Express.js `/health` handler looks like this: +The Effect equivalent of our Express `/health` handler looks like this: ```ts twoslash -import { HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" // ---cut--- -function health() { +import { HttpServerResponse } from "@effect/platform" + +const health = Effect.sync(() => { return HttpServerResponse.text("ok") -} +}) ``` `HttpServerResponse` is Effect's class for generating HTTP responses, we need to @@ -72,15 +96,16 @@ The way we wire this into our Effect `router` is going to look a little bit strange, but please stay with me: ```ts twoslash -import { HttpServerResponse, HttpRouter } from "@effect/platform" import { Effect } from "effect" // ---cut--- -function health() { +import { HttpServerResponse, HttpRouter } from "@effect/platform" + +const health = Effect.sync(() => { return HttpServerResponse.text("ok") -} +}) const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)), + HttpRouter.get("/health", health), ) ``` @@ -96,31 +121,50 @@ Here's an example of an `Effect` that returns the string `"Hello, world!"`: ```ts twoslash import { Effect } from "effect" // ---cut--- -Effect.promise(async () => "Hello, world!") +Effect.sync(() => "Hello, world!") ``` -`Effect.promise` takes a function that returns a `Promise` and creates an -`Effect` out of it. `Effect.sync` works exactly the same, except it takes a -synchronous function instead of an asynchronous one. + -If we want to run this Effect, we can use `Effect.runPromise`: +`Effect.sync` takes a function and creates an `Effect` out of it. If you need to +do asynchronous work in an Effect, you can use `Effect.promise`: ```ts twoslash import { Effect } from "effect" // ---cut--- -const effect = Effect.promise(async () => "Hello, world!") -const result = await Effect.runPromise(effect) -console.log(result) +Effect.promise(async () => "Hello, world!") +``` + +If we want to run these Effects, we can use `Effect.runSync` and +`Effect.runPromise`: + +```ts twoslash +import { Effect } from "effect" +// ---cut--- +const syncResult = Effect.runSync( + Effect.sync(() => "Hello, world!") +) +console.log(syncResult) +// => "Hello, world!" + +const asyncResult = await Effect.runPromise( + Effect.promise(async () => "Hello, world!") +) +console.log(asyncResult) // => "Hello, world!" ``` What if you want to run an Effect that calls another Effect? You can use `Effect.gen` to do that: -```ts twoslash {2-5,8} +```ts twoslash import { Effect } from "effect" // ---cut--- -const effect = Effect.promise(async () => "Hello, world!") +const effect = Effect.sync(() => "Hello, world!") const gen = Effect.gen(function* () { const str = yield* effect return str.toUpperCase() @@ -130,42 +174,43 @@ console.log(result) // => "HELLO, WORLD!" ``` -This makes use of JavaScript's [generator functions][1]. You can think of the -`yield*` keyword as being very similar to `await`ing a `Promise`. Under the -hood, all `Effect`s are generators, and `yield*`ing one passes it to the Effect -runtime and waits for the result. +This makes use of JavaScript's [generator functions][1]. The way Effect uses +them, if you think of an Effect as being like a `Promise`, `yield*`ing an +`Effect` is the same as `await`ing a `Promise`. Under the hood, all `Effect`s +are generators, and `yield*`ing one passes it to the Effect runtime and waits +for the result. -Given that all we want to do above is call `.toUpperCase()` on the result -of `effect`, the scaffolding of `Effect.gen` may feel heavy. Effect gives us -a suite of tools to work with `Effect`s, and one of those tools is `pipe`: +`Effect.gen` is one of the most versatile ways to create Effects, and you'll use +it a lot. Another thing you'll see used a lot is `Effect.pipe`. Here's the same +code as above, but using `Effect.pipe`: -```ts twoslash {2} +```ts twoslash import { Effect } from "effect" // ---cut--- -const effect = Effect.promise(async () => "Hello, world!") -const upper = effect.pipe(Effect.map((s) => s.toUpperCase())) -const result = await Effect.runPromise(upper) +const effect = Effect.sync(() => "Hello, world!").pipe( + Effect.map((s) => s.toUpperCase()) +) +const result = await Effect.runSync(effect) console.log(result) // => "HELLO, WORLD!" ``` -`pipe` passes the result of one computation as input to another. Here, +`pipe` passes the result of one Effect as input to another. Here, `Effect.map` transforms the result of the first `Effect` with the function provided. We can `pipe` as many `Effect`s together as we like: -```ts twoslash {2-7,10} +```ts twoslash ins={3-5} import { Effect } from "effect" // ---cut--- -const effect = Effect.promise(async () => "Hello, world!") -const upper = effect.pipe( +const effect = Effect.sync(() => "Hello, world!").pipe( Effect.map((s) => s.toUpperCase()), Effect.map((s) => s.split("")), Effect.map((s) => s.reverse()), Effect.map((s) => s.join("")), ) -const result = await Effect.runPromise(upper) +const result = await Effect.runSync(effect) console.log(result) // => "!DLROW ,OLLEH" ``` @@ -182,12 +227,12 @@ app: ```ts twoslash import { HttpRouter, HttpServerResponse } from "@effect/platform" import { Effect } from "effect" -function health() { +const health = Effect.sync(() => { return HttpServerResponse.text("ok") -} +}) // ---cut--- const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)), + HttpRouter.get("/health", health), ) ``` @@ -204,21 +249,22 @@ console.log(Effect.isEffect(HttpRouter.empty)) ``` The helper `HttpRouter.get` takes an `HttpRouter` as an argument and returns a -new `HttpRouter` with the given route added. If we wanted to, we could have done -this much more directly: +new `HttpRouter` with the given route added. If we wanted to, we could have +called it like a regular function. The following two lines of code do exactly +the same thing: ```ts twoslash import { HttpRouter, HttpServerResponse } from "@effect/platform" import { Effect } from "effect" -function health() { +const health = Effect.sync(() => { return HttpServerResponse.text("ok") -} +}) // ---cut--- -const router = HttpRouter.get("/health", Effect.sync(health))(HttpRouter.empty) +HttpRouter.empty.pipe(HttpRouter.get("/health", health)) +HttpRouter.get("/health", health)(HttpRouter.empty) ``` -This is exactly the same as the `pipe` version, except that if we wanted to add -multiple routes it gets unwieldy quickly: +If we add a few more routes, we can see how this gets quickly out of hand: ```ts twoslash import { HttpRouter, HttpServerResponse } from "@effect/platform" @@ -264,17 +310,19 @@ import { Effect } from "effect" const app = express() -function health() { +const health = Effect.sync(() => { return HttpServerResponse.text("ok") -} +}) const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)) + HttpRouter.get("/health", health) ) const main = Effect.gen(function* () { app.use(yield* NodeHttpServer.makeHandler(router)) - app.listen(3000) + app.listen(3000, () => { + console.log("Server is running on http://localhost:3000") + }) yield* Effect.never }) diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index 769c5dba9..20e6bb91a 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -8,7 +8,9 @@ sidebar: order: 3 --- -The next endpoint we're going to migrate is one that lists all of the users in a +import { Aside } from "@astrojs/starlight/components" + +The next endpoint we're going to write is one that lists all of the users in a database. For this tutorial I'm going to use Postgres running in Docker as our database. If you don't have Docker installed, I recommend following the [official getting started guide](https://www.docker.com/get-started/). @@ -16,12 +18,20 @@ database. If you don't have Docker installed, I recommend following the When you're ready, you can start a Postgres container with: ```shell -docker run -e POSTGRES_HOST_AUTH_METHOD=trust -p 5432:5432 postgres +docker run -d -e POSTGRES_HOST_AUTH_METHOD=trust -p 5432:5432 postgres:17 ``` -## Creating our data + + +## Creating test data -To create our `users` table and sample data, we're going to use the `psql` +To create our `users` table and data, we're going to use the `psql` tool that's present inside of the Docker container we created. Open a new terminal and run: @@ -38,12 +48,24 @@ INSERT INTO users (name) VALUES ('Alice'); INSERT INTO users (name) VALUES ('Bob'); ``` +To exit `psql`, type `\q` and press enter. + ## The `/users` endpoint -Let's take a look at our app in full with new Express.js `/users` endpoint added -to it: +To talk to Postgres we'll need a client library. We're going to use `pg` for +this tutorial. You can install it with: + +```shell +bun add pg @types/pg +``` -```ts twoslash {6,8,11-14,25} title=index.ts +To start with, we're going to hardcode our database connection details. Later +on we'll learn how to use Effect to manage our app's configuration. + +Here's the code for our app in full, with a new `/users` endpoint added in +Express: + +```ts twoslash ins={6,8,11-14,25} title=index.ts import express from "express" import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" @@ -59,37 +81,33 @@ app.get("/users", async (req, res) => { res.send({ users: rows }) }) -function health() { +const health = Effect.sync(() => { return HttpServerResponse.text("ok") -} +}) const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)) + HttpRouter.get("/health", health) ) const main = Effect.gen(function* () { yield* Effect.promise(() => db.connect()) app.use(yield* NodeHttpServer.makeHandler(router)) - app.listen(3000) + app.listen(3000, () => { + console.log("Server is running on http://localhost:3000") + }) yield* Effect.never }) NodeRuntime.runMain(main) ``` -We'll also need to add some new dependencies to our project: - -```shell -bun add pg @types/pg -``` - -Then you can run the server with: +We can run this code with: ```shell bun index.ts ``` -And in another shell we can test the endpoint. +And in another shell we can test the new endpoint: ```shell http get localhost:3000/users @@ -234,7 +252,8 @@ const users = Effect.flatMap( ) ``` -But it is more idiomatic in Effect to use `pipe`: +And remembering what we learned about `pipe`, we can rewrite this to be a bit +more readable: ```ts twoslash import { HttpServerResponse } from "@effect/platform" @@ -254,7 +273,8 @@ const users = Effect.promise( This no longer gives us a type error. We've split our computation into parts: fetching the data with `Effect.promise`, manipulating its shape with `Effect.map`, and serializing it with `HttpServerResponse.json`. The -`Effect.flatMap` at the end takes care of the accidental `Effect` nesting we had in our previous implementation. +`Effect.flatMap` at the end takes care of the `Effect` nesting we had in our +previous implementation. ## An aside on pipes @@ -288,7 +308,7 @@ Our pipeline starts with 1, then passes it through `add(2)`, `mul(3)`, and One of the powers this gives you is the ability to add things easily into the pipeline. -```ts twoslash {6-9,14,16,18} +```ts twoslash ins={6-9,14,16,18} import { pipe } from "effect" const add = (x: number) => (y: number) => y + x @@ -321,156 +341,11 @@ This prints the following, with `result` still being 8: As we migrate more complex endpoints, we'll see how this ability to "tap into" the pipeline is really useful. -## Adding error handling to our database query - -All this talk of error handling might have gotten you thinking: can't our -database query fail? It can! If the query is malformed, `db.query` will throw an -error. When that happens, our endpoint will currently return a 500 error to -the client. Change the SQL query to `SELECT * FROM nonexistent` and try it: - -```shell -http get "localhost:3000/users" -``` - -```http -HTTP/1.1 500 Internal Server Error -Content-Length: 0 -Date: Fri, 14 Feb 2025 17:25:57 GMT -X-Powered-By: Express -``` - -Strictly speaking, a 500 error is correct here. Our server encountered an error -because of a server-side problem, which is what the 500-class errors are for. -But it's not good Effect to let exceptions sneak by like this. - -A simple thing we can do is changing `Effect.promise` to `Effect.tryPromise`: - -```ts twoslash -import { HttpServerResponse } from "@effect/platform" -import { Effect, Layer } from "effect" -import { createServer } from "node:http" -import { Client } from "pg" -const db = new Client({ user: "postgres" }) -//---cut--- -const users = Effect.tryPromise( - () => db.query("SELECT * FROM users") -).pipe( - Effect.map(({ rows }) => ({ users: rows })), - Effect.flatMap(HttpServerResponse.json) -) -``` - -This changes the return type from: - -```typescript -Effect -``` - -To: - -```typescript -Effect -``` - -`Effect.promise` is assumed to always succeed. `Effect.tryPromise` is assumed -to maybe throw an exception, but because TypeScript doesn't give us any way -to know _what_ exception might be thrown, Effect defaults to `UnknownException`. - -We can improve on this: - -```ts twoslash {1,4-5} -import { HttpServerResponse } from "@effect/platform" -import { Effect, Layer } from "effect" -import { createServer } from "node:http" -import { Client} from "pg" -const db = new Client({ user: "postgres" }) -//---cut--- -class UserFetchError extends Error {} - -const users = Effect.tryPromise({ - try: () => db.query("SELECT * FROM users"), - catch: (cause) => new UserFetchError("failed to fetch users", { cause }), -}).pipe( - Effect.map(({ rows }) => ({ users: rows })), - Effect.flatMap(HttpServerResponse.json) -) -``` - -We're using a different variant of `Effect.tryPromise` to give us more control -over the type of the error that is returned. Becuase it's difficult to know what -errors may be thrown by third-party libraries, it's idiomatic to create new -error types that wrap the original error. - -Now our effect has the type: - -```typescript -Effect -``` - -Now our program is aware of what can go wrong _at the type level_. This -information is completely missing if you're using exceptions in TypeScript. -With this information we can ensure, and get TypeScript to verify for us, -that things do not go wrong in unexpected ways. - -```ts twoslash -// @errors: 2375 -import { Effect } from "effect" - -const effect = Effect.succeed(1).pipe( - Effect.tryMap({ - try: (n) => n + 1, - catch: () => new Error(), - }), -) - -const noError: Effect.Effect = effect -``` - -The above code fails because we try to assign an Effect that can fail to a -variable that expects an Effect that can't fail. We can fix this error in a -few ways. - -By providing a default value: - -```ts twoslash {8} -import { Effect } from "effect" - -const effect = Effect.succeed(1).pipe( - Effect.tryMap({ - try: (n) => n + 1, - catch: () => new Error(), - }), - Effect.orElseSucceed(() => 0), -) - -const noError: Effect.Effect = effect -``` - -Or by converting the error into what Effect calls a "defect": - -```ts twoslash {8} -import { Effect } from "effect" - -const effect = Effect.succeed(1).pipe( - Effect.tryMap({ - try: (n) => n + 1, - catch: () => new Error(), - }), - Effect.orDie -) - -const noError: Effect.Effect = effect -``` - -This effectively recreates the behaviour of throwing an exception. Defects are -critical errors that you don't want to handle. If you were to create a defect in -an Effect handler, the result would be a 500 error returned to the user. - ## The end result -Here's our full code so far: +Here's our full code so far, with new lines highlighted: -```ts twoslash title=index.ts +```ts twoslash title=index.ts ins={6,8,12-17,25,29} import express from "express" import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; @@ -482,34 +357,30 @@ const db = new Client({ user: "postgres" }); const app = express(); -class UserFetchError extends Error {} -const users = Effect.tryPromise({ - try: () => db.query("SELECT * FROM users"), - catch: (cause) => new UserFetchError("failed to fetch users", { cause }), -}).pipe( +const users = Effect.promise( + () => db.query("SELECT * FROM users"), +).pipe( Effect.map(({ rows }) => ({ users: rows })), Effect.flatMap(HttpServerResponse.json), - Effect.orDie ); -function health() { +const health = Effect.sync(() => { return HttpServerResponse.text("ok"); -} +}) const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)), + HttpRouter.get("/health", health), HttpRouter.get("/users", users), ); const main = Effect.gen(function* () { yield* Effect.promise(() => db.connect()); app.use(yield* NodeHttpServer.makeHandler(router)); - app.listen(3000); + app.listen(3000, () => { + console.log("Server is running on http://localhost:3000"); + }); yield* Effect.never; }); NodeRuntime.runMain(main); -``` - -Going forward, I'm going to trim older endpoints from the full code as we go so -that the length of these code samples doesn't get overwhelming. \ No newline at end of file +``` \ No newline at end of file diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index 3afaf03fa..2de1fa437 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -8,16 +8,44 @@ sidebar: order: 1 --- -In this tutorial we're going to migrate an Express.js app to Effect. We'll learn -how to configure your existing Express.js codebase to allow migrating endpoints -one at a time, adopting Effect incrementally. We'll start with the base set of -Effect concepts you'll need to migrate the simplest endpoint possible, and we'll -introduce new concepts as we migrate more complex endpoints. +## Who this is for -## Our Express.js app +This tutorial assumes you are: -We'll start with a simple Express.js app that has a single health checking -endpoint. +- New to Effect +- Not new to TypeScript / web development +- Want to use Effect in a new or existing project + +If you're new to TypeScript you may still be able to follow along, but we aren't +going to be explaining concepts like generics or async/await. You'll get more +value from this tutorial, and from Effect, if you're already familiar with the +TypeScript language. + +## How we will teach you + +Effect is crafted from decades of experience and frustration with building web +applications. It uses advanced features of the TypeScript language and builds on +ideas from functional programming. As a result, **Effect has a steep learning +curve**. But the promise we make you is that **if you invest the time required +to learn Effect, it will pay for itself many times over.** + +We're going to teach you Effect by example, starting from familiar ground and +introducing new ideas one at a time. This tutorial isn't going to jump straight +to best practices. To get there, we have to build up a foundation of knowledge +about Effect. + +## What we're going to build + +We're going to build a backend web application. It will have configuration, +connect to a database, and call out to other APIs. + +To put Effect into context, we're going to build our application twice. First +using [Express][1], a popular web framework for Node.js, and then with Effect. +Don't worry if you don't know or use Express, we'll explain everything as we +go. + +This is the code we're going to start with. It defines an Express app with a +single endpoint, `GET /health`, that listens on port 3000. ```ts twoslash title=index.ts import express from "express" @@ -33,16 +61,24 @@ app.listen(3000, () => { }) ``` +## Getting set up + +To get the most value out of this tutorial, follow along as you read. Copy or +type out the code examples, run them, try changing things to see what happens. +It doesn't matter if you break things, you can always come back and get the +working code. If all you do is read these words, you'll miss out on the most +important part of learning: doing. + Create a new TypeScript project using your favourite package manager. I'm using `bun`: ```shell -mkdir express-to-effect -cd express-to-effect +mkdir express-tutorial +cd express-tutorial bun init ``` -Save our Express.js app into `index.ts` and run it to make sure it works: +Save our Expresss app into `index.ts` and run it to make sure it works: ```shell bun add express @@ -66,4 +102,6 @@ ok ``` I'm using [HTTPie](https://httpie.io/cli) throughout this tutorial, because the -output is nicer than `curl`. \ No newline at end of file +output is nicer than `curl`. + +[1]: https://expressjs.com/ \ No newline at end of file diff --git a/content/src/content/docs/learn/tutorials/scratch.mdx b/content/src/content/docs/learn/tutorials/scratch.mdx new file mode 100644 index 000000000..86cfbaddd --- /dev/null +++ b/content/src/content/docs/learn/tutorials/scratch.mdx @@ -0,0 +1,154 @@ +--- +type: Tutorial +title: Scratch +tags: + - some + - tags +sidebar: + order: 999 +--- + +## Adding error handling to our database query + +All this talk of error handling might have gotten you thinking: can't our +database query fail? It can! If the query is malformed, `db.query` will throw an +error. When that happens, our endpoint will currently return a 500 error to +the client. Change the SQL query to `SELECT * FROM nonexistent` and try it: + +```shell +http get "localhost:3000/users" +``` + +```http +HTTP/1.1 500 Internal Server Error +Content-Length: 0 +Date: Fri, 14 Feb 2025 17:25:57 GMT +X-Powered-By: Express +``` + +Strictly speaking, a 500 error is correct here. Our server encountered an error +because of a server-side problem, which is what the 500-class errors are for. +But it's not good Effect to let exceptions sneak by like this. + +A simple thing we can do is changing `Effect.promise` to `Effect.tryPromise`: + +```ts twoslash +import { HttpServerResponse } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import { Client } from "pg" +const db = new Client({ user: "postgres" }) +//---cut--- +const users = Effect.tryPromise( + () => db.query("SELECT * FROM users") +).pipe( + Effect.map(({ rows }) => ({ users: rows })), + Effect.flatMap(HttpServerResponse.json) +) +``` + +This changes the return type from: + +```typescript +Effect +``` + +To: + +```typescript +Effect +``` + +`Effect.promise` is assumed to always succeed. `Effect.tryPromise` is assumed +to maybe throw an exception, but because TypeScript doesn't give us any way +to know _what_ exception might be thrown, Effect defaults to `UnknownException`. + +We can improve on this: + +```ts twoslash {1,4-5} +import { HttpServerResponse } from "@effect/platform" +import { Effect, Layer } from "effect" +import { createServer } from "node:http" +import { Client} from "pg" +const db = new Client({ user: "postgres" }) +//---cut--- +class UserFetchError extends Error {} + +const users = Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (cause) => new UserFetchError("failed to fetch users", { cause }), +}).pipe( + Effect.map(({ rows }) => ({ users: rows })), + Effect.flatMap(HttpServerResponse.json) +) +``` + +We're using a different variant of `Effect.tryPromise` to give us more control +over the type of the error that is returned. Becuase it's difficult to know what +errors may be thrown by third-party libraries, it's idiomatic to create new +error types that wrap the original error. + +Now our effect has the type: + +```typescript +Effect +``` + +Now our program is aware of what can go wrong _at the type level_. This +information is completely missing if you're using exceptions in TypeScript. +With this information we can ensure, and get TypeScript to verify for us, +that things do not go wrong in unexpected ways. + +```ts twoslash +// @errors: 2375 +import { Effect } from "effect" + +const effect = Effect.succeed(1).pipe( + Effect.tryMap({ + try: (n) => n + 1, + catch: () => new Error(), + }), +) + +const noError: Effect.Effect = effect +``` + +The above code fails because we try to assign an Effect that can fail to a +variable that expects an Effect that can't fail. We can fix this error in a +few ways. + +By providing a default value: + +```ts twoslash {8} +import { Effect } from "effect" + +const effect = Effect.succeed(1).pipe( + Effect.tryMap({ + try: (n) => n + 1, + catch: () => new Error(), + }), + Effect.orElseSucceed(() => 0), +) + +const noError: Effect.Effect = effect +``` + +Or by converting the error into what Effect calls a "defect": + +```ts twoslash {8} +import { Effect } from "effect" + +const effect = Effect.succeed(1).pipe( + Effect.tryMap({ + try: (n) => n + 1, + catch: () => new Error(), + }), + Effect.orDie +) + +const noError: Effect.Effect = effect +``` + +This effectively recreates the behaviour of throwing an exception. Defects are +critical errors that you don't want to handle. If you were to create a defect in +an Effect handler, the result would be a 500 error returned to the user. From 55fa6dff014104e297ebd27093bf7eee9ed7b328 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 7 Mar 2025 14:53:14 +0000 Subject: [PATCH 20/28] Round off the calling an API section. --- .../docs/learn/tutorials/calling-an-api.mdx | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) diff --git a/content/src/content/docs/learn/tutorials/calling-an-api.mdx b/content/src/content/docs/learn/tutorials/calling-an-api.mdx index 05fee7e51..44f9c8fe6 100644 --- a/content/src/content/docs/learn/tutorials/calling-an-api.mdx +++ b/content/src/content/docs/learn/tutorials/calling-an-api.mdx @@ -164,3 +164,237 @@ const query = Effect.gen(function* () { //---cut--- Effect.runPromise(query); ``` + +What this is telling us is that this Effect cannot be run unless an +`HttpServerRequest` is provided to it. + +## Providing requirements + +The way we provide an Effect with things it needs is through the use of +"services." If you've used dependency injection in other languages, Effect's +services are the same idea. + +To keep track of what services are available, Effect maintains an internal +context object. You can think of this as a map of services, with the key being a +unique identifier for the service, and the value being the service itself. + +Effect uses a special type called a `Tag` as the unique identifier for services, +and the syntax for defining one is unusual so bear with me here: + +```ts twoslash +import { Context, Effect } from "effect"; +//---cut--- +class FavouriteNumber extends Context.Tag("FavouriteNumber")< + FavouriteNumber, + number +>() {} + +const effect = Effect.gen(function* () { + const n = yield* FavouriteNumber; + console.log(`My favourite number is ${n}`); +}); +``` + +The class declaration at the top defines the tag as well as the type for the +service. In this example we just return a `number`, but if you wanted to you +could return a more complex object. + +In the `Effect.gen` call we `yield*` the tag to get the value of the service. +Note that we haven't created an implementation of the service yet, so we won't +be able to run this Effect. + +Here's how we would implement and then provide the service: + +```ts twoslash +import { Context, Effect } from "effect"; +class FavouriteNumber extends Context.Tag("FavouriteNumber")< + FavouriteNumber, + number +>() {} + +const effect = Effect.gen(function* () { + const n = yield* FavouriteNumber; + console.log(`My favourite number is ${n}`); +}); +//---cut--- +Effect.runPromise( + effect.pipe(Effect.provideService(FavouriteNumber, 42)) +); +// => My favourite number is 42 + +Effect.runPromise( + effect.pipe(Effect.provideService(FavouriteNumber, 100)) +); +// => My favourite number is 100 +``` + +Providing a service to an Effect ends up changing its type, too. + +```ts twoslash +import { Context, Effect } from "effect"; +class FavouriteNumber extends Context.Tag("FavouriteNumber")< + FavouriteNumber, + number +>() {} +//---cut--- +const needsRequirements = Effect.gen(function* () { +// ^? + const n = yield* FavouriteNumber; + console.log(`My favourite number is ${n}`); +}); + +const hasRequirements = needsRequirements.pipe( +// ^? + Effect.provideService(FavouriteNumber, 42) +); +``` + +We go from: + +``` +Effect +``` + +To: + +``` +Effect +``` + +So each Effect can be thought of as a self-contained program. Services are its +inputs, it has an output, and it can error. Effect forces us to be more +deliberate about what our programs need to run, and that makes it easier to +reason about them. + +So how is `HttpServerRequest` provided to our `query` Effect? It's the +`NodeHttpServer.makeHandler` function that does this. It takes our `HttpRouter` +Effect and provides a service to it that we can get the current request from. + +## Parsing the query string + +Now we know about services, and how the request object gets to us, let's do +something useful with it! + +Here's where we left our `/query` endpoint: + +```ts twoslash +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +//---cut--- +const query = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + return yield* HttpServerResponse.json({ url: req.url }); +}); +``` + +We want to take that `req.url` and parse out the `year` query parameter. We can +do this with the `URL` class. + +```ts twoslash ins={3-4} +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +//---cut--- +const query = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + const url = new URL(req.url, `http://${req.headers.host}`); + const year = url.searchParams.get("year"); + return yield* HttpServerResponse.json({ year }); +}); +``` + +And now we can see the year in the response: + +```shell +http get "localhost:3000/query?year=2010" +``` + +```http +HTTP/1.1 200 OK +Content-Length: 15 +Content-Type: application/json +Date: Fri, 07 Mar 2025 14:21:50 GMT +X-Powered-By: Express + +{ + "year": "2010" +} +``` + +## Calling the API + +Now that we have the `year` query parameter, we can use it to filter the results +from the API. + +```ts twoslash ins={6-18} +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; +//---cut--- +const query = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + const requestUrl = new URL(req.url, `http://${req.headers.host}`); + const year = requestUrl.searchParams.get("year"); + + const apiUrl = new URL( + "https://owen-wilson-wow-api.onrender.com/wows/random" + ); + if (year !== null) { + apiUrl.searchParams.set("year", year); + } + + const response = yield* Effect.promise(async () => { + const resp = await fetch(apiUrl.toString()); + return await resp.json(); + }); + + return yield* HttpServerResponse.json(response); +}); +``` + +And querying this endpoint we now get the filtered results: + +```shell +http get "localhost:3000/query?year=2010" +``` + +```http +HTTP/1.1 200 OK +Content-Length: 1066 +Content-Type: application/json +Date: Fri, 07 Mar 2025 14:45:01 GMT +X-Powered-By: Express + +[ + { + "audio": "https://assets.ctfassets.net/bs8ntwkklfua/3B0YwIinFRjyDhg87ubLnm/e7ca0400c19fe1fe118ebc2901dc3518/Little_Fockers_Wow_1.mp3", + "character": "Kevin Rawley", + "current_wow_in_movie": 1, + "director": "Paul Weitz", + "full_line": "Wow.", + "movie": "Little Fockers", + "movie_duration": "01:37:40", + "poster": "https://images.ctfassets.net/bs8ntwkklfua/2uitJuVd7wiuh57N42VotB/97be419cc8d17405b256bf6f63afd461/Little_Fockers_Poster.jpg", + "release_date": "2010-12-22", + "timestamp": "00:26:32", + "total_wows_in_movie": 2, + "video": { + "1080p": "https://videos.ctfassets.net/bs8ntwkklfua/6sQcwSbOHUtQwSa2AiGVx4/4bd91b3f82378c6cec36003010b6bc7b/Little_Fockers_Wow_1_1080p.mp4", + "360p": "https://videos.ctfassets.net/bs8ntwkklfua/4NRdmxRxOFF7u8uSDCDpbz/f948d2f764468c89f5e37fa2b3804661/Little_Fockers_Wow_1_360p.mp4", + "480p": "https://videos.ctfassets.net/bs8ntwkklfua/5jBrCwue7YyNWATPMsnEM3/3897126fa0cacee7817cb798db85a829/Little_Fockers_Wow_1_480p.mp4", + "720p": "https://videos.ctfassets.net/bs8ntwkklfua/1MgGCFiDEMuDtZf5pPXZrq/9abb8fedcf84c2aad104a993cab043ef/Little_Fockers_Wow_1_720p.mp4" + }, + "year": 2010 + } +] +``` \ No newline at end of file From 99167494b407c4a52fbd8e1db93a9fddead77139 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 7 Mar 2025 15:21:12 +0000 Subject: [PATCH 21/28] Tidying up. --- .../docs/learn/tutorials/calling-an-api.mdx | 206 +++++++++--------- .../docs/learn/tutorials/first-endpoint.mdx | 60 ++--- .../learn/tutorials/hooking-up-a-database.mdx | 160 ++++++++++---- .../docs/learn/tutorials/introduction.mdx | 8 +- 4 files changed, 257 insertions(+), 177 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/calling-an-api.mdx b/content/src/content/docs/learn/tutorials/calling-an-api.mdx index 44f9c8fe6..fda8827ec 100644 --- a/content/src/content/docs/learn/tutorials/calling-an-api.mdx +++ b/content/src/content/docs/learn/tutorials/calling-an-api.mdx @@ -11,38 +11,38 @@ sidebar: Next up we're going to take a look at an endpoint that makes an API call to another service. Here's the code: -```ts twoslash title=index.ts ins={9-19} -import express from "express"; +```ts twoslash title=index.ts ins={9-19} showLineNumbers=false +import express from "express" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; -import { HttpRouter } from "@effect/platform"; -import { Effect } from "effect"; +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { HttpRouter } from "@effect/platform" +import { Effect } from "effect" -const app = express(); +const app = express() app.get("/wow", async (req, res) => { - const url = new URL(`https://owen-wilson-wow-api.onrender.com/wows/random`); + const url = new URL(`https://owen-wilson-wow-api.onrender.com/wows/random`) - const year = req.query.year; + const year = req.query.year if (typeof year === "string") { - url.searchParams.set("year", year); + url.searchParams.set("year", year) } - const wow = await fetch(url.toString()); - res.json(await wow.json()); -}); + const wow = await fetch(url.toString()) + res.json(await wow.json()) +}) const router = HttpRouter.empty const main = Effect.gen(function* () { - app.use(yield* NodeHttpServer.makeHandler(router)); + app.use(yield* NodeHttpServer.makeHandler(router)) app.listen(3000, () => { - console.log("Server is running on http://localhost:3000"); - }); - yield* Effect.never; -}); + console.log("Server is running on http://localhost:3000") + }) + yield* Effect.never +}) -NodeRuntime.runMain(main); +NodeRuntime.runMain(main) ``` I've removed our `/health` and `/users` endpoints to keep the code to a @@ -60,18 +60,18 @@ run into a problem: how do we reference the HTTP request object? We need to get the `year` from the query parameters, but if you look back at our `/users` endpoint, you'll see that there's no request object. -```ts twoslash -import { Effect } from "effect"; -import { HttpServerResponse } from "@effect/platform"; -import { Client } from "pg"; -const db = new Client({ user: "postgres" }); +```ts twoslash showLineNumbers=false +import { Effect } from "effect" +import { HttpServerResponse } from "@effect/platform" +import { Client } from "pg" +const db = new Client({ user: "postgres" }) //---cut--- const users = Effect.promise( () => db.query("SELECT * FROM users"), ).pipe( Effect.map(({ rows }) => ({ users: rows })), Effect.flatMap(HttpServerResponse.json), -); +) ``` The way that Effect exposes the request object is through the @@ -79,39 +79,39 @@ The way that Effect exposes the request object is through the to get the result of one Effect while inside another, and we can do the same here. -```ts twoslash -import { Effect } from "effect"; +```ts twoslash showLineNumbers=false +import { Effect } from "effect" //---cut--- -import { HttpServerRequest, HttpServerResponse } from "@effect/platform"; +import { HttpServerRequest, HttpServerResponse } from "@effect/platform" const query = Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest; - return yield* HttpServerResponse.json({ url: req.url }); -}); + const req = yield* HttpServerRequest.HttpServerRequest + return yield* HttpServerResponse.json({ url: req.url }) +}) ``` If we wire this handler up as a new `/query` endpoint in our application: -```ts twoslash -import { Effect } from "effect"; -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform"; +```ts twoslash showLineNumbers=false +import { Effect } from "effect" +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "@effect/platform" const query = Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest; - return yield* HttpServerResponse.json({ url: req.url }); -}); + const req = yield* HttpServerRequest.HttpServerRequest + return yield* HttpServerResponse.json({ url: req.url }) +}) //---cut--- const router = HttpRouter.empty.pipe( HttpRouter.get("/query", query), -); +) ``` We can call it like so: -```shell +```shell showLineNumbers=false http get "localhost:3000/query?year=2010" ``` -```http +```http showLineNumbers=false HTTP/1.1 200 OK Content-Length: 16 Content-Type: application/json @@ -127,19 +127,19 @@ X-Powered-By: Express Let's look a bit closer at our `query` Effect: -```ts twoslash +```ts twoslash showLineNumbers=false import { HttpRouter, HttpServerRequest, HttpServerResponse, -} from "@effect/platform"; -import { Effect } from "effect"; +} from "@effect/platform" +import { Effect } from "effect" //---cut--- const query = Effect.gen(function* () { // ^? - const req = yield* HttpServerRequest.HttpServerRequest; - return yield* HttpServerResponse.json({ url: req.url }); -}); + const req = yield* HttpServerRequest.HttpServerRequest + return yield* HttpServerResponse.json({ url: req.url }) +}) ``` An Effect that returns an `HttpServerResponse`, may throw an `HttpBodyError`, @@ -149,20 +149,20 @@ that, to execute, this Effect requires an `HttpServerRequest`. If we were to try and run this Effect directly, we would get a type error: -```ts twoslash +```ts twoslash showLineNumbers=false // @errors: 2379 import { HttpRouter, HttpServerRequest, HttpServerResponse, -} from "@effect/platform"; -import { Effect } from "effect"; +} from "@effect/platform" +import { Effect } from "effect" const query = Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest; - return yield* HttpServerResponse.json({ url: req.url }); -}); + const req = yield* HttpServerRequest.HttpServerRequest + return yield* HttpServerResponse.json({ url: req.url }) +}) //---cut--- -Effect.runPromise(query); +Effect.runPromise(query) ``` What this is telling us is that this Effect cannot be run unless an @@ -181,8 +181,8 @@ unique identifier for the service, and the value being the service itself. Effect uses a special type called a `Tag` as the unique identifier for services, and the syntax for defining one is unusual so bear with me here: -```ts twoslash -import { Context, Effect } from "effect"; +```ts twoslash showLineNumbers=false +import { Context, Effect } from "effect" //---cut--- class FavouriteNumber extends Context.Tag("FavouriteNumber")< FavouriteNumber, @@ -190,9 +190,9 @@ class FavouriteNumber extends Context.Tag("FavouriteNumber")< >() {} const effect = Effect.gen(function* () { - const n = yield* FavouriteNumber; - console.log(`My favourite number is ${n}`); -}); + const n = yield* FavouriteNumber + console.log(`My favourite number is ${n}`) +}) ``` The class declaration at the top defines the tag as well as the type for the @@ -205,33 +205,33 @@ be able to run this Effect. Here's how we would implement and then provide the service: -```ts twoslash -import { Context, Effect } from "effect"; +```ts twoslash showLineNumbers=false +import { Context, Effect } from "effect" class FavouriteNumber extends Context.Tag("FavouriteNumber")< FavouriteNumber, number >() {} const effect = Effect.gen(function* () { - const n = yield* FavouriteNumber; - console.log(`My favourite number is ${n}`); -}); + const n = yield* FavouriteNumber + console.log(`My favourite number is ${n}`) +}) //---cut--- Effect.runPromise( effect.pipe(Effect.provideService(FavouriteNumber, 42)) -); +) // => My favourite number is 42 Effect.runPromise( effect.pipe(Effect.provideService(FavouriteNumber, 100)) -); +) // => My favourite number is 100 ``` Providing a service to an Effect ends up changing its type, too. -```ts twoslash -import { Context, Effect } from "effect"; +```ts twoslash showLineNumbers=false +import { Context, Effect } from "effect" class FavouriteNumber extends Context.Tag("FavouriteNumber")< FavouriteNumber, number @@ -239,25 +239,25 @@ class FavouriteNumber extends Context.Tag("FavouriteNumber")< //---cut--- const needsRequirements = Effect.gen(function* () { // ^? - const n = yield* FavouriteNumber; - console.log(`My favourite number is ${n}`); -}); + const n = yield* FavouriteNumber + console.log(`My favourite number is ${n}`) +}) const hasRequirements = needsRequirements.pipe( // ^? Effect.provideService(FavouriteNumber, 42) -); +) ``` We go from: -``` +``` showLineNumbers=false Effect ``` To: -``` +``` showLineNumbers=false Effect ``` @@ -277,46 +277,46 @@ something useful with it! Here's where we left our `/query` endpoint: -```ts twoslash +```ts twoslash showLineNumbers=false import { HttpRouter, HttpServerRequest, HttpServerResponse, -} from "@effect/platform"; -import { Effect } from "effect"; +} from "@effect/platform" +import { Effect } from "effect" //---cut--- const query = Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest; - return yield* HttpServerResponse.json({ url: req.url }); -}); + const req = yield* HttpServerRequest.HttpServerRequest + return yield* HttpServerResponse.json({ url: req.url }) +}) ``` We want to take that `req.url` and parse out the `year` query parameter. We can do this with the `URL` class. -```ts twoslash ins={3-4} +```ts twoslash ins={3-4} showLineNumbers=false import { HttpRouter, HttpServerRequest, HttpServerResponse, -} from "@effect/platform"; -import { Effect } from "effect"; +} from "@effect/platform" +import { Effect } from "effect" //---cut--- const query = Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest; - const url = new URL(req.url, `http://${req.headers.host}`); - const year = url.searchParams.get("year"); - return yield* HttpServerResponse.json({ year }); -}); + const req = yield* HttpServerRequest.HttpServerRequest + const url = new URL(req.url, `http://${req.headers.host}`) + const year = url.searchParams.get("year") + return yield* HttpServerResponse.json({ year }) +}) ``` And now we can see the year in the response: -```shell +```shell showLineNumbers=false http get "localhost:3000/query?year=2010" ``` -```http +```http showLineNumbers=false HTTP/1.1 200 OK Content-Length: 15 Content-Type: application/json @@ -333,42 +333,42 @@ X-Powered-By: Express Now that we have the `year` query parameter, we can use it to filter the results from the API. -```ts twoslash ins={6-18} +```ts twoslash ins={6-18} showLineNumbers=false import { HttpRouter, HttpServerRequest, HttpServerResponse, -} from "@effect/platform"; -import { Effect } from "effect"; +} from "@effect/platform" +import { Effect } from "effect" //---cut--- const query = Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest; - const requestUrl = new URL(req.url, `http://${req.headers.host}`); - const year = requestUrl.searchParams.get("year"); + const req = yield* HttpServerRequest.HttpServerRequest + const requestUrl = new URL(req.url, `http://${req.headers.host}`) + const year = requestUrl.searchParams.get("year") const apiUrl = new URL( "https://owen-wilson-wow-api.onrender.com/wows/random" - ); + ) if (year !== null) { - apiUrl.searchParams.set("year", year); + apiUrl.searchParams.set("year", year) } const response = yield* Effect.promise(async () => { - const resp = await fetch(apiUrl.toString()); - return await resp.json(); - }); + const resp = await fetch(apiUrl.toString()) + return await resp.json() + }) - return yield* HttpServerResponse.json(response); -}); + return yield* HttpServerResponse.json(response) +}) ``` And querying this endpoint we now get the filtered results: -```shell +```shell showLineNumbers=false http get "localhost:3000/query?year=2010" ``` -```http +```http showLineNumbers=false HTTP/1.1 200 OK Content-Length: 1066 Content-Type: application/json diff --git a/content/src/content/docs/learn/tutorials/first-endpoint.mdx b/content/src/content/docs/learn/tutorials/first-endpoint.mdx index 7d38111f8..7e7015e6b 100644 --- a/content/src/content/docs/learn/tutorials/first-endpoint.mdx +++ b/content/src/content/docs/learn/tutorials/first-endpoint.mdx @@ -14,7 +14,7 @@ The first step to writing Effect is to modify our Express app to allow it to serve both Effect and Express endpoints at the same time. We need to first add some dependencies to our project: -```shell +```shell showLineNumbers=false bun add effect @effect/platform @effect/platform-node ``` @@ -30,7 +30,7 @@ to use `@effect/platform-node`. Then make the highlighted changes to our Express app: -```ts twoslash ins={3-5,16-26} del={13-15} title=index.ts +```ts twoslash ins={3-5,16-26} del={13-15} title=index.ts showLineNumbers=false import express from "express" import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" @@ -61,7 +61,7 @@ NodeRuntime.runMain(main) And we can run the resulting code like so: -```shell +```shell showLineNumbers=false bun index.ts ``` @@ -79,7 +79,7 @@ start to become second nature. The Effect equivalent of our Express `/health` handler looks like this: -```ts twoslash +```ts twoslash showLineNumbers=false import { Effect } from "effect" // ---cut--- import { HttpServerResponse } from "@effect/platform" @@ -95,7 +95,7 @@ use it for any responses returned from Effect. The way we wire this into our Effect `router` is going to look a little bit strange, but please stay with me: -```ts twoslash +```ts twoslash showLineNumbers=false import { Effect } from "effect" // ---cut--- import { HttpServerResponse, HttpRouter } from "@effect/platform" @@ -118,7 +118,7 @@ a lazy computation, similar to a function that hasn't been called yet. Here's an example of an `Effect` that returns the string `"Hello, world!"`: -```ts twoslash +```ts twoslash showLineNumbers=false import { Effect } from "effect" // ---cut--- Effect.sync(() => "Hello, world!") @@ -133,7 +133,7 @@ how to use the `Effect` type. `Effect.sync` takes a function and creates an `Effect` out of it. If you need to do asynchronous work in an Effect, you can use `Effect.promise`: -```ts twoslash +```ts twoslash showLineNumbers=false import { Effect } from "effect" // ---cut--- Effect.promise(async () => "Hello, world!") @@ -142,7 +142,7 @@ Effect.promise(async () => "Hello, world!") If we want to run these Effects, we can use `Effect.runSync` and `Effect.runPromise`: -```ts twoslash +```ts twoslash showLineNumbers=false import { Effect } from "effect" // ---cut--- const syncResult = Effect.runSync( @@ -161,7 +161,7 @@ console.log(asyncResult) What if you want to run an Effect that calls another Effect? You can use `Effect.gen` to do that: -```ts twoslash +```ts twoslash showLineNumbers=false import { Effect } from "effect" // ---cut--- const effect = Effect.sync(() => "Hello, world!") @@ -184,7 +184,7 @@ for the result. it a lot. Another thing you'll see used a lot is `Effect.pipe`. Here's the same code as above, but using `Effect.pipe`: -```ts twoslash +```ts twoslash showLineNumbers=false import { Effect } from "effect" // ---cut--- const effect = Effect.sync(() => "Hello, world!").pipe( @@ -201,7 +201,7 @@ provided. We can `pipe` as many `Effect`s together as we like: -```ts twoslash ins={3-5} +```ts twoslash ins={3-5} showLineNumbers=false import { Effect } from "effect" // ---cut--- const effect = Effect.sync(() => "Hello, world!").pipe( @@ -224,7 +224,7 @@ things you can do with the `Effect` type. Looking back at our `HttpRouter`, we used `pipe` to add a new endpoint to our app: -```ts twoslash +```ts twoslash showLineNumbers=false import { HttpRouter, HttpServerResponse } from "@effect/platform" import { Effect } from "effect" const health = Effect.sync(() => { @@ -240,7 +240,7 @@ const router = HttpRouter.empty.pipe( simplest router is one with no routes at all, and Effect exposes that to us as the `HttpRouter.empty` value. Under the hood, this is itself an `Effect`: -```ts twoslash +```ts twoslash showLineNumbers=false import { HttpRouter } from "@effect/platform" import { Effect } from "effect" // ---cut--- @@ -253,7 +253,7 @@ new `HttpRouter` with the given route added. If we wanted to, we could have called it like a regular function. The following two lines of code do exactly the same thing: -```ts twoslash +```ts twoslash showLineNumbers=false import { HttpRouter, HttpServerResponse } from "@effect/platform" import { Effect } from "effect" const health = Effect.sync(() => { @@ -266,22 +266,22 @@ HttpRouter.get("/health", health)(HttpRouter.empty) If we add a few more routes, we can see how this gets quickly out of hand: -```ts twoslash +```ts twoslash showLineNumbers=false import { HttpRouter, HttpServerResponse } from "@effect/platform" import { Effect } from "effect" -function health() { +const health = Effect.sync(() => { return HttpServerResponse.text("ok") -} -function status() { +}) +const status = Effect.sync(() => { return HttpServerResponse.text("ok") -} -function version() { +}) +const version = Effect.sync(() => { return HttpServerResponse.text("ok") -} +}) // ---cut--- -HttpRouter.get("/health", Effect.sync(health))( - HttpRouter.get("/status", Effect.sync(status))( - HttpRouter.get("/version", Effect.sync(version))( +HttpRouter.get("/health", health)( + HttpRouter.get("/status", status)( + HttpRouter.get("/version", version)( HttpRouter.empty, ), ), @@ -290,9 +290,9 @@ HttpRouter.get("/health", Effect.sync(health))( // vs HttpRouter.empty.pipe( - HttpRouter.get("/version", Effect.sync(version)), - HttpRouter.get("/status", Effect.sync(status)), - HttpRouter.get("/health", Effect.sync(health)), + HttpRouter.get("/version", version), + HttpRouter.get("/status", status), + HttpRouter.get("/health", health), ) ``` @@ -301,7 +301,7 @@ HttpRouter.empty.pipe( Here's the full code of our app with our `/health` endpoint being served by Effect: -```ts twoslash {9-15} +```ts twoslash {9-15} showLineNumbers=false import express from "express" import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" @@ -331,11 +331,11 @@ NodeRuntime.runMain(main) And the output of the endpoint: -```shell +```shell showLineNumbers=false http get localhost:3000/health ``` -```http +```http showLineNumbers=false HTTP/1.1 200 OK Content-Length: 2 Content-Type: text/plain diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index 20e6bb91a..8138d0764 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -17,7 +17,7 @@ database. If you don't have Docker installed, I recommend following the When you're ready, you can start a Postgres container with: -```shell +```shell showLineNumbers=false docker run -d -e POSTGRES_HOST_AUTH_METHOD=trust -p 5432:5432 postgres:17 ``` @@ -35,17 +35,17 @@ To create our `users` table and data, we're going to use the `psql` tool that's present inside of the Docker container we created. Open a new terminal and run: -```shell +```shell showLineNumbers=false docker exec -it $(docker ps -qf ancestor=postgres) psql -U postgres ``` This will drop you into a `psql` shell, with a prompt that starts `postgres=#`. From here you can run the following SQL commands: -```sql -CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT); -INSERT INTO users (name) VALUES ('Alice'); -INSERT INTO users (name) VALUES ('Bob'); +```sql showLineNumbers=false +CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT) +INSERT INTO users (name) VALUES ('Alice') +INSERT INTO users (name) VALUES ('Bob') ``` To exit `psql`, type `\q` and press enter. @@ -55,7 +55,7 @@ To exit `psql`, type `\q` and press enter. To talk to Postgres we'll need a client library. We're going to use `pg` for this tutorial. You can install it with: -```shell +```shell showLineNumbers=false bun add pg @types/pg ``` @@ -65,7 +65,7 @@ on we'll learn how to use Effect to manage our app's configuration. Here's the code for our app in full, with a new `/users` endpoint added in Express: -```ts twoslash ins={6,8,11-14,25} title=index.ts +```ts twoslash ins={6,8,11-14,25} title=index.ts showLineNumbers=false import express from "express" import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" @@ -103,17 +103,17 @@ NodeRuntime.runMain(main) We can run this code with: -```shell +```shell showLineNumbers=false bun index.ts ``` And in another shell we can test the new endpoint: -```shell +```shell showLineNumbers=false http get localhost:3000/users ``` -```http +```http showLineNumbers=false HTTP/1.1 200 OK Content-Length: 57 Content-Type: application/json; charset=utf-8 @@ -140,7 +140,7 @@ X-Powered-By: Express If we were to copy what we did with the `/health` endpoint, we'd end up with code that looked like this: -```ts twoslash +```ts twoslash showLineNumbers=false import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform" import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" import { Effect, Layer } from "effect" @@ -167,7 +167,7 @@ This doesn't work, as we can see from the error. The reason is that `HttpServerResponse.json` is an operation that might fail. Not all JavaScript objects can be converted to JSON. -```ts twoslash +```ts twoslash showLineNumbers=false JSON.stringify({ big: 10n }) //=> TypeError: JSON.stringify cannot serialize BigInt. ``` @@ -182,19 +182,20 @@ When we introduced the `Effect` type in the previous section, we dealt exclusively with Effects that couldn't fail. That won't get us very far if we're building anything non-trivial. -`Effect` is a generic type that takes 3 arguments: +`Effect` is a generic type that takes 3 type arguments: -```typescript +```typescript showLineNumbers=false Effect ``` `Success` is the type the Effect returns when it succeeds, `Error` is the type -it returns when it fails, and we'll talk about `Requirements` a bit later. +it returns when it fails, and we'll talk about `Requirements` in the next +section. When we created our example Effects in the previous section, these all took on the type `Effect`. -```ts twoslash +```ts twoslash showLineNumbers=false import { Effect } from "effect" //---cut--- const _ = Effect.sync(() => "Hello, world!") @@ -203,11 +204,90 @@ const _ = Effect.sync(() => "Hello, world!") This means they will return a `string` on success, and never fail. + + The Effect that `HttpServerResponse.json` returns is of type `Effect`. So when we try to return it from our `users` handler, we end up wrapping one Effect in another. -```ts twoslash +```ts twoslash showLineNumbers=false import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" @@ -224,7 +304,7 @@ const users = Effect.promise(async () => { This is what the type error from earlier was trying to tell us. Here's the error again: -```text wrap +```text wrap showLineNumbers=false Argument of type 'Effect, never, never>' is not assignable to parameter of type 'Handler' ``` @@ -236,7 +316,7 @@ not an `HttpServerResponse`, the type checker gets upset with us. It's possible to fix this with a call to `Effect.flatMap`: -```ts twoslash +```ts twoslash showLineNumbers=false import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" @@ -255,7 +335,7 @@ const users = Effect.flatMap( And remembering what we learned about `pipe`, we can rewrite this to be a bit more readable: -```ts twoslash +```ts twoslash showLineNumbers=false import { HttpServerResponse } from "@effect/platform" import { Effect, Layer } from "effect" import { createServer } from "node:http" @@ -283,7 +363,7 @@ you're passing some data through a sequence of functions. Take this example: -```ts twoslash +```ts twoslash showLineNumbers=false import { pipe } from "effect" const add = (x: number) => (y: number) => y + x @@ -308,7 +388,7 @@ Our pipeline starts with 1, then passes it through `add(2)`, `mul(3)`, and One of the powers this gives you is the ability to add things easily into the pipeline. -```ts twoslash ins={6-9,14,16,18} +```ts twoslash ins={6-9,14,16,18} showLineNumbers=false import { pipe } from "effect" const add = (x: number) => (y: number) => y + x @@ -345,42 +425,42 @@ the pipeline is really useful. Here's our full code so far, with new lines highlighted: -```ts twoslash title=index.ts ins={6,8,12-17,25,29} +```ts twoslash title=index.ts ins={6,8,12-17,25,29} showLineNumbers=false import express from "express" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; -import { HttpRouter, HttpServerResponse } from "@effect/platform"; -import { Effect } from "effect"; -import { Client } from "pg"; +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +import { Client } from "pg" -const db = new Client({ user: "postgres" }); +const db = new Client({ user: "postgres" }) -const app = express(); +const app = express() const users = Effect.promise( () => db.query("SELECT * FROM users"), ).pipe( Effect.map(({ rows }) => ({ users: rows })), Effect.flatMap(HttpServerResponse.json), -); +) const health = Effect.sync(() => { - return HttpServerResponse.text("ok"); + return HttpServerResponse.text("ok") }) const router = HttpRouter.empty.pipe( HttpRouter.get("/health", health), HttpRouter.get("/users", users), -); +) const main = Effect.gen(function* () { - yield* Effect.promise(() => db.connect()); - app.use(yield* NodeHttpServer.makeHandler(router)); + yield* Effect.promise(() => db.connect()) + app.use(yield* NodeHttpServer.makeHandler(router)) app.listen(3000, () => { - console.log("Server is running on http://localhost:3000"); - }); - yield* Effect.never; -}); + console.log("Server is running on http://localhost:3000") + }) + yield* Effect.never +}) -NodeRuntime.runMain(main); +NodeRuntime.runMain(main) ``` \ No newline at end of file diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index 2de1fa437..6db196391 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -47,7 +47,7 @@ go. This is the code we're going to start with. It defines an Express app with a single endpoint, `GET /health`, that listens on port 3000. -```ts twoslash title=index.ts +```ts twoslash title=index.ts showLineNumbers=false import express from "express" const app = express() @@ -72,7 +72,7 @@ important part of learning: doing. Create a new TypeScript project using your favourite package manager. I'm using `bun`: -```shell +```shell showLineNumbers=false mkdir express-tutorial cd express-tutorial bun init @@ -80,7 +80,7 @@ bun init Save our Expresss app into `index.ts` and run it to make sure it works: -```shell +```shell showLineNumbers=false bun add express bun add -D @types/express bun index.ts @@ -89,7 +89,7 @@ bun index.ts You should see `Server is running on http://localhost:3000` in your terminal, and visiting `http://localhost:3000/health` should return `ok`. -```http +```http showLineNumbers=false $ http get localhost:3000/health HTTP/1.1 200 OK Content-Length: 2 From cfaa3f52db4b28d1b96603aca08efc37b12ee4a6 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 7 Mar 2025 15:23:53 +0000 Subject: [PATCH 22/28] One last reference to Express.js -> Express --- content/src/content/docs/learn/tutorials/first-endpoint.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/src/content/docs/learn/tutorials/first-endpoint.mdx b/content/src/content/docs/learn/tutorials/first-endpoint.mdx index 7e7015e6b..5d2c1c0d7 100644 --- a/content/src/content/docs/learn/tutorials/first-endpoint.mdx +++ b/content/src/content/docs/learn/tutorials/first-endpoint.mdx @@ -345,7 +345,7 @@ X-Powered-By: Express ok ``` -This output has 2 small differences from the Express.js version: +This output has 2 small differences from the Express version: 1. The `Content-Type` header is `text/plain` instead of `text/plain; charset=utf-8`. 2. The `ETag` header is missing. From b7f881c77c39827ceb4e0485e5b211ac3cf77154 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 28 Mar 2025 10:46:52 +0000 Subject: [PATCH 23/28] Incorporate feedback from Livorno. --- .../docs/learn/tutorials/calling-an-api.mdx | 224 +++++++++--------- .../docs/learn/tutorials/first-endpoint.mdx | 62 ++--- .../learn/tutorials/hooking-up-a-database.mdx | 170 +++++-------- .../docs/learn/tutorials/introduction.mdx | 12 +- 4 files changed, 196 insertions(+), 272 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/calling-an-api.mdx b/content/src/content/docs/learn/tutorials/calling-an-api.mdx index fda8827ec..1f4534c59 100644 --- a/content/src/content/docs/learn/tutorials/calling-an-api.mdx +++ b/content/src/content/docs/learn/tutorials/calling-an-api.mdx @@ -8,6 +8,8 @@ sidebar: order: 4 --- +import { Aside } from "@astrojs/starlight/components" + Next up we're going to take a look at an endpoint that makes an API call to another service. Here's the code: @@ -20,16 +22,16 @@ import { Effect } from "effect" const app = express() -app.get("/wow", async (req, res) => { - const url = new URL(`https://owen-wilson-wow-api.onrender.com/wows/random`) - - const year = req.query.year - if (typeof year === "string") { - url.searchParams.set("year", year) +app.get("/search", async (req, res) => { + const query = req.query.q + if (typeof query !== "string") { + res.status(400).send("Query parameter 'q' is required") + return } - const wow = await fetch(url.toString()) - res.json(await wow.json()) + const url = `https://en.wikipedia.org/w/rest.php/v1/search/page?q=${query}&limit=1` + const results = await fetch(url) + res.json(await results.json()) }) const router = HttpRouter.empty @@ -48,16 +50,15 @@ NodeRuntime.runMain(main) I've removed our `/health` and `/users` endpoints to keep the code to a reasonable length. -For a bit of fun I've chosen the [Owen Wilson Wow -API](https://owen-wilson-wow-api.onrender.com/) as our API to call. I've also -decided to use a query parameter to filter the results by year, to show how this -works in Effect. +We're using the [Wikipedia Search API](https://www.mediawiki.org/wiki/API%3AREST_API) +as an example, and I've also used a query parameter `q` to use in the request, +to show how this works in Effect. ## Referencing the current request If we were to reach straight away for `Effect.promise`, we would quickly -run into a problem: how do we reference the HTTP request object? We need to -get the `year` from the query parameters, but if you look back at our +run into a problem: how do we reference the current request object? We need to +get the `q` from the query parameters, but if you look back at our `/users` endpoint, you'll see that there's no request object. ```ts twoslash showLineNumbers=false @@ -66,11 +67,11 @@ import { HttpServerResponse } from "@effect/platform" import { Client } from "pg" const db = new Client({ user: "postgres" }) //---cut--- -const users = Effect.promise( - () => db.query("SELECT * FROM users"), -).pipe( - Effect.map(({ rows }) => ({ users: rows })), - Effect.flatMap(HttpServerResponse.json), +const users = Effect.promise(async () => { + const { rows } = await db.query("SELECT * FROM users") + return { users: rows } +}).pipe( + Effect.flatMap((body) => HttpServerResponse.json(body)) ) ``` @@ -90,7 +91,7 @@ const query = Effect.gen(function* () { }) ``` -If we wire this handler up as a new `/query` endpoint in our application: +If we add this handler as a new `/query` endpoint in our application: ```ts twoslash showLineNumbers=false import { Effect } from "effect" @@ -108,18 +109,12 @@ const router = HttpRouter.empty.pipe( We can call it like so: ```shell showLineNumbers=false -http get "localhost:3000/query?year=2010" +http -pb get "localhost:3000/query?q=typescript" ``` -```http showLineNumbers=false -HTTP/1.1 200 OK -Content-Length: 16 -Content-Type: application/json -Date: Fri, 21 Feb 2025 16:41:03 GMT -X-Powered-By: Express - +```json showLineNumbers=false { - "url": "/query?year=2010" + "url": "/query?q=typescript" } ``` @@ -136,7 +131,7 @@ import { import { Effect } from "effect" //---cut--- const query = Effect.gen(function* () { - // ^? + // query: Effect const req = yield* HttpServerRequest.HttpServerRequest return yield* HttpServerResponse.json({ url: req.url }) }) @@ -170,16 +165,17 @@ What this is telling us is that this Effect cannot be run unless an ## Providing requirements -The way we provide an Effect with things it needs is through the use of +One way we can provide an Effect with things it needs is through the use of "services." If you've used dependency injection in other languages, Effect's services are the same idea. To keep track of what services are available, Effect maintains an internal -context object. You can think of this as a map of services, with the key being a -unique identifier for the service, and the value being the service itself. +"context" object. You can think of this as a map of services, with the key being +a unique identifier for the service, and the value being the service itself. -Effect uses a special type called a `Tag` as the unique identifier for services, -and the syntax for defining one is unusual so bear with me here: +Effect uses a type called a `Tag` as the unique identifier for services. The +way that tags are defined is quite complex, but this complexity is necessary +in order to get the level of type safety that Effect provides. ```ts twoslash showLineNumbers=false import { Context, Effect } from "effect" @@ -188,22 +184,34 @@ class FavouriteNumber extends Context.Tag("FavouriteNumber")< FavouriteNumber, number >() {} +``` + +You will typically make tags once per service and then forget about them, so +don't worry too much about memorising this syntax. The important things to note +are that: + +- The service is called `FavouriteNumber`. +- It returns a `number`. -const effect = Effect.gen(function* () { +To use this service in an `Effect` we can use `Effect.gen` as we saw in the +previous section. + +```ts twoslash showLineNumbers=false +import { Context, Effect } from "effect" +class FavouriteNumber extends Context.Tag("FavouriteNumber")< + FavouriteNumber, + number +>() {} +//---cut--- +const printNum = Effect.gen(function* () { const n = yield* FavouriteNumber console.log(`My favourite number is ${n}`) }) ``` -The class declaration at the top defines the tag as well as the type for the -service. In this example we just return a `number`, but if you wanted to you -could return a more complex object. - -In the `Effect.gen` call we `yield*` the tag to get the value of the service. -Note that we haven't created an implementation of the service yet, so we won't -be able to run this Effect. - -Here's how we would implement and then provide the service: +Lastly, we won't be able to run this `Effect` until we provide an implementation +of the `FavouriteNumber` service to it, which we can do with +`Effect.provideService`. ```ts twoslash showLineNumbers=false import { Context, Effect } from "effect" @@ -212,18 +220,18 @@ class FavouriteNumber extends Context.Tag("FavouriteNumber")< number >() {} -const effect = Effect.gen(function* () { +const printNum = Effect.gen(function* () { const n = yield* FavouriteNumber console.log(`My favourite number is ${n}`) }) //---cut--- Effect.runPromise( - effect.pipe(Effect.provideService(FavouriteNumber, 42)) + printNum.pipe(Effect.provideService(FavouriteNumber, 42)) ) // => My favourite number is 42 Effect.runPromise( - effect.pipe(Effect.provideService(FavouriteNumber, 100)) + printNum.pipe(Effect.provideService(FavouriteNumber, 100)) ) // => My favourite number is 100 ``` @@ -238,13 +246,13 @@ class FavouriteNumber extends Context.Tag("FavouriteNumber")< >() {} //---cut--- const needsRequirements = Effect.gen(function* () { -// ^? + // needsRequirements: Effect const n = yield* FavouriteNumber console.log(`My favourite number is ${n}`) }) const hasRequirements = needsRequirements.pipe( -// ^? + // hasRequirements: Effect Effect.provideService(FavouriteNumber, 42) ) ``` @@ -261,14 +269,19 @@ To: Effect ``` -So each Effect can be thought of as a self-contained program. Services are its -inputs, it has an output, and it can error. Effect forces us to be more -deliberate about what our programs need to run, and that makes it easier to -reason about them. +So each Effect can be thought of as a self-contained program. Its requirements +are its inputs, its success value is its output, and it can error. Effect forces +us to be more deliberate about our programs. Everything each Effect needs to run +is encoded into the type system, and this enables TypeScript to help us more +deeply than it would normally be able to. + + ## Parsing the query string @@ -291,7 +304,7 @@ const query = Effect.gen(function* () { }) ``` -We want to take that `req.url` and parse out the `year` query parameter. We can +We want to take that `req.url` and parse out the `q` query parameter. We can do this with the `URL` class. ```ts twoslash ins={3-4} showLineNumbers=false @@ -305,32 +318,26 @@ import { Effect } from "effect" const query = Effect.gen(function* () { const req = yield* HttpServerRequest.HttpServerRequest const url = new URL(req.url, `http://${req.headers.host}`) - const year = url.searchParams.get("year") - return yield* HttpServerResponse.json({ year }) + const q = url.searchParams.get("q") + return yield* HttpServerResponse.json({ q }) }) ``` And now we can see the year in the response: ```shell showLineNumbers=false -http get "localhost:3000/query?year=2010" +http -pb get "localhost:3000/query?q=typescript" ``` ```http showLineNumbers=false -HTTP/1.1 200 OK -Content-Length: 15 -Content-Type: application/json -Date: Fri, 07 Mar 2025 14:21:50 GMT -X-Powered-By: Express - { - "year": "2010" + "q": "typescript" } ``` ## Calling the API -Now that we have the `year` query parameter, we can use it to filter the results +Now that we have the `q` query parameter, we can use it to filter the results from the API. ```ts twoslash ins={6-18} showLineNumbers=false @@ -342,59 +349,50 @@ import { import { Effect } from "effect" //---cut--- const query = Effect.gen(function* () { - const req = yield* HttpServerRequest.HttpServerRequest - const requestUrl = new URL(req.url, `http://${req.headers.host}`) - const year = requestUrl.searchParams.get("year") - - const apiUrl = new URL( - "https://owen-wilson-wow-api.onrender.com/wows/random" - ) - if (year !== null) { - apiUrl.searchParams.set("year", year) + const req = yield* HttpServerRequest.HttpServerRequest; + const url = new URL(req.url, `http://${req.headers.host}`) + const q = url.searchParams.get("q"); + + if (!q) { + return yield* HttpServerResponse.json( + { error: "Query parameter 'q' is required" }, + { status: 400 } + ); } const response = yield* Effect.promise(async () => { - const resp = await fetch(apiUrl.toString()) - return await resp.json() - }) + const api = `https://en.wikipedia.org/w/rest.php/v1/search/page?q=${q}&limit=1`; + return fetch(api).then((resp) => resp.json()); + }); - return yield* HttpServerResponse.json(response) -}) + return yield* HttpServerResponse.json(response); +}); ``` And querying this endpoint we now get the filtered results: ```shell showLineNumbers=false -http get "localhost:3000/query?year=2010" +http -pb get "localhost:3000/query?q=typescript" ``` -```http showLineNumbers=false -HTTP/1.1 200 OK -Content-Length: 1066 -Content-Type: application/json -Date: Fri, 07 Mar 2025 14:45:01 GMT -X-Powered-By: Express - -[ - { - "audio": "https://assets.ctfassets.net/bs8ntwkklfua/3B0YwIinFRjyDhg87ubLnm/e7ca0400c19fe1fe118ebc2901dc3518/Little_Fockers_Wow_1.mp3", - "character": "Kevin Rawley", - "current_wow_in_movie": 1, - "director": "Paul Weitz", - "full_line": "Wow.", - "movie": "Little Fockers", - "movie_duration": "01:37:40", - "poster": "https://images.ctfassets.net/bs8ntwkklfua/2uitJuVd7wiuh57N42VotB/97be419cc8d17405b256bf6f63afd461/Little_Fockers_Poster.jpg", - "release_date": "2010-12-22", - "timestamp": "00:26:32", - "total_wows_in_movie": 2, - "video": { - "1080p": "https://videos.ctfassets.net/bs8ntwkklfua/6sQcwSbOHUtQwSa2AiGVx4/4bd91b3f82378c6cec36003010b6bc7b/Little_Fockers_Wow_1_1080p.mp4", - "360p": "https://videos.ctfassets.net/bs8ntwkklfua/4NRdmxRxOFF7u8uSDCDpbz/f948d2f764468c89f5e37fa2b3804661/Little_Fockers_Wow_1_360p.mp4", - "480p": "https://videos.ctfassets.net/bs8ntwkklfua/5jBrCwue7YyNWATPMsnEM3/3897126fa0cacee7817cb798db85a829/Little_Fockers_Wow_1_480p.mp4", - "720p": "https://videos.ctfassets.net/bs8ntwkklfua/1MgGCFiDEMuDtZf5pPXZrq/9abb8fedcf84c2aad104a993cab043ef/Little_Fockers_Wow_1_720p.mp4" - }, - "year": 2010 - } -] +```json showLineNumbers=false +{ + "pages": [ + { + "description": "Programming language and superset of JavaScript", + "excerpt": "TypeScript (abbreviated as TS) is a free and open-source high-level programming language developed by Microsoft that adds static typing with optional type", + "id": 8157205, + "key": "TypeScript", + "matched_title": null, + "thumbnail": { + "duration": null, + "height": 60, + "mimetype": "image/svg+xml", + "url": "//upload.wikimedia.org/wikipedia/commons/thumb/f/f5/Typescript.svg/60px-Typescript.svg.png", + "width": 60 + }, + "title": "TypeScript" + } + ] +} ``` \ No newline at end of file diff --git a/content/src/content/docs/learn/tutorials/first-endpoint.mdx b/content/src/content/docs/learn/tutorials/first-endpoint.mdx index 5d2c1c0d7..4c2aaaa7a 100644 --- a/content/src/content/docs/learn/tutorials/first-endpoint.mdx +++ b/content/src/content/docs/learn/tutorials/first-endpoint.mdx @@ -28,7 +28,10 @@ but to make sure this tutorial is as widely applicable as possible, I'm going to use `@effect/platform-node`. -Then make the highlighted changes to our Express app: +The first step is to introduce some boilerplate code that will allow us to +serve both Effect and Express endpoints at the same time. I'm not expecting you +to understand everything you read in the following code block, all will be +explained as we progress through this tutorial. ```ts twoslash ins={3-5,16-26} del={13-15} title=index.ts showLineNumbers=false import express from "express" @@ -59,12 +62,6 @@ const main = Effect.gen(function* () { NodeRuntime.runMain(main) ``` -And we can run the resulting code like so: - -```shell showLineNumbers=false -bun index.ts -``` - Our Express `/health` handler still works as expected, but we've wrapped the Express app in a way that will allow us to define endpoints using Effect while still responding to our existing endpoints in Express. @@ -126,8 +123,7 @@ Effect.sync(() => "Hello, world!") `Effect.sync` takes a function and creates an `Effect` out of it. If you need to @@ -139,13 +135,12 @@ import { Effect } from "effect" Effect.promise(async () => "Hello, world!") ``` -If we want to run these Effects, we can use `Effect.runSync` and -`Effect.runPromise`: +If we want to run these Effects, we can use `Effect.runPromise`: ```ts twoslash showLineNumbers=false import { Effect } from "effect" // ---cut--- -const syncResult = Effect.runSync( +const syncResult = Effect.runPromise( Effect.sync(() => "Hello, world!") ) console.log(syncResult) @@ -158,8 +153,12 @@ console.log(asyncResult) // => "Hello, world!" ``` -What if you want to run an Effect that calls another Effect? You can use -`Effect.gen` to do that: +`Effect.runPromise` runs an Effect and returns a `Promise` that resolves to the +result of the Effect. You can pass both synchronous and asynchronous Effects to +`Effect.runPromise`, and it will handle them both. + +What if you want to run an Effect that uses the result of another Effect? You +can use `Effect.gen` to do that: ```ts twoslash showLineNumbers=false import { Effect } from "effect" @@ -174,15 +173,14 @@ console.log(result) // => "HELLO, WORLD!" ``` -This makes use of JavaScript's [generator functions][1]. The way Effect uses -them, if you think of an Effect as being like a `Promise`, `yield*`ing an -`Effect` is the same as `await`ing a `Promise`. Under the hood, all `Effect`s -are generators, and `yield*`ing one passes it to the Effect runtime and waits -for the result. +This makes use of JavaScript's [generator functions][1]. You can think of an +Effect as being like a `Promise`, and `yield*`ing an `Effect` is the same as +`await`ing a `Promise`. Under the hood, all `Effect`s are generators, and +`yield*`ing one passes it to the Effect runtime and waits for the result. `Effect.gen` is one of the most versatile ways to create Effects, and you'll use -it a lot. Another thing you'll see used a lot is `Effect.pipe`. Here's the same -code as above, but using `Effect.pipe`: +it a lot. Another thing you'll see used a lot is `Effect.pipe`. Here's an +example that does the same work as code as above, but using `Effect.pipe`: ```ts twoslash showLineNumbers=false import { Effect } from "effect" @@ -190,7 +188,7 @@ import { Effect } from "effect" const effect = Effect.sync(() => "Hello, world!").pipe( Effect.map((s) => s.toUpperCase()) ) -const result = await Effect.runSync(effect) +const result = await Effect.runPromise(effect) console.log(result) // => "HELLO, WORLD!" ``` @@ -210,7 +208,7 @@ const effect = Effect.sync(() => "Hello, world!").pipe( Effect.map((s) => s.reverse()), Effect.map((s) => s.join("")), ) -const result = await Effect.runSync(effect) +const result = await Effect.runPromise(effect) console.log(result) // => "!DLROW ,OLLEH" ``` @@ -264,7 +262,8 @@ HttpRouter.empty.pipe(HttpRouter.get("/health", health)) HttpRouter.get("/health", health)(HttpRouter.empty) ``` -If we add a few more routes, we can see how this gets quickly out of hand: +If we add a few more routes, though, we can see how this gets quickly out of +hand: ```ts twoslash showLineNumbers=false import { HttpRouter, HttpServerResponse } from "@effect/platform" @@ -332,24 +331,11 @@ NodeRuntime.runMain(main) And the output of the endpoint: ```shell showLineNumbers=false -http get localhost:3000/health +http -pb get localhost:3000/health ``` ```http showLineNumbers=false -HTTP/1.1 200 OK -Content-Length: 2 -Content-Type: text/plain -Date: Fri, 14 Feb 2025 13:51:39 GMT -X-Powered-By: Express - ok ``` -This output has 2 small differences from the Express version: - -1. The `Content-Type` header is `text/plain` instead of `text/plain; charset=utf-8`. -2. The `ETag` header is missing. - -TODO(samwho): explain how to close this gap. - [1]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function* diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index 8138d0764..c01938337 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -24,9 +24,6 @@ docker run -d -e POSTGRES_HOST_AUTH_METHOD=trust -p 5432:5432 postgres:17 ## Creating test data @@ -56,7 +53,8 @@ To talk to Postgres we'll need a client library. We're going to use `pg` for this tutorial. You can install it with: ```shell showLineNumbers=false -bun add pg @types/pg +bun add pg +bun add -D @types/pg ``` To start with, we're going to hardcode our database connection details. Later @@ -101,26 +99,14 @@ const main = Effect.gen(function* () { NodeRuntime.runMain(main) ``` -We can run this code with: +Here's what you should see when you run this code and you've set up your +Postgres database correctly: ```shell showLineNumbers=false -bun index.ts +http -pb get localhost:3000/users ``` -And in another shell we can test the new endpoint: - -```shell showLineNumbers=false -http get localhost:3000/users -``` - -```http showLineNumbers=false -HTTP/1.1 200 OK -Content-Length: 57 -Content-Type: application/json; charset=utf-8 -Date: Fri, 14 Feb 2025 14:28:02 GMT -ETag: W/"39-6Qu85qIU12mhgGYWwHErQVfJRxI" -X-Powered-By: Express - +```json showLineNumbers=false { "users": [ { @@ -138,7 +124,7 @@ X-Powered-By: Express ## The direct approach If we were to copy what we did with the `/health` endpoint, we'd end up with -code that looked like this: +broken code. Here's how it would look: ```ts twoslash showLineNumbers=false import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform" @@ -163,18 +149,18 @@ const router = HttpRouter.empty.pipe( ) ``` -This doesn't work, as we can see from the error. The reason is that -`HttpServerResponse.json` is an operation that might fail. Not all JavaScript -objects can be converted to JSON. +Effect error messages can be difficult to decipher at first. What this one is +trying to tell us is that our `users` HTTP handler does not return the expected +type. The reason for this is that `HttpServerResponse.json` is an operation that +might fail. Not all JavaScript objects can be converted to JSON. ```ts twoslash showLineNumbers=false JSON.stringify({ big: 10n }) //=> TypeError: JSON.stringify cannot serialize BigInt. ``` -For this reason, `HttpServerResponse.json` returns an `Effect` that can fail. -We didn't see this earlier because `HttpServerResponse.text` can't fail, and -so just returns an `HttpServerResponse` directly. +Because it can fail, instead of returning an `HttpServerResponse` directly, +it returns another `Effect`. ## Error handling in Effect @@ -198,12 +184,24 @@ the type `Effect`. ```ts twoslash showLineNumbers=false import { Effect } from "effect" //---cut--- -const _ = Effect.sync(() => "Hello, world!") -// ^? +const s = Effect.sync(() => "Hello, world!") +// s: Effect ``` This means they will return a `string` on success, and never fail. +When an `Effect` _can_ fail, its return value with have a different type. For +example, the `HttpServerResponse.json` function returns a value of type: + +```typescript showLineNumbers=false +Effect +``` + +This means that it will return an `HttpServerResponse` on success, and an +`HttpBodyError` on failure. This pushing of errors into the type system is one +of the key features of Effect. It makes it much harder for us to forget to +handle edge cases, and leads to more robust and reliable products. + -The Effect that `HttpServerResponse.json` returns is of type -`Effect`. So when we try to return -it from our `users` handler, we end up wrapping one Effect in another. - -```ts twoslash showLineNumbers=false -import { HttpServerResponse } from "@effect/platform" -import { Effect, Layer } from "effect" -import { createServer } from "node:http" -import { Client } from "pg" -const db = new Client({ user: "postgres" }) -//---cut--- -const users = Effect.promise(async () => { -// ^? - const { rows } = await db.query("SELECT * FROM users") - return HttpServerResponse.json({ users: rows }) -}) -``` - -This is what the type error from earlier was trying to tell us. Here's the -error again: - -```text wrap showLineNumbers=false -Argument of type 'Effect, never, never>' is not assignable to parameter of type 'Handler' -``` - -Under the hood, a `Handler` is just a type alias for `Effect`. Because `Effect` is -not an `HttpServerResponse`, the type checker gets upset with us. - ## Fixing our nested Effects with `flatMap` -It's possible to fix this with a call to `Effect.flatMap`: - -```ts twoslash showLineNumbers=false -import { HttpServerResponse } from "@effect/platform" -import { Effect, Layer } from "effect" -import { createServer } from "node:http" -import { Client } from "pg" -const db = new Client({ user: "postgres" }) -//---cut--- -const users = Effect.flatMap( - Effect.promise(async () => { - const { rows } = await db.query("SELECT * FROM users") - return { users: rows } - }), - HttpServerResponse.json -) -``` - -And remembering what we learned about `pipe`, we can rewrite this to be a bit -more readable: +We can solve all of our problems with a call to `Effect.flatMap`: ```ts twoslash showLineNumbers=false import { HttpServerResponse } from "@effect/platform" @@ -342,26 +283,29 @@ import { createServer } from "node:http" import { Client } from "pg" const db = new Client({ user: "postgres" }) //---cut--- -const users = Effect.promise( - () => db.query("SELECT * FROM users") -).pipe( - Effect.map(({ rows }) => ({ users: rows })), - Effect.flatMap(HttpServerResponse.json) +const users = Effect.promise(async () => { + const { rows } = await db.query("SELECT * FROM users") + return { users: rows } +}).pipe( + Effect.flatMap((body) => HttpServerResponse.json(body)) ) ``` -This no longer gives us a type error. We've split our computation into parts: -fetching the data with `Effect.promise`, manipulating its shape with -`Effect.map`, and serializing it with `HttpServerResponse.json`. The -`Effect.flatMap` at the end takes care of the `Effect` nesting we had in our -previous implementation. +The `Effect.flatMap` takes in the body object we want to serialise, passes it to +`HttpServerResponse.json`, and rather than returning a wrapped `Effect` it will +_replace_ the original `Effect` with the new one from `HttpServerResponse.json`. +The result is that our handler has the correct return type and we no longer +have any type errors. ## An aside on pipes -If `pipe` isn't quite clicking for you yet, one way to visualise it is that -you're passing some data through a sequence of functions. +If you're new to functional programming, you might be finding `pipe` difficult +to understand. That's perfectly normal! You can skip this section if you feel +comfortable with `pipe`, but if you don't I'm going to show you some more +examples to help you understand it. -Take this example: +You can visualise a `pipe` as a series of functions that will be called in order +on a value. The value gets "fed" through the pipe. ```ts twoslash showLineNumbers=false import { pipe } from "effect" @@ -437,11 +381,11 @@ const db = new Client({ user: "postgres" }) const app = express() -const users = Effect.promise( - () => db.query("SELECT * FROM users"), -).pipe( - Effect.map(({ rows }) => ({ users: rows })), - Effect.flatMap(HttpServerResponse.json), +const users = Effect.promise(async () => { + const { rows } = await db.query("SELECT * FROM users") + return { users: rows } +}).pipe( + Effect.flatMap((body) => HttpServerResponse.json(body)) ) const health = Effect.sync(() => { diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index 6db196391..df7e09a48 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -89,15 +89,11 @@ bun index.ts You should see `Server is running on http://localhost:3000` in your terminal, and visiting `http://localhost:3000/health` should return `ok`. -```http showLineNumbers=false -$ http get localhost:3000/health -HTTP/1.1 200 OK -Content-Length: 2 -Content-Type: text/plain; charset=utf-8 -Date: Fri, 14 Feb 2025 13:48:43 GMT -ETag: W/"2-eoX0dku9ba8cNUXvu/DyeabcC+s" -X-Powered-By: Express +```shell showLineNumbers=false +$ http -pb get localhost:3000/health +``` +```http showLineNumbers=false ok ``` From f6da245dd15f1e4a74c37c70d3f269edd2f0b295 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 28 Mar 2025 17:17:42 +0000 Subject: [PATCH 24/28] Started work on a new section about services and layers. --- .../docs/learn/tutorials/calling-an-api.mdx | 56 ++- .../learn/tutorials/hooking-up-a-database.mdx | 8 +- .../learn/tutorials/services-and-config.mdx | 381 ++++++++++++++++++ 3 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 content/src/content/docs/learn/tutorials/services-and-config.mdx diff --git a/content/src/content/docs/learn/tutorials/calling-an-api.mdx b/content/src/content/docs/learn/tutorials/calling-an-api.mdx index 1f4534c59..1997f01cb 100644 --- a/content/src/content/docs/learn/tutorials/calling-an-api.mdx +++ b/content/src/content/docs/learn/tutorials/calling-an-api.mdx @@ -22,7 +22,7 @@ import { Effect } from "effect" const app = express() -app.get("/search", async (req, res) => { +app.get("/query", async (req, res) => { const query = req.query.q if (typeof query !== "string") { res.status(400).send("Query parameter 'q' is required") @@ -395,4 +395,58 @@ http -pb get "localhost:3000/query?q=typescript" } ] } +``` + +## The end result + +Here is the full code for the endpoint we've written in this section. Again, +this doesn't include the `/health` and `/users` endpoints to keep the code +to a reasonable length. + +```ts twoslash title=index.ts ins={13-31,34} showLineNumbers=false +import express from "express"; + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Effect } from "effect"; + +const app = express(); + +const query = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + const url = new URL(req.url, `http://${req.headers.host}`); + const q = url.searchParams.get("q"); + + if (!q) { + return yield* HttpServerResponse.json( + { error: "Query parameter 'q' is required" }, + { status: 400 } + ); + } + + const response = yield* Effect.promise(async () => { + const api = `https://en.wikipedia.org/w/rest.php/v1/search/page?q=${q}&limit=1`; + return fetch(api).then((resp) => resp.json()); + }); + + return yield* HttpServerResponse.json(response); +}); + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/query", query) +); + +const main = Effect.gen(function* () { + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000, () => { + console.log("Server is running on http://localhost:3000"); + }); + yield* Effect.never; +}); + +NodeRuntime.runMain(main); ``` \ No newline at end of file diff --git a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx index c01938337..43aa39dd1 100644 --- a/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -33,16 +33,16 @@ tool that's present inside of the Docker container we created. Open a new terminal and run: ```shell showLineNumbers=false -docker exec -it $(docker ps -qf ancestor=postgres) psql -U postgres +docker exec -it $(docker ps -qf ancestor=postgres:17) psql -U postgres ``` This will drop you into a `psql` shell, with a prompt that starts `postgres=#`. From here you can run the following SQL commands: ```sql showLineNumbers=false -CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT) -INSERT INTO users (name) VALUES ('Alice') -INSERT INTO users (name) VALUES ('Bob') +CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT); +INSERT INTO users (name) VALUES ('Alice'); +INSERT INTO users (name) VALUES ('Bob'); ``` To exit `psql`, type `\q` and press enter. diff --git a/content/src/content/docs/learn/tutorials/services-and-config.mdx b/content/src/content/docs/learn/tutorials/services-and-config.mdx new file mode 100644 index 000000000..7215d1fe4 --- /dev/null +++ b/content/src/content/docs/learn/tutorials/services-and-config.mdx @@ -0,0 +1,381 @@ +--- +type: Tutorial +title: Services and Config +tags: + - some + - tags +sidebar: + order: 5 +--- + +import { Aside } from "@astrojs/starlight/components" + +In the previous sections we've seen how to write endpoints in Effect that call +out to databases and other APIs. + +In this section we're going to revisit that code and write it more +"Effectfully." We're going to create services for our database and external API +calls, and use Effect's `Config` module to pass configuration to our +application. This will align our code more with the Effect best practices. + +We're going to start out creating a service for our database. As a reminder, +here's what our `/users` endpoint looked like: + +```ts twoslash showLineNumbers=false +import { HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +import { Client } from "pg" + +const db = new Client({ user: "postgres" }) +//---cut--- +const users = Effect.promise(async () => { + const { rows } = await db.query("SELECT * FROM users") + return { users: rows } +}).pipe( + Effect.flatMap((body) => HttpServerResponse.json(body)) +) +``` + +## Defining a Database service + +The first thing we need is a `Tag` for this service, which looks like this: + +```ts twoslash showLineNumbers=false +import { Context, Effect } from "effect"; +//---cut--- +class Database extends Context.Tag("Database")< + Database, + { + getUsers: () => Effect.Effect; + } +>() {} +``` + +And then we can use the `Database.of` method to create an instance of the service: + +```ts twoslash showLineNumbers=false +import { Context, Effect } from "effect"; +import { Client } from "pg" +const db = new Client({ user: "postgres" }) +class Database extends Context.Tag("Database")< + Database, + { + getUsers: () => Effect.Effect; + } +>() {} +//---cut--- +const DatabaseLive = Database.of({ + getUsers: () => Effect.promise(async () => { + const { rows } = await db.query("SELECT * FROM users") + return rows + }), +}) +``` + +The choice of `DatabaseLive` for the name is a common convention you will see +in Effect code, where "live" is referring to the fact that this is the version +of this service we will use in live, production code. + +When originally implementing the `/users` endpoint, I glossed over the fact that +database calls can fail. I'm going to right that wrong here by using +`Effect.tryPromise` and wrapping any error we get back from `pg`. + +```ts twoslash ins={1-3,8,15-19} showLineNumbers=false +import { Context, Effect } from "effect"; +import { Client } from "pg" +const db = new Client({ user: "postgres" }) +//---cut--- +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} + +class Database extends Context.Tag("Database")< + Database, + { + getUsers: () => Effect.Effect; + } +>() {} + +const DatabaseLive = Database.of({ + getUsers: () => + Effect.gen(function* () { + const { rows } = yield* Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (cause) => new DatabaseError("Failed to fetch users", { cause }), + }); + return rows; + }), +}); +``` + +Where `Effect.promise` takes a function that returns a `Promise` and +creates an `Effect`, `Effect.tryPromise` takes two functions: + +1. `try`: a function that returns a `Promise` +2. `catch`: a function that takes any error from the `Promise` and returns a + value of type `E` + +The result is an `Effect`. This is a way of taking async/await code +and turning it into Effect code, complete with typed errors. + +Also noteworthy is that our `DatabaseError` class has a `_tag` property. This +will come in useful later on in this section, and I'll explain why when it does. + +The last touch I want to put on this service for now is to get rid of that +`any[]` type in the `getUsers` method. Let's create a proper `User` type and use +that instead. + +```ts twoslash ins={1-4,9,20} showLineNumbers=false +import { Context, Effect } from "effect"; +import { Client } from "pg" +const db = new Client({ user: "postgres" }) +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} +//---cut--- +interface User { + id: string + name: string +} + +class Database extends Context.Tag("Database")< + Database, + { + getUsers: () => Effect.Effect; + } +>() {} + +const DatabaseLive = Database.of({ + getUsers: () => + Effect.gen(function* () { + const { rows } = yield* Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (cause) => new DatabaseError("Failed to fetch users", { cause }), + }); + return rows as User[]; + }), +}); +``` + +## Using our Database service + +Now that we've defined `Database`, we can use it to simplify our `/users` +endpoint. Here's what it looks like now: + +```ts twoslash showLineNumbers=false +import { HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +import { Client } from "pg" + +const db = new Client({ user: "postgres" }) +//---cut--- +const users = Effect.promise(async () => { + const { rows } = await db.query("SELECT * FROM users") + return { users: rows } +}).pipe( + Effect.flatMap((body) => HttpServerResponse.json(body)) +) +``` + +And here's what it looks like with our new `Database` service: + +```ts twoslash showLineNumbers=false +import { HttpServerResponse } from "@effect/platform" +import { Effect, Context } from "effect" +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} +interface User { + id: string + name: string +} + +class Database extends Context.Tag("Database")< + Database, + { + getUsers: () => Effect.Effect; + } +>() {} +//---cut--- +const users = Effect.gen(function* () { + const db = yield* Database; + const users = yield* db.getUsers(); + return yield* HttpServerResponse.json(users); +}); +``` + +How cool is that? Take a close look at the type of our `users` handler now: + +```typescript +Effect +``` + +We can tell at a glance that this handler can fail with either a `DatabaseError` +or a `HttpBodyError`, and it needs a `Database` in order to do its work. +If you've been following along, you'll also have noticed now that there's a new +error at the bottom of our code: + +```ts twoslash showLineNumbers=false +// @errors: 2769 +import express from "express"; + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Context, Effect } from "effect"; +import { Client } from "pg"; + +const db = new Client({ user: "postgres" }); +const app = express(); + +interface User { + id: string; + name: string; +} + +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} + +class Database extends Context.Tag("Database")< + Database, + { + getUsers: () => Effect.Effect; + } +>() {} + +const users = Effect.gen(function* () { + const db = yield* Database; + const users = yield* db.getUsers(); + return yield* HttpServerResponse.json(users); +}); + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/users", users) +); + +const main = Effect.gen(function* () { + yield* Effect.promise(() => db.connect()); + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000, () => { + console.log("Server is running on http://localhost:3000"); + }); + yield* Effect.never; +}); +//---cut--- +NodeRuntime.runMain(main); +``` + +This is because the `Database` requirement of our `users` handler is not +satisfied. We need to provide a `Database` instance to our `main` Effect. + + +```ts twoslash ins={8-10} showLineNumbers=false +import express from "express"; + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Context, Effect } from "effect"; +import { Client } from "pg"; + +const db = new Client({ user: "postgres" }); +const app = express(); + +interface User { + id: string; + name: string; +} + +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} + +class Database extends Context.Tag("Database")< + Database, + { + getUsers: () => Effect.Effect; + } +>() {} + +const DatabaseLive = Database.of({ + getUsers: () => + Effect.gen(function* () { + const { rows } = yield* Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (cause) => new DatabaseError("Failed to fetch users", { cause }), + }); + return rows; + }), +}); + +const users = Effect.gen(function* () { + const db = yield* Database; + const users = yield* db.getUsers(); + return yield* HttpServerResponse.json(users); +}); + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/users", users) +); + +//---cut--- +const main = Effect.gen(function* () { + yield* Effect.promise(() => db.connect()); + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000, () => { + console.log("Server is running on http://localhost:3000"); + }); + yield* Effect.never; +}).pipe( + Effect.provideService(Database, DatabaseLive), +); +NodeRuntime.runMain(main); +``` + +## Services that depend on other services + +Our `Database` service has added modularity to our code, but there's still that +pesky `Client` instance hanging around. Technically, our `Database` service +depends on that `Client` instance being available and connected. We should +represent this in the type system. + +### Layers + +The way that Effect handles services that depend on other services is through +the `Layer` system. Layers exist simply to encode the dependencies between +services, and we can start by creating a "base" layer that providers our +`pg` `Client` instance. + +```ts twoslash showLineNumbers=false +import { Context, Effect, Layer } from "effect"; +import { Client } from "pg"; + +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} +//---cut--- +class PostgresClient extends Context.Tag("PostgresClient")< + PostgresClient, + { connect: () => Effect.Effect } +>() {} + +const PostgresClientLive = Layer.succeed(PostgresClient, { + connect: () => + Effect.gen(function* () { + const db = new Client({ user: "postgres" }); + yield* Effect.tryPromise({ + try: () => db.connect(), + catch: (cause) => + new DatabaseError("Failed to connect to database", { cause }), + }); + return db; + }), +}); +``` \ No newline at end of file From 25bef5f1f114fde5c5bf144f480c331229744710 Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Mon, 31 Mar 2025 16:59:28 -0400 Subject: [PATCH 25/28] tweak error css --- content/src/styles/twoslash.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/content/src/styles/twoslash.css b/content/src/styles/twoslash.css index 46da56ace..72dd9e8b5 100644 --- a/content/src/styles/twoslash.css +++ b/content/src/styles/twoslash.css @@ -1,3 +1,15 @@ +.expressive-code span.twoslash.twoerror { + display: inline; +} + +.expressive-code div.twoslash-error-box { + margin-left: 0; +} + +.expressive-code span.twoslash-error-box-content { + white-space: break-spaces; +} + .expressive-code .twoslash-popup-container { --ec-twoSlash-brdCol: var(--ec-gtrFg); } From d832170cbfdd50f74fcd75ec40914a84356cd1ff Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 4 Apr 2025 10:27:41 +0100 Subject: [PATCH 26/28] Update content/src/content/docs/learn/tutorials/calling-an-api.mdx Co-authored-by: Maxwell Brown --- content/src/content/docs/learn/tutorials/calling-an-api.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/content/src/content/docs/learn/tutorials/calling-an-api.mdx b/content/src/content/docs/learn/tutorials/calling-an-api.mdx index 1997f01cb..54ae4f27f 100644 --- a/content/src/content/docs/learn/tutorials/calling-an-api.mdx +++ b/content/src/content/docs/learn/tutorials/calling-an-api.mdx @@ -29,7 +29,9 @@ app.get("/query", async (req, res) => { return } - const url = `https://en.wikipedia.org/w/rest.php/v1/search/page?q=${query}&limit=1` + const baseUrl = "https://en.wikipedia.org/w/rest.php/v1/search/page` + const queryParams = `?q=${query}&limit=1` + const url = `${baseUrl}${queryParams}` const results = await fetch(url) res.json(await results.json()) }) From a30d34c137507995ccd2378fba24ae48feac2e6d Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Fri, 4 Apr 2025 10:34:40 +0100 Subject: [PATCH 27/28] Respond to Max's PR feedback. --- .../docs/learn/tutorials/calling-an-api.mdx | 8 ++++---- .../docs/learn/tutorials/services-and-config.mdx | 16 ++++++---------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/content/src/content/docs/learn/tutorials/calling-an-api.mdx b/content/src/content/docs/learn/tutorials/calling-an-api.mdx index 1997f01cb..93146af36 100644 --- a/content/src/content/docs/learn/tutorials/calling-an-api.mdx +++ b/content/src/content/docs/learn/tutorials/calling-an-api.mdx @@ -270,10 +270,10 @@ Effect ``` So each Effect can be thought of as a self-contained program. Its requirements -are its inputs, its success value is its output, and it can error. Effect forces -us to be more deliberate about our programs. Everything each Effect needs to run -is encoded into the type system, and this enables TypeScript to help us more -deeply than it would normally be able to. +are its inputs, and its output is either a success value or an error. Effect +forces us to be more deliberate about our programs. Everything each Effect needs +to run is encoded into the type system, and this enables TypeScript to help us +more deeply than it would normally be able to. -The first step is to introduce some boilerplate code that will allow us to -serve both Effect and Express endpoints at the same time. I'm not expecting you -to understand everything you read in the following code block, all will be -explained as we progress through this tutorial. +Next, some boilerplate. I'm not expecting you to understand everything you read +in the following code block, all will be explained as we progress through this +tutorial. ```ts twoslash ins={3-5,16-26} del={13-15} title=index.ts showLineNumbers=false import express from "express" @@ -67,10 +70,10 @@ Express app in a way that will allow us to define endpoints using Effect while still responding to our existing endpoints in Express. There's a lot of new things happening at the bottom of our file. What is -`Effect.gen`, what are those `yield*`s doing, what is `Effect.never`. For the -time being we're going to set these questions aside. It looks intimidating now, -but if you stick with this tutorial to the end, it will not only make sense, but -start to become second nature. +`Effect.gen`, what are those `yield*`s doing, what is `Effect.never`. I hear +you. For the time being we're going to set these questions aside. It looks +intimidating now, but at the end of this tutorial you will understand exactly +what's going on. For now, let's focus on the `/health` endpoint. ## Our new `/health` handler @@ -110,7 +113,7 @@ What on earth are `Effect.sync` and `.pipe`? ## The Effect Type -At the core of Effect is... well, the `Effect`. You can think of an `Effect` as +At the core of Effect is the `Effect` type. You can think of an `Effect` as a lazy computation, similar to a function that hasn't been called yet. Here's an example of an `Effect` that returns the string `"Hello, world!"`: @@ -123,7 +126,7 @@ Effect.sync(() => "Hello, world!") `Effect.sync` takes a function and creates an `Effect` out of it. If you need to @@ -155,7 +158,7 @@ console.log(asyncResult) `Effect.runPromise` runs an Effect and returns a `Promise` that resolves to the result of the Effect. You can pass both synchronous and asynchronous Effects to -`Effect.runPromise`, and it will handle them both. +`Effect.runPromise`, it will handle them both. What if you want to run an Effect that uses the result of another Effect? You can use `Effect.gen` to do that: @@ -178,9 +181,9 @@ Effect as being like a `Promise`, and `yield*`ing an `Effect` is the same as `await`ing a `Promise`. Under the hood, all `Effect`s are generators, and `yield*`ing one passes it to the Effect runtime and waits for the result. -`Effect.gen` is one of the most versatile ways to create Effects, and you'll use -it a lot. Another thing you'll see used a lot is `Effect.pipe`. Here's an -example that does the same work as code as above, but using `Effect.pipe`: +`Effect.gen` is one of the most versatile ways to create Effects, you'll see it +used a lot. Another thing you'll see a lot is `Effect.pipe`. Here's an example +that does the same work as code as above, but using `Effect.pipe`: ```ts twoslash showLineNumbers=false import { Effect } from "effect" @@ -285,9 +288,23 @@ HttpRouter.get("/health", health)( ), ), ) +``` -// vs +Versus: +```ts twoslash showLineNumbers=false +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +const health = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) +const status = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) +const version = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) +// ---cut--- HttpRouter.empty.pipe( HttpRouter.get("/version", version), HttpRouter.get("/status", status), @@ -295,6 +312,9 @@ HttpRouter.empty.pipe( ) ``` +The second version is much easier to read, and will be easier to maintain as +you add more routes. + ## The end result Here's the full code of our app with our `/health` endpoint being served by diff --git a/content/src/content/docs/learn/tutorials/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index df7e09a48..9c44e1b8d 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -14,26 +14,33 @@ This tutorial assumes you are: - New to Effect - Not new to TypeScript / web development -- Want to use Effect in a new or existing project +- Are interested in using Effect in a new or existing project +- (Optional) Have attempted to read the docs before and gotten quickly confused If you're new to TypeScript you may still be able to follow along, but we aren't going to be explaining concepts like generics or async/await. You'll get more value from this tutorial, and from Effect, if you're already familiar with the -TypeScript language. +TypeScript language. A good place to start is the [TypeScript +Handbook](https://www.typescriptlang.org/docs/handbook/intro.html). ## How we will teach you -Effect is crafted from decades of experience and frustration with building web +Effect is crafted from decades of experience building production web applications. It uses advanced features of the TypeScript language and builds on ideas from functional programming. As a result, **Effect has a steep learning -curve**. But the promise we make you is that **if you invest the time required -to learn Effect, it will pay for itself many times over.** +curve**. But the promise we make to you is that **if you invest the time to +learn Effect, it will pay for itself many times over.** We're going to teach you Effect by example, starting from familiar ground and introducing new ideas one at a time. This tutorial isn't going to jump straight to best practices. To get there, we have to build up a foundation of knowledge about Effect. +By the end of this tutorial you will understand the core concepts of Effect, +enough to be able to dive into the documentation and understand what you're +looking at. You will also have seen how to gradually introduce Effect into an +existing codebase, in a way that allows you to commit incrementally and safely. + ## What we're going to build We're going to build a backend web application. It will have configuration, @@ -52,10 +59,14 @@ import express from "express" const app = express() +// Define an HTTP handler on `GET /health` that returns a 200 OK response +// with the text "ok" app.get("/health", (req, res) => { res.type("text/plain").send("ok") }) +// Start listening on port 3000. Log a message to the console when the server is +// ready. This function call doesn't return unless the process is killed. app.listen(3000, () => { console.log("Server is running on http://localhost:3000") }) @@ -65,9 +76,9 @@ app.listen(3000, () => { To get the most value out of this tutorial, follow along as you read. Copy or type out the code examples, run them, try changing things to see what happens. -It doesn't matter if you break things, you can always come back and get the -working code. If all you do is read these words, you'll miss out on the most -important part of learning: doing. +It doesn't matter if you break something, you can always come back here and get +the working code. If all you do is read the words of this tutorial, you'll miss +out on the most important part of learning: doing. Create a new TypeScript project using your favourite package manager. I'm using `bun`: diff --git a/content/src/content/docs/learn/tutorials/scratch.mdx b/content/src/content/docs/learn/tutorials/scratch.mdx deleted file mode 100644 index 86cfbaddd..000000000 --- a/content/src/content/docs/learn/tutorials/scratch.mdx +++ /dev/null @@ -1,154 +0,0 @@ ---- -type: Tutorial -title: Scratch -tags: - - some - - tags -sidebar: - order: 999 ---- - -## Adding error handling to our database query - -All this talk of error handling might have gotten you thinking: can't our -database query fail? It can! If the query is malformed, `db.query` will throw an -error. When that happens, our endpoint will currently return a 500 error to -the client. Change the SQL query to `SELECT * FROM nonexistent` and try it: - -```shell -http get "localhost:3000/users" -``` - -```http -HTTP/1.1 500 Internal Server Error -Content-Length: 0 -Date: Fri, 14 Feb 2025 17:25:57 GMT -X-Powered-By: Express -``` - -Strictly speaking, a 500 error is correct here. Our server encountered an error -because of a server-side problem, which is what the 500-class errors are for. -But it's not good Effect to let exceptions sneak by like this. - -A simple thing we can do is changing `Effect.promise` to `Effect.tryPromise`: - -```ts twoslash -import { HttpServerResponse } from "@effect/platform" -import { Effect, Layer } from "effect" -import { createServer } from "node:http" -import { Client } from "pg" -const db = new Client({ user: "postgres" }) -//---cut--- -const users = Effect.tryPromise( - () => db.query("SELECT * FROM users") -).pipe( - Effect.map(({ rows }) => ({ users: rows })), - Effect.flatMap(HttpServerResponse.json) -) -``` - -This changes the return type from: - -```typescript -Effect -``` - -To: - -```typescript -Effect -``` - -`Effect.promise` is assumed to always succeed. `Effect.tryPromise` is assumed -to maybe throw an exception, but because TypeScript doesn't give us any way -to know _what_ exception might be thrown, Effect defaults to `UnknownException`. - -We can improve on this: - -```ts twoslash {1,4-5} -import { HttpServerResponse } from "@effect/platform" -import { Effect, Layer } from "effect" -import { createServer } from "node:http" -import { Client} from "pg" -const db = new Client({ user: "postgres" }) -//---cut--- -class UserFetchError extends Error {} - -const users = Effect.tryPromise({ - try: () => db.query("SELECT * FROM users"), - catch: (cause) => new UserFetchError("failed to fetch users", { cause }), -}).pipe( - Effect.map(({ rows }) => ({ users: rows })), - Effect.flatMap(HttpServerResponse.json) -) -``` - -We're using a different variant of `Effect.tryPromise` to give us more control -over the type of the error that is returned. Becuase it's difficult to know what -errors may be thrown by third-party libraries, it's idiomatic to create new -error types that wrap the original error. - -Now our effect has the type: - -```typescript -Effect -``` - -Now our program is aware of what can go wrong _at the type level_. This -information is completely missing if you're using exceptions in TypeScript. -With this information we can ensure, and get TypeScript to verify for us, -that things do not go wrong in unexpected ways. - -```ts twoslash -// @errors: 2375 -import { Effect } from "effect" - -const effect = Effect.succeed(1).pipe( - Effect.tryMap({ - try: (n) => n + 1, - catch: () => new Error(), - }), -) - -const noError: Effect.Effect = effect -``` - -The above code fails because we try to assign an Effect that can fail to a -variable that expects an Effect that can't fail. We can fix this error in a -few ways. - -By providing a default value: - -```ts twoslash {8} -import { Effect } from "effect" - -const effect = Effect.succeed(1).pipe( - Effect.tryMap({ - try: (n) => n + 1, - catch: () => new Error(), - }), - Effect.orElseSucceed(() => 0), -) - -const noError: Effect.Effect = effect -``` - -Or by converting the error into what Effect calls a "defect": - -```ts twoslash {8} -import { Effect } from "effect" - -const effect = Effect.succeed(1).pipe( - Effect.tryMap({ - try: (n) => n + 1, - catch: () => new Error(), - }), - Effect.orDie -) - -const noError: Effect.Effect = effect -``` - -This effectively recreates the behaviour of throwing an exception. Defects are -critical errors that you don't want to handle. If you were to create a defect in -an Effect handler, the result would be a 500 error returned to the user. diff --git a/content/src/content/docs/learn/tutorials/services-and-config.mdx b/content/src/content/docs/learn/tutorials/services-and-config.mdx index cbf14dad1..951a865ec 100644 --- a/content/src/content/docs/learn/tutorials/services-and-config.mdx +++ b/content/src/content/docs/learn/tutorials/services-and-config.mdx @@ -200,14 +200,14 @@ const users = Effect.gen(function* () { }); ``` -How cool is that? Take a close look at the type of our `users` handler now: +Take a close look at the type of our `users` handler now: ```typescript Effect ``` We can tell at a glance that this handler can fail with either a `DatabaseError` -or a `HttpBodyError`, and it needs a `Database` in order to do its work. +or an `HttpBodyError`, and it needs a `Database` in order to do its work. If you've been following along, you'll also have noticed now that there's a new error at the bottom of our code: @@ -335,19 +335,24 @@ const main = Effect.gen(function* () { NodeRuntime.runMain(main); ``` +This resolves the error and "injects" the `Database` dependency into our +program. This creates a separation between our program and the database it uses, +such that it would be easy to swap out a new database implementation without +having to modify any of our application code. So if you wanted to use a +different database in development or testing, it would be easy to do so. + ## Services that depend on other services Our `Database` service has added modularity to our code, but there's still that -pesky `Client` instance hanging around. Technically, our `Database` service +pesky `Client` instance in the global scope. Technically, our `Database` service depends on that `Client` instance being available and connected. We should represent this in the type system. ### Layers The way that Effect handles services that depend on other services is through -the `Layer` system. Layers exist simply to encode the dependencies between -services, and we can start by creating a "base" layer that providers our -`pg` `Client` instance. +the `Layer` system. Layers exist to encode dependencies between services, and we +can start by creating a "base" layer that provides our `pg` `Client` instance. ```ts twoslash showLineNumbers=false import { Context, Effect, Layer } from "effect"; @@ -374,4 +379,546 @@ const PostgresClientLayer = Layer.succeed(PostgresClient, { return db; }), }); -``` \ No newline at end of file +``` + +The type of `PostgresClientLayer` is `Layer`. +The generic parameters of a `Layer` are: + +1. `Out`: the types that this layer provides +2. `Error`: the type of error that this layer can fail with +3. `Requirements`: the requirements needed to build this layer + +So we've created a layer that can output a `PostgresClient`, doesn't fail, +and doesn't need anything. + +We can make use of this new `PostgresClient` tag in our `Database` service. +We're going to redefine our `Database` service as a `Layer` using +`Layer.effect`. This will allow us to encode our dependencies on the `Layer`, +rather than on the Effects returned from our service methods. + +```ts twoslash showLineNumbers=false +import { Context, Effect, Layer } from "effect"; +import { Client } from "pg"; +interface User { + id: string; + name: string; +} +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} +class Database extends Context.Tag("Database")< + Database, + { + getUsers: () => Effect.Effect; + } +>() {} +class PostgresClient extends Context.Tag("PostgresClient")< + PostgresClient, + { connect: () => Effect.Effect } +>() {} +//---cut--- +const DatabaseLayer = Layer.effect( + Database, + Effect.gen(function* () { + const client = yield* PostgresClient; + const db = yield* client.connect(); + return { + getUsers: () => + Effect.gen(function* () { + const { rows } = yield* Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (cause) => + new DatabaseError("Failed to fetch users", { cause }), + }); + return rows as User[]; + }), + }; + }) +); +``` + +And now `DatabaseLayer` is a `Layer`. +So it: + +1. Provides a `Database`. +2. Can fail with a `DatabaseError`. +3. Requires a `PostgresClient`. + +Lastly we can provide `DatabaseLayer` to our `main` Effect: + +```ts twoslash ins={7} showLineNumbers=false +import express from "express"; + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Context, Effect, Layer } from "effect"; +import { Client } from "pg"; + +const db = new Client({ user: "postgres" }); +const app = express(); + +interface User { + id: string; + name: string; +} + +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} + +class PostgresClient extends Context.Tag("PostgresClient")< + PostgresClient, + { connect: () => Effect.Effect } +>() {} + +const PostgresClientLayer = Layer.succeed(PostgresClient, { + connect: () => + Effect.gen(function* () { + const db = new Client({ user: "postgres" }); + yield* Effect.tryPromise({ + try: () => db.connect(), + catch: (cause) => + new DatabaseError("Failed to connect to database", { cause }), + }); + return db; + }), +}); + + +class Database extends Context.Tag("Database")< + Database, + { + getUsers: () => Effect.Effect; + } +>() {} + +const DatabaseLayer = Layer.effect( + Database, + Effect.gen(function* () { + const client = yield* PostgresClient; + const db = yield* client.connect(); + return { + getUsers: () => + Effect.gen(function* () { + const { rows } = yield* Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (cause) => + new DatabaseError("Failed to fetch users", { cause }), + }); + return rows as User[]; + }), + }; + }) +); + +const users = Effect.gen(function* () { + const db = yield* Database; + const users = yield* db.getUsers(); + return yield* HttpServerResponse.json(users); +}); + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/users", users) +); +//---cut--- +const main = Effect.gen(function* () { + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000, () => { + console.log("Server is running on http://localhost:3000"); + }); + yield* Effect.never; +}).pipe(Effect.provide(DatabaseLayer.pipe(Layer.provide(PostgresClientLayer)))); +``` + +Because you usually end up providing a lot of layers to your program, it's +common to create a `MainLayer` like so: + +```ts twoslash ins={1-4,12} showLineNumbers=false +import express from "express"; +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Context, Effect, Layer } from "effect"; +import { Client } from "pg"; + +const db = new Client({ user: "postgres" }); + +interface User { + id: string; + name: string; +} + +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} + +class PostgresClient extends Context.Tag("PostgresClient")< + PostgresClient, + { connect: () => Effect.Effect } +>() {} + +const PostgresClientLayer = Layer.succeed(PostgresClient, { + connect: () => + Effect.gen(function* () { + const db = new Client({ user: "postgres" }); + yield* Effect.tryPromise({ + try: () => db.connect(), + catch: (cause) => + new DatabaseError("Failed to connect to database", { cause }), + }); + return db; + }), +}); + + +class Database extends Context.Tag("Database")< + Database, + { + getUsers: () => Effect.Effect; + } +>() {} + +const DatabaseLayer = Layer.effect( + Database, + Effect.gen(function* () { + const client = yield* PostgresClient; + const db = yield* client.connect(); + return { + getUsers: () => + Effect.gen(function* () { + const { rows } = yield* Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (cause) => + new DatabaseError("Failed to fetch users", { cause }), + }); + return rows as User[]; + }), + }; + }) +); +const app = express(); +const users = Effect.gen(function* () { + const db = yield* Database; + const users = yield* db.getUsers(); + return yield* HttpServerResponse.json(users); +}); +const router = HttpRouter.empty.pipe( + HttpRouter.get("/users", users) +); +//---cut--- +const MainLayer = Layer.provide( + DatabaseLayer, + PostgresClientLayer +); + +const main = Effect.gen(function* () { + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000, () => { + console.log("Server is running on http://localhost:3000"); + }); + yield* Effect.never; +}).pipe(Effect.provide(MainLayer)); +``` + +## Database config + +The last dependency we should separate out is the database configuration itself. +When we create our `Client`, we pass in `{ user: "postgres" }`. In practice, +this would also include a password and a hostname and potentially other things. +A common way to configure these values is to use environment variables, and +Effect comes with built-in support for this. + +```ts twoslash showLineNumbers=false +import { Effect, Config } from "effect"; + +Effect.runPromise(Effect.gen(function* () { + const user = yield* Config.string("DB_USER"); + console.log(`User: ${user}`); +})); +``` + +This type checks correct, but when you run it you'll notice you get an error. +Save the above code as `config.ts` and run it with: + +```bash twoslash showLineNumbers=false +bun config.ts +``` + +```text twoslash wrap showLineNumbers=false +(FiberFailure) Error: (Missing data at DB_USER: "Expected DB_USER to exist in the process context") +``` + +By default, Effect provides a service that gets config values from environment +variables. + +```bash twoslash showLineNumbers=false +DB_USER=postgres bun config.ts +``` + +```text twoslash wrap showLineNumbers=false +User: postgres +``` + +If we want to, we can give config values a default value. + +```ts twoslash ins={2-4} showLineNumbers=false +import { Effect, Config } from "effect"; +//---cut--- +Effect.runPromise(Effect.gen(function* () { + const user = yield* Config.string("DB_USER").pipe( + Config.withDefault("postgres") + ); + console.log(`User: ${user}`); +})); +``` + +You can read more about the `Config` module in the [Config documentation][1], +but for now, let's use it to configure our database connection. + +```ts twoslash {4-6,10} showLineNumbers=false +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { + HttpMiddleware, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Config, Context, Effect, Layer } from "effect"; +import { Client } from "pg"; + +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} + +class PostgresClient extends Context.Tag("PostgresClient")< + PostgresClient, + { connect: () => Effect.Effect } +>() {} +//---cut--- +const PostgresClientLayer = Layer.effect( + PostgresClient, + Effect.gen(function* () { + const user = yield* Config.string("DB_USER").pipe( + Config.withDefault("postgres") + ); + return { + connect: () => + Effect.gen(function* () { + const db = new Client({ user }); + yield* Effect.tryPromise({ + try: () => db.connect(), + catch: (cause) => + new DatabaseError("Failed to connect to database", { cause }), + }); + return db; + }), + }; + }) +); +``` + +Nothing changes in the ability to run our program, but now we can change the +`user` parameter with an environment variable if we should choose to. You could +extend this to include a password, host, port, and other parameters as you need. + +## The end result + +Here's all of our new code, all in one block: + +```ts twoslash showLineNumbers=false +import express from "express"; + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"; +import { + HttpMiddleware, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform"; +import { Config, Context, Effect, Layer } from "effect"; +import { Client } from "pg"; + +const app = express(); + +// --- Types + +interface User { + id: string; + name: string; +} + +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} + +// --- Tags + +class Database extends Context.Tag("Database")< + Database, + { + getUsers: () => Effect.Effect; + } +>() {} + +class PostgresClient extends Context.Tag("PostgresClient")< + PostgresClient, + { connect: () => Effect.Effect } +>() {} + +// --- Layers + +const PostgresClientLayer = Layer.effect( + PostgresClient, + Effect.gen(function* () { + const user = yield* Config.string("DB_USER").pipe( + Config.withDefault("postgres") + ); + return { + connect: () => + Effect.gen(function* () { + const db = new Client({ user }); + yield* Effect.tryPromise({ + try: () => db.connect(), + catch: (cause) => + new DatabaseError("Failed to connect to database", { cause }), + }); + return db; + }), + }; + }) +); + +const DatabaseService = Layer.effect( + Database, + Effect.gen(function* () { + const client = yield* PostgresClient; + const db = yield* client.connect(); + return { + getUsers: () => + Effect.gen(function* () { + const { rows } = yield* Effect.tryPromise({ + try: () => db.query("SELECT * FROM users"), + catch: (cause) => + new DatabaseError("Failed to fetch users", { cause }), + }); + return rows as User[]; + }), + }; + }) +); + +const MainLayer = Layer.provide( + DatabaseService, + PostgresClientLayer, +) + +// --- Routes + +const users = Effect.gen(function* () { + const db = yield* Database; + const users = yield* db.getUsers(); + return yield* HttpServerResponse.json(users); +}); + +const query = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest; + const url = new URL(req.url, `http://${req.headers.host}`); + const q = url.searchParams.get("q"); + + if (!q) { + return yield* HttpServerResponse.json( + { error: "Query parameter 'q' is required" }, + { status: 400 } + ); + } + + const response = yield* Effect.promise(async () => { + const api = `https://en.wikipedia.org/w/rest.php/v1/search/page?q=${q}&limit=1`; + return fetch(api).then((resp) => resp.json()); + }); + + return yield* HttpServerResponse.json(response); +}); + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/search", query), + HttpRouter.get("/users", users), + HttpMiddleware.logger +); + +// --- Main + +const main = Effect.gen(function* () { + app.use(yield* NodeHttpServer.makeHandler(router)); + app.listen(3000, () => { + console.log("Server is running on http://localhost:3000"); + }); + yield* Effect.never; +}).pipe(Effect.provide(MainLayer)); + +NodeRuntime.runMain(main); +``` + +We started to get a feel for Effectful code by creating services and building up +layers, but in practice you wouldn't keep all of these things in a single file. +We've done that for the sake of keeping the tutorial simple. + +There are plenty of ways you _could_ split this code up, and Effect doesn't +force you down any one path. If you have an existing project you may want to +keep a similar structure to what you already have. Or if you're finding yourself +creating clearer boundaries between services, routes, layers, and so on, you +may want to represent that in your file structure. The choice is yours. + +## Conclusion + +We've come a long way since we first took a look at a simple Express app we +were going to migrate to Effect. Let's recap what we know now that we didn't +know before: + +1. We know how to create and run Effects using `Effect.gen` and + `Effect.runPromise`. +2. We know how wrap async/await code as Effects using `Effect.tryPromise`, + including bringing their error states into the type system. +3. We know how to create services and layers using `Context.Tag` and + `Layer.effect`, to encode dependencies in the type system and make them easy + to swap out. +4. We know how to use the `Config` module to read environment variables and + provide default values. +5. We know how we could gradually adopt Effect into an existing project, one + endpoint at a time, without having to rewrite everything at once. + +It's a lot, but it is only the tip of the iceberg. Effect provides solutions +out of the box for: + +- [Retrying][2] +- [Caching][3] +- [Interruptions][4] +- [Cron jobs][5] +- [Streaming][6] +- [Logging][7] +- [Tracing][8] +- [Schema validation][9] +- More! + +Effect is a complete toolbox for building robust production applications in +TypeScript. With what you have learned in this tutorial, you should be much +better prepared to dive into the documentation and learning all of the +tools available to you! + + +[1]: https://effect.website/docs/configuration/ +[2]: https://effect.website/docs/error-management/retrying/ +[3]: https://effect.website/docs/caching/caching-effects/ +[4]: https://effect.website/docs/concurrency/basic-concurrency/#interruptions +[5]: https://effect.website/docs/scheduling/cron/ +[6]: https://effect.website/docs/stream/introduction/ +[7]: https://effect.website/docs/observability/logging/ +[8]: https://effect.website/docs/observability/tracing/ +[9]: https://effect.website/docs/schema/introduction/ \ No newline at end of file