diff --git a/content/package.json b/content/package.json index 8527fa441..a92792c7a 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", @@ -101,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 eee07fae7..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) @@ -134,6 +137,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 +2284,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==} @@ -2329,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==} @@ -2365,6 +2377,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 +2667,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} @@ -4092,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==} @@ -4183,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==} @@ -4295,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'} @@ -7072,6 +7127,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 @@ -7130,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': {} @@ -7168,6 +7233,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 +7622,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 @@ -9472,6 +9546,8 @@ snapshots: object-hash@3.0.0: {} + obuf@1.1.2: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -9582,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: {} @@ -9680,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/calling-an-api.mdx b/content/src/content/docs/learn/tutorials/calling-an-api.mdx new file mode 100644 index 000000000..5c29b3671 --- /dev/null +++ b/content/src/content/docs/learn/tutorials/calling-an-api.mdx @@ -0,0 +1,454 @@ +--- +type: Tutorial +title: Calling an API +tags: + - some + - tags +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: + +```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" + +const app = express() + +app.get("/query", async (req, res) => { + const query = req.query.q + if (typeof query !== "string") { + res.status(400).send("Query parameter 'q' is required") + return + } + + 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()) +}) + +const router = HttpRouter.empty + +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) +``` + +I've removed our `/health` and `/users` endpoints to keep the code to a +reasonable length. + +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 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 +import { Effect } from "effect" +import { HttpServerResponse } from "@effect/platform" +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)) +) +``` + +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 showLineNumbers=false +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 add this handler as a new `/query` endpoint in our application: + +```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 }) +}) +//---cut--- +const router = HttpRouter.empty.pipe( + HttpRouter.get("/query", query), +) +``` + +We can call it like so: + +```shell showLineNumbers=false +http -pb get "localhost:3000/query?q=typescript" +``` + +```json showLineNumbers=false +{ + "url": "/query?q=typescript" +} +``` + +## Effect requirements + +Let's look a bit closer at our `query` Effect: + +```ts twoslash showLineNumbers=false +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform" +import { Effect } from "effect" +//---cut--- +const query = Effect.gen(function* () { + // query: Effect + const req = yield* HttpServerRequest.HttpServerRequest + return yield* HttpServerResponse.json({ url: req.url }) +}) +``` + +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`. + +If we were to try and run this Effect directly, we would get a type error: + +```ts twoslash showLineNumbers=false +// @errors: 2379 +import { + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "@effect/platform" +import { Effect } from "effect" +const query = Effect.gen(function* () { + const req = yield* HttpServerRequest.HttpServerRequest + return yield* HttpServerResponse.json({ url: req.url }) +}) +//---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 + +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. + +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" +//---cut--- +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`. + +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}`) +}) +``` + +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" +class FavouriteNumber extends Context.Tag("FavouriteNumber")< + FavouriteNumber, + number +>() {} + +const printNum = Effect.gen(function* () { + const n = yield* FavouriteNumber + console.log(`My favourite number is ${n}`) +}) +//---cut--- +Effect.runPromise( + printNum.pipe(Effect.provideService(FavouriteNumber, 42)) +) +// => My favourite number is 42 + +Effect.runPromise( + printNum.pipe(Effect.provideService(FavouriteNumber, 100)) +) +// => My favourite number is 100 +``` + +Providing a service to an Effect ends up changing its type, too. + +```ts twoslash showLineNumbers=false +import { Context, Effect } from "effect" +class FavouriteNumber extends Context.Tag("FavouriteNumber")< + FavouriteNumber, + number +>() {} +//---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) +) +``` + +We go from: + +``` showLineNumbers=false +Effect +``` + +To: + +``` showLineNumbers=false +Effect +``` + +So each Effect can be thought of as a self-contained program. Its requirements +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. + + + +## 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 showLineNumbers=false +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 `q` query parameter. We can +do this with the `URL` class. + +```ts twoslash ins={3-4} showLineNumbers=false +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 q = url.searchParams.get("q") + return yield* HttpServerResponse.json({ q }) +}) +``` + +And now we can see the year in the response: + +```shell showLineNumbers=false +http -pb get "localhost:3000/query?q=typescript" +``` + +```http showLineNumbers=false +{ + "q": "typescript" +} +``` + +## Calling the API + +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 +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 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); +}); +``` + +And querying this endpoint we now get the filtered results: + +```shell showLineNumbers=false +http -pb get "localhost:3000/query?q=typescript" +``` + +```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" + } + ] +} +``` + +## 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/first-endpoint.mdx b/content/src/content/docs/learn/tutorials/first-endpoint.mdx new file mode 100644 index 000000000..40e108ec3 --- /dev/null +++ b/content/src/content/docs/learn/tutorials/first-endpoint.mdx @@ -0,0 +1,361 @@ +--- +type: Tutorial +title: Our first Effect endpoint +tags: + - some + - tags +sidebar: + order: 2 +--- + +import { Aside } from "@astrojs/starlight/components" + +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. If you're looking to +adopt Effect into an existing codebase, this is a great way to do it. You can +start by wrapping your existing Express app in Effect, and then gradually +migrating your endpoints to Effect one at a time. + +First, we need to add some dependencies to our project: + +```shell showLineNumbers=false +bun add effect @effect/platform @effect/platform-node +``` + + + +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" + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { HttpRouter } from "@effect/platform" +import { Effect } from "effect" + +const app = express() + +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, () => { + console.log("Server is running on http://localhost:3000") + }) + yield* Effect.never +}) + +NodeRuntime.runMain(main) +``` + +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`. 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 + +The Effect equivalent of our Express `/health` handler looks like this: + +```ts twoslash showLineNumbers=false +import { Effect } from "effect" +// ---cut--- +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 +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 showLineNumbers=false +import { Effect } from "effect" +// ---cut--- +import { HttpServerResponse, HttpRouter } from "@effect/platform" + +const health = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", health), +) +``` + +What on earth are `Effect.sync` and `.pipe`? + +## The Effect Type + +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!"`: + +```ts twoslash showLineNumbers=false +import { Effect } from "effect" +// ---cut--- +Effect.sync(() => "Hello, world!") +``` + + + +`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 showLineNumbers=false +import { Effect } from "effect" +// ---cut--- +Effect.promise(async () => "Hello, world!") +``` + +If we want to run these Effects, we can use `Effect.runPromise`: + +```ts twoslash showLineNumbers=false +import { Effect } from "effect" +// ---cut--- +const syncResult = Effect.runPromise( + Effect.sync(() => "Hello, world!") +) +console.log(syncResult) +// => "Hello, world!" + +const asyncResult = await Effect.runPromise( + Effect.promise(async () => "Hello, world!") +) +console.log(asyncResult) +// => "Hello, world!" +``` + +`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`, 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" +// ---cut--- +const effect = Effect.sync(() => "Hello, world!") +const gen = Effect.gen(function* () { + const str = yield* effect + 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 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, 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" +// ---cut--- +const effect = Effect.sync(() => "Hello, world!").pipe( + Effect.map((s) => s.toUpperCase()) +) +const result = await Effect.runPromise(effect) +console.log(result) +// => "HELLO, WORLD!" +``` + +`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 ins={3-5} showLineNumbers=false +import { Effect } from "effect" +// ---cut--- +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(effect) +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 showLineNumbers=false +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +const health = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) +// ---cut--- +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", 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 showLineNumbers=false +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 +called it like a regular function. The following two lines of code do exactly +the same thing: + +```ts twoslash showLineNumbers=false +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" +const health = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) +// ---cut--- +HttpRouter.empty.pipe(HttpRouter.get("/health", health)) +HttpRouter.get("/health", health)(HttpRouter.empty) +``` + +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" +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.get("/health", health)( + HttpRouter.get("/status", status)( + HttpRouter.get("/version", version)( + HttpRouter.empty, + ), + ), +) +``` + +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), + HttpRouter.get("/health", health), +) +``` + +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 +Effect: + +```ts twoslash {9-15} showLineNumbers=false +import express from "express" + +import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" +import { HttpRouter, HttpServerResponse } from "@effect/platform" +import { Effect } from "effect" + +const app = express() + +const health = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", health) +) + +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) +``` + +And the output of the endpoint: + +```shell showLineNumbers=false +http -pb get localhost:3000/health +``` + +```http showLineNumbers=false +ok +``` + +[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 new file mode 100644 index 000000000..43aa39dd1 --- /dev/null +++ b/content/src/content/docs/learn/tutorials/hooking-up-a-database.mdx @@ -0,0 +1,410 @@ +--- +type: Tutorial +title: Hooking up a database +tags: + - some + - tags +sidebar: + order: 3 +--- + +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/). + +When you're ready, you can start a Postgres container with: + +```shell showLineNumbers=false +docker run -d -e POSTGRES_HOST_AUTH_METHOD=trust -p 5432:5432 postgres:17 +``` + + + +## Creating test data + +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 showLineNumbers=false +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'); +``` + +To exit `psql`, type `\q` and press enter. + +## The `/users` endpoint + +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 +bun add -D @types/pg +``` + +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 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" + +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 health = Effect.sync(() => { + return HttpServerResponse.text("ok") +}) + +const router = HttpRouter.empty.pipe( + HttpRouter.get("/health", health) +) + +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 +}) + +NodeRuntime.runMain(main) +``` + +Here's what you should see when you run this code and you've set up your +Postgres database correctly: + +```shell showLineNumbers=false +http -pb get localhost:3000/users +``` + +```json showLineNumbers=false +{ + "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 +broken code. Here's how it would look: + +```ts twoslash showLineNumbers=false +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" }) +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 router = HttpRouter.empty.pipe( + HttpRouter.get("/health", health), + HttpRouter.get("/users", users), +) +``` + +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. +``` + +Because it can fail, instead of returning an `HttpServerResponse` directly, +it returns another `Effect`. + +## 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. + +`Effect` is a generic type that takes 3 type arguments: + +```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` in the next +section. + +When we created our example Effects in the previous section, these all took on +the type `Effect`. + +```ts twoslash showLineNumbers=false +import { Effect } from "effect" +//---cut--- +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. + + + +## Fixing our nested Effects with `flatMap` + +We can solve all of our problems 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.promise(async () => { + const { rows } = await db.query("SELECT * FROM users") + return { users: rows } +}).pipe( + Effect.flatMap((body) => HttpServerResponse.json(body)) +) +``` + +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 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. + +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" + +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 ins={6-9,14,16,18} showLineNumbers=false +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. + +## The end result + +Here's our full code so far, with new lines highlighted: + +```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" + +const db = new Client({ user: "postgres" }) + +const app = express() + +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(() => { + 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)) + 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/introduction.mdx b/content/src/content/docs/learn/tutorials/introduction.mdx index 888b1e2c5..9c44e1b8d 100644 --- a/content/src/content/docs/learn/tutorials/introduction.mdx +++ b/content/src/content/docs/learn/tutorials/introduction.mdx @@ -4,258 +4,111 @@ 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 -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 +- Are interested in using Effect in a new or existing project +- (Optional) Have attempted to read the docs before and gotten quickly confused -```ts twoslash -import express from "express" +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. A good place to start is the [TypeScript +Handbook](https://www.typescriptlang.org/docs/handbook/intro.html). -const app = express() +## How we will teach you -app.get("/health", (req, res) => { - res.type("text/plain").send("ok") -}) +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 to you is that **if you invest the time to +learn Effect, it will pay for itself many times over.** -app.listen(3000, () => { - console.log("Server is running on http://localhost:3000") -}) -``` +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. -Create a new TypeScript project using your favourite package manager. I'm using -`bun`: +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. -```shell -mkdir express-to-effect -cd express-to-effect -bun init -``` +## What we're going to build -Save our Express.js app into `index.ts` and run it to make sure it works: +We're going to build a backend web application. It will have configuration, +connect to a database, and call out to other APIs. -```shell -bun add express -bun run index.ts -``` +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. -You should see `Server is running on http://localhost:3000` in your terminal, -and visiting `http://localhost:3000/health` should return `ok`. +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. -## 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. - -```typescript +```ts twoslash title=index.ts showLineNumbers=false import express from "express" -// New imports -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() +// 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") }) -// New server runner -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 Express 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. - -```typescript -const router = HttpRouter.empty -``` - -And let's define the function we want to run when we hit the `/health` endpoint: - -```typescript -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. - -```typescript -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`. 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. - -Here are some examples of simple `Effect`s: - -```typescript -const sync = Effect.sync(() => "Hello, world!") -const promise = Effect.promise(async () => "Hello, world!") -const succeed = Effect.succeed("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. - -```typescript -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!" -``` - -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: - -```typescript -const gen = Effect.gen(function* () { - const result = yield* promise - return result.toUpperCase() +// 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") }) - -const resultGen = await Effect.runPromise(gen) -console.log(resultGen) -// "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`: - -```typescript -const upper = promise.pipe(Effect.map((s) => s.toUpperCase())) -const result = await Effect.runPromise(upper) -console.log(result) -// "HELLO, WORLD!" -``` +## Getting set up -`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. +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 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. -We can `pipe` as many `Effect`s together as we like: +Create a new TypeScript project using your favourite package manager. I'm using +`bun`: -```typescript -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" +```shell showLineNumbers=false +mkdir express-tutorial +cd express-tutorial +bun init ``` -## Understanding `HttpRouter` +Save our Expresss app into `index.ts` and run it to make sure it works: -Looking back at `HttpRouter`, we used `pipe` to add a new endpoint to our app: - -```typescript -const router = HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)), -); +```shell showLineNumbers=false +bun add express +bun add -D @types/express +bun index.ts ``` -`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`: +You should see `Server is running on http://localhost:3000` in your terminal, +and visiting `http://localhost:3000/health` should return `ok`. -```typescript -console.log(Effect.isEffect(HttpRouter.empty)) -// true +```shell showLineNumbers=false +$ http -pb get localhost:3000/health ``` -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 -const router = HttpRouter.get("/health", Effect.sync(health))(HttpRouter.empty) +```http showLineNumbers=false +ok ``` -This is exactly the same as the `pipe` version, except that if we wanted to add -multiple routes it gets unwieldy quickly: - -```typescript -HttpRouter.get("/health", Effect.sync(health))( - HttpRouter.get("/status", Effect.sync(status))( - HttpRouter.get("/version", Effect.sync(version))( - HttpRouter.empty, - ), - ), -) +I'm using [HTTPie](https://httpie.io/cli) throughout this tutorial, because the +output is nicer than `curl`. -// vs - -HttpRouter.empty.pipe( - HttpRouter.get("/health", Effect.sync(health)), - HttpRouter.get("/status", Effect.sync(status)), - HttpRouter.get("/version", Effect.sync(version)), -) -``` +[1]: https://expressjs.com/ \ No newline at end of file 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..951a865ec --- /dev/null +++ b/content/src/content/docs/learn/tutorials/services-and-config.mdx @@ -0,0 +1,924 @@ +--- +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 DatabaseService = Database.of({ + getUsers: () => Effect.promise(async () => { + const { rows } = await db.query("SELECT * FROM users") + return rows + }), +}) +``` + +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 DatabaseService = 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 DatabaseService = 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); +}); +``` + +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 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: + +```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 DatabaseService = 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, DatabaseService), +); +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 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 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"; +import { Client } from "pg"; + +class DatabaseError extends Error { + readonly _tag = "DatabaseError"; +} +//---cut--- +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; + }), +}); +``` + +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 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); }