diff --git a/README.md b/README.md index 9fac573..06575ed 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,12 @@ yarn add http-result The core idea of `http-result` is to handle method responses as a **Result** type, which can either be a successful result with data or an error. This avoids unhandled errors and allows for more precise control over how errors are processed. -### Example: `createOrg` +### Example Here’s an example using `http-result` to handle errors in a method that creates an organization: +**Internal Service code** + ```typescript import { HttpErrorResults, Ok, Result } from 'http-result' @@ -70,20 +72,38 @@ class OrganisationService { }): Promise> { // Simulate error handling if (!userId) { - return Err('BadRequest', 'Article too small') - // return HttpErrorResults.NotFound('User does not exists') + return Err('NotFound', 'User does not exist') + // You can also use + // return HttpErrorResults.NotFound('User does not exist') + } + + if (name === 'Rick Astley') { + return HttpErrorResults.InternalServerError('Server got rick rolled') } - if (!name) { - return HttpErrorResults.InternalServer('User does not exists') + // error can be BadRequest | NotFound + const [letter, letterError] = await this.letterService.sendLetter(name) + + if (letterError) { + // repackage, preserves message from letterError in messages array + // send better more relevant errors to users even if generic code fails. + return HttpErrorResults.InternalServerError( + 'Failed to send letter', + letterError, + ) } // Simulate successful organization creation const org = { id: '123', name } - Ok(org) + + return Ok(org) } } +``` +**API Gateway/Handler code** + +```typescript // caller function, API request handler in this case import { tsRestError, TsRestResponse } from 'http-result/ts-rest' @@ -95,11 +115,12 @@ const [org, error] = await organisationService.createOrg({ }) if (error.kind === 'NotFound') { - // special handling + // special handling, repackage the error to hid original return TsRestResponse.BadRequest('You made a mistake!') } if (error) { + // forwar return tsRestError(error) // Return a structured error response } diff --git a/docs/why-api.md b/docs/why-api.md new file mode 100644 index 0000000..9318c37 --- /dev/null +++ b/docs/why-api.md @@ -0,0 +1,116 @@ +# Why API? + +This doc explains why API is the way it is, so when things change we can update the API for good and not take it as God's word. + +## Result as array of `[data, error]` + +Requirements for this API were: + +1. Errors should not be ignore-able, i.e. must be handled explicitly. +2. Low mental bandwidth requirements, should fade away in the background. +3. Errors must have http status code. + +Rust uses [enums](https://doc.rust-lang.org/rust-by-example/custom_types/enum.html) to implement result type, and you have to unpack that handle error and then proceed with your code. + +```rust +// Result +match divide(10.0, 0.0) { + Ok(result) => println!("Result: {}", result), + Err(e) => println!("Error: {}", e), +} +``` + +Go on the other hand is skip-able on a type level, linters might catch this but code's good. + +```go +result, err := divide(10, 2) +fmt.Println("Result:", result) +``` + +So the rust way is superior in this aspect. But, Typescript lacks **pattern matching** feature that would elegantly enable this form so here comes the solutions. + +### Approaches + +#### Tagged Unions/Discriminated Unions + +```typescript +type Result = + | { error: false; data: T } + | { + error: true + kind: S // 'BadRequest' | 'NotFound' | 'InternalServerError' + message: string + } +``` + +Usage: + +```typescript +const result = await someServiceFunction(arg) + +if (result.error) { + // handle error +} + +// handle success + +console.log(result.data) +``` + +But developer now has to worry about this result type and, it doesn't fade away +in the background need to write boilerplate code. Violate requirement (2). + +#### Tuples + +Tuples are like arrays but of fixed lengths + +```typescript +type Result = [T, null] | [null | ErrPayload] +``` + +This gives us our current API + +```typescript +const [data, error] = await someServiceFunction(name) + +if (error) { + // handle error +} + +console.log(data) +``` + +As soon as we de-structure the `data, error` everything is normal code. + +### Helper Functions + +Both of the above are easy to accept as a return value but hell to return from a function, for that we have some helper functions. Just like in `rust` + +```typescript +return Ok(data) + +return Err('BadRequest', 'This is a message') +return HttpErrorResults.BadRequest('This is a message') +``` + +There are two error returning helper functions. Depending on your editor/IDE +one might have better experience than other. + +### Formatter + +The core `Error` can then be converted into different frameworks' response +format using other set of helper functions. +They are separate from core code as you'd generally be using only one of them in your app. + +- [x] `http-result/ts-rest` +- [ ] `http-result/express` +- [ ] `http-result/koa` + +```typescript +import { tsRestError, TsRestResponse } from 'http-result/ts-rest' + +return TsRestResponse.Created(org) // Return a successful response + +return TsRestResponse.BadRequest('You made a mistake!') +return tsRestError(error) // Return a structured error response, automatically +``` diff --git a/examples/live/main.ts b/examples/live/main.ts index a2983f6..ec03319 100644 --- a/examples/live/main.ts +++ b/examples/live/main.ts @@ -15,7 +15,7 @@ const router = s.router(contract, { return TsRestResponse.OK(post) }, createPost: async ({ body }) => { - const [post, createPostError] = serviceCreatePost(body.content) + const [post, createPostError] = await serviceCreatePost(body.content) // if (!createPostError) { if (post) { diff --git a/examples/live/service.ts b/examples/live/service.ts index 527bd38..86ddba6 100644 --- a/examples/live/service.ts +++ b/examples/live/service.ts @@ -6,10 +6,11 @@ import { IHttpErrorKind, } from '../../src/index' import { Post } from './contract' +import { createIndex } from './sub-service' -export function serviceCreatePost( +export async function serviceCreatePost( article: string, -): Result { +): Promise> { if (article.length < 10) { return Err('BadRequest', 'Article too small') } @@ -18,6 +19,17 @@ export function serviceCreatePost( return HttpErrorResults.InternalServerError('Error storing in DB') } + const [index, indexError] = await createIndex(article) + + if (indexError) { + return HttpErrorResults.InternalServerError( + 'Index Creation failed', + indexError, + ) + } + + console.log(index) + return Ok({ article }) } diff --git a/examples/live/sub-service.ts b/examples/live/sub-service.ts new file mode 100644 index 0000000..fc98732 --- /dev/null +++ b/examples/live/sub-service.ts @@ -0,0 +1,12 @@ +import { Result, HttpErrorResults, Ok } from '../../src/index' + +// if expecting, expect true, need help what to do here +export async function createIndex( + _article: string, +): Promise> { + if (Math.random() > 0.5) { + HttpErrorResults.URITooLong('Too long url for article') + } + + return Ok(true) +} diff --git a/examples/package/ts-rest/main.ts b/examples/package/ts-rest/main.ts index 1edbe81..9d71b90 100644 --- a/examples/package/ts-rest/main.ts +++ b/examples/package/ts-rest/main.ts @@ -15,7 +15,7 @@ const router = s.router(contract, { return TsRestResponse.OK(post) }, createPost: async ({ body }) => { - const [post, createPostError] = serviceCreatePost(body.content) + const [post, createPostError] = await serviceCreatePost(body.content) // if (!createPostError) { if (post) { diff --git a/examples/package/ts-rest/service.ts b/examples/package/ts-rest/service.ts index 78c49bf..382540e 100644 --- a/examples/package/ts-rest/service.ts +++ b/examples/package/ts-rest/service.ts @@ -1,10 +1,10 @@ -import { HttpErrorResults, Ok, Result } from 'http-result' -import { Err } from '../../../dist/index' +import { HttpErrorResults, Ok, Result, Err } from 'http-result' import { Post } from './contract' +import { createIndex } from './sub-service' -export function serviceCreatePost( +export async function serviceCreatePost( article: string, -): Result { +): Promise> { if (article.length < 10) { return Err('BadRequest', 'Article too small') } @@ -13,5 +13,16 @@ export function serviceCreatePost( return HttpErrorResults.InternalServerError('Error storing in DB') } + const [index, indexError] = await createIndex(article) + + if (indexError) { + return HttpErrorResults.InternalServerError( + 'Index Creation failed', + indexError, + ) + } + + console.log(index) + return Ok({ article }) } diff --git a/examples/package/ts-rest/sub-service.ts b/examples/package/ts-rest/sub-service.ts new file mode 100644 index 0000000..34fd559 --- /dev/null +++ b/examples/package/ts-rest/sub-service.ts @@ -0,0 +1,12 @@ +import { Result, HttpErrorResults, Ok } from 'http-result' + +// if expecting, expect true, need help what to do here +export async function createIndex( + _article: string, +): Promise> { + if (Math.random() > 0.5) { + HttpErrorResults.URITooLong('Too long url for article') + } + + return Ok(true) +}