diff --git a/.eslintignore b/.eslintignore index 491fc35..fbefebf 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ node_modules lib +examples diff --git a/README.md b/README.md index 4bb5440..f65d7e0 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,10 @@ console.info(result().toPrecision(2)) // => e.g. '0.57' ## Examples -- [input validation](https://www.typescriptlang.org/play?noUncheckedIndexedAccess=true&noUnusedLocals=true&noUnusedParameters=true&target=99&jsx=0&useUnknownInCatchVariables=true&noImplicitOverride=true&noFallthroughCasesInSwitch=true&exactOptionalPropertyTypes=true&pretty=true#code/JYWwDg9gTgLgBAbzgUwB4GNlhsCA7AGjgGcBXdTY4uAXzgDMoIQ4ByNTbXPAQwBs+AT1YAoEQHpxcALSy58hYqXKVqteo2a1Y9PmLwAbv2AATHjGRwAvHAA8AEXM8AfAAozMHgC44jz0SM+U3NoH3cnHz8eAEprZzgYKFJLAB8SROA8AHNYq3iEEQBIXTx9OGBiAHkAa2s4QOCYaHDPaKLgejhXCprrKxtE5NioZBhSKDwSckpiFpixQpGxiZQMLBx8bqrqtpoxSRktI+OT07PVMRhBMEsABQgymwLCnlIYAAtQ9KhMrKKcGB8ZA+fQ-bJFEoWPAwEEZcF7EQlMqQfQANWMHmgdVcKJhcHu+ly+SKByW40mIGQPDwv3opD4KCgTCgcEpVB4WWQ1AA7u9kJNiMxRu9fuVqJkGiZ2p1XABCXEAOleH2gw1G5LYACJlZ8oJrWRViL9RNKuvKHjAFQCgWrlpNWJrrch9SBDcbTTiLVbgIDkAqgdkPnB4gBGABMAAZbRqHU6XaQygAjSzEXUWFkfalwcMRuDod48KA8dDp4gmwodM2KyH8mDRlYOmvQl1u7KiCsyxVO-38rJB2xwACsEajcDJDc1TZg8bKkJ4mTg5jgQJ4ZWHufzheLpfLpPVKwABoNkAe4Lz+fUMeZuHAwKviFyiuPJseRAikfBiDwDMgCfAbKugh4OgXS4j4f5EogEJ6IYV4bHgABKXL0v+l5BB4yCevoRC4ui6EhFAbQdl0krXvgSFkHwloVAAomsXD4PWkykfBFEoXABx3lQDDQNyhYmKsnDwQsBxfgYoofJYHg8GefLMXBN4FtQXEPlKxQweUAk2CGHFSDwfE+iQ36-haAAqEBRImq6YbibRPvuArTFysymLs+xSOcnled5PnSGIaCQLAeYaUkkwAcQQEga4kHPB+Rk-n+bFUXU+nzp+xl-q4zwvG8uo+KwABSEDvJM9gQMgrAEEUhROvlNFMliBZ4CYQTZCQnz0gJyYoKuwDIFAlXVVO+UADLQMgLDAGAZAsCYEB8NARBGvAPCUvASLICW+6LiYU0VMA6CisgQSWnAACCa1EAAjqQwAKoNhQ0HZxGuGJJn6El1HEHRQncLEHxMNycB4MgQP1cyr0ZRan3Rc9SLzX6mT0BArgHiDQO4vFyAmD4AAkCBvYlyFUdFNAHm5IihdFIhAA) -- [fetching data](https://www.typescriptlang.org/play?target=99&moduleResolution=99&module=100&noUncheckedIndexedAccess=true&noUnusedLocals=true&noUnusedParameters=true&jsx=0&useUnknownInCatchVariables=true&noImplicitOverride=true&noFallthroughCasesInSwitch=true&exactOptionalPropertyTypes=true&pretty=true#code/JYWwDg9gTgLgBAbzgUwB4GNlhsCA7AGjgGcBXdTY4o4YgUQyx3wEMAbNgTwCVky34AXzgAzKBBBwA5GkzZcedlykAoUJFiI4LKslgBlcpWJxhYidNlMFSzgHodxPTFUqYnMMjgApYvjgAvD5+eACCUFAsnHAAPsH4APIARgBWyOjwcb74AApQoMA4AG7IKm4eXtlhEVGB8XgA2gC6Ze6e9clpGXUIKgCQDQDScMB4JDD5eADmTQD8AFz1KoKtFfV5BcVeQUkQEGzILGNxeKQcsXCnIEl6F8QTo1NldnZwALQfn1-fP79--wDAUDgSDAc9XgATZAiUZeCHAEQiPTIPDwBhyZhjdBsRx8UTQbTnADWowhcAgIhQjHk+GIKhecAAcgkACp0RZ+EDIAAWEAA7nAWRV9Oh8tg4OgjlJ4PD7o9SLRuXAbjA+cgUXB4Yjkai4ABhHG6EwwbksGV6YAlUTiSQAAzoEWgtoAdPTXnAPZ7QiYWHA+dAiSxxKQ8GS1RKjiRkPBfWBxJ5YNFRsRgFC4CazRLDcaIHAQCwiV5RkioCjMH6A9opixRq7sY4mdH-VAiejrP40DAURCTA7xFBEP0GQABGDEN7AKZ4aBeE1eHI4zhTYOhzUQPHT+AJlP3dPcry26cAVVOTghABkIJK2MRbRL8DCnn1SywIfguHAAMSpur6TjXfYAAopEZJsAzbGk8CkABKZYVHrKg4AAEXSCAoSqCDMSpLtQ17R0B16PoRzHCcpxnPd50XZcIBDMk3w3CAtz0Hd4DnOBDwgE9SDPS9r1ve88EffoXzfPAP2-Mkgj-AC2GAlD0DQ5AMOpTEYLghCTAACRgGAwEwhRsO7PD+0HIjXlHcdJ2nUsKLgBcomo2i1wYpioBY2yOK4nir3YfiFMEydhMOUTxJ-KT-12WSpG03T9PwNSVg0uA6HAdwkLNHRozisZOyM5L8NM4jLLImy2PspcVzo9cTE3OBt1oVj93Y49T2QC8fJvO9-KE59gvfaIJN-CKgKkFLsE4dKYEymBsoS8E-X3MYkRgdBuUeTUMqIX0kQFE1HhMSUxlNMBPDwZ1nQWjVOWjNbplEGsbxoeBaDgKY9jJGBcyJacBT5U14FNY18imKZkQ+praDIUp-N3ZbVsm30gh0Tg8HQOAAB5eBgUgoDwIV2ly3D6gAPkA7tIFGGAOQeaZoMCYnTJh+A4e5Xh+HgJG+RrZno1WsnQwp1FYL6PpnUlFbuTJ+mqQxBRALwZABVA1VwJUuXkGgzW4AZNinCgEoBwUs4yRuOAQxYIoHpYJIDjgc7XT6BE4EA2hstsNmzhgQCWY9gQtdLbHcdEXnWb4T3tdeMAGxEaAuagMkrEgsozLt87bNLABHUg+HgI22DJEQHr9QolV9e4zW4hS0wAFlQVByQHABWOvU4dp3AIAQh9sOBGdCAiTpgOcZytX8HlxW4BivTR7wQDbQAEgQbv2edcvseIQRFkX5fPdXqb15ZTtBFtTXk6Zjapt9jntC5wpg4lq-nRSEJAOF0W51niEMulr+pu0EwsbD3xhrfootxZ822AzROmJx4CnkopZSssx4ay1gye2DdbJR04GwCAr57zGwEu5CkIw8CWzYD+bw+gEiMlbv0dursZ7ux7l7X+LAr7+2jMPC+bDmERzqtHWOQYE4z2Tu3cIkRODOloOIqIgFWHsLgAAMkUXADu8jmHOgONME0dNCK9UDiPJBs8FYCjGmlDKTgZoz1fnTNBacCT5k4KbNiZDUxmgMsQtirD8H53usANgqd+grCCgYkgRg+DEDkRlBRdiLpHALqMWw99VrrW8XnMkvlcymzIBQCJIgzhwTdO8UEJTSllPKRUgEqx2hHj1j0OhEJqaTCfIoLkTTHiFPPqDGAtTmJ1GRqjZ2dMAgM0IufFmvS3JX36bfHmEsEbo0mc0UmUhuQ6TAMQeYLxrpvBYGAYAYsJB2G4sxNSQ5Xh-RRMgA2fovDdOVCwdARJtBwFLOzIg4ZiC8gIatdIzyY4DmgQoEwMI3IwDoZSb2IdJnEEfgwoxujQHn1kHUCZetYXMNfqAhkppQy219JgWANYxhtC8MQoF-hSSWlTKQWwoD24ouTFNVGyBiHK2bK2GeiKRb6K4RS2eUg9Q0V8bVFmZt0UXQXIcJw6YoDRBYNWUYcAcRdigM6NSItgkiwZOGUS0pXmHA4NESUNlrY0UageMxE0LFZRnnefcpYiB+FuQa0JkZkCpXlTUTg9LIWMrwOXFlxCrUI0sdlblIsh5BxycYQCzQ35apTkqpQ5I5yG0ytQF1Uaxi+lBgrSIAS9AmS5FQBVpRI2cKDvy4CJ40CeAyG1FA+FJUHEygJKa3RdakBOtAGAbwuwsBAOq4WWqdZNTeeHQ6KAS63GyeEqg+SAnxPbcS8kONxV9IJKbTxax7SetDbaoxtp+jnxOW5VF0L0VXyxectcJAJA3XWnyEutlWEntpPsZAUjBIQDnjGvJZwPws0bYvM9xBNEoimCaYQYGT7J2zWE3JVBAJgdsZHARUA450QyoUhklT8MEcI0Rt4ZQunRlCBwSZrS8RI2ICjNGr9pZjNpPAMD0zObc1etGGFWKU7XI1CwEQqq8ye2AGAW2Ct7iNvyajLC14bwuqnfmQsYSbJ-UzCmyAVBgA2zJaQFaD6DqRhuBC52bGNHwvbHgCNDJMxsW7OSSkNEBxxggMuQdogcG-S8DCRQRq9ziF2k1ItBIMzwC-VMC63SpAmFlOJqIjbPq2TPaAk0gXLgTz7NAFDV7MWnz6KOi5XglMFi8GQUq-0XWmitOA7kMgMnnH5XSFOwCRRingM+84Bx4CcBonAb6-IRiUl66QfEUB7lJdxRCfFhirO3omNEJLpYQAQCtGxchCs9x2e5IF40TUssDjNVaZdI2pDnCcF4d1BUHXltxAYedkTzPs1gre7bXgHPhgQ147+z6TTmr9JEE661ChOtzHfKdpsYakC5GSZNYxuK0oCVzH1vKg5Pc9q-Z0+YwCAUAkgajphhkM2o7BYJeHiMU8p1T74pGWOvJDP0ujgzGMjMZnTs91GTAcbvt0ijbAqODr4LxmGn7v0xxy3oTnpOyhQBDFioAA) +> You need to clone this repository locally and open it in your IDE (VS Code) to see typesafety in action. (StackBlitz)[https://stackblitz.com/] and [github.dev](https://github.dev/github/dev) won't show TypeScript errors. Also the [TypeScript Playground](https://www.typescriptlang.org/play) is not able to show it correctly unless you set some specific options. + +- [input validation](https://github.com/ivanhofer/exceptionally/blob/main/examples/input-validation.ts) +- [fetching data](https://github.com/ivanhofer/exceptionally/blob/main/examples/fetching-data.ts) diff --git a/examples/fetching-data.ts b/examples/fetching-data.ts new file mode 100644 index 0000000..ca61fc7 --- /dev/null +++ b/examples/fetching-data.ts @@ -0,0 +1,125 @@ +// ! NOTE: You need to clone this repository locally and open it in your IDE (VS Code) to see typesafety in action. + +import { exception, isExceptionallyResult, success } from 'exceptionally' +import { assertSuccess, guardSuccess } from 'exceptionally/assert' + +type Json = JsonArray | JsonObject | JsonPrimitive + +type JsonArray = Json[] + +type JsonObject = { + [K in string]?: Json +} + +type JsonPrimitive = boolean | null | number | string + +// -------------------------------------------------------------------------------------------------------------------- + +// define different Exception classes for all kind of exceptions +// NOTE: somehow TypeScript can't distinguish between different Classes that derive from `Error`. +// As a workaround we can set a property inside that class to make inference work again. +class NetworkException extends Error { + // @ts-ignore the Playground does not persist the `noUnusedLocals` config + readonly #id = Symbol('NetworkException') +} +class DecodeJsonException extends Error { + // @ts-ignore the Playground does not persist the `noUnusedLocals` config + readonly #id = Symbol('DecodeJsonException') +} +class HttpException extends Error { + // @ts-ignore the Playground does not persist the `noUnusedLocals` config + readonly #id = Symbol('HttpException') +} +class EmptyDatasetException extends Error { + // @ts-ignore the Playground does not persist the `noUnusedLocals` config + readonly #id = Symbol('EmptyDatasetException') +} + +// when fetching data, a few things can happen.. when something fails, it is good to know what has triggered the issue +const fetchData = async (endpoint: string) => { + const fetchResult = await fetch(endpoint) + .catch(e => exception(new NetworkException(e))) // the server could be unavailable ... + if (isExceptionallyResult(fetchResult)) return fetchResult // pass forward exception + + // ... the request could fail with a statuscode 4xx or 5xx ... + if (!fetchResult.ok) return exception(new HttpException(`${fetchResult.status}: ${fetchResult.statusText}`)) + + const dataResult = await fetchResult.json() + .then(data => data as ReturnType) + .catch(e => exception(new DecodeJsonException(e))) // ... or the payload could consist of invalid JSON ... + if (isExceptionallyResult(dataResult)) return dataResult // pass forward exception + + if (Array.isArray(dataResult) && !dataResult.length) { + return exception(new EmptyDatasetException()) // ... or maybe the validation of the data could fail .. + } + + return success(dataResult) // ... and finally fetching data could also be successful +} + +// -------------------------------------------------------------------------------------------------------------------- + +type User = { + id: string + name: string +} + +const getUsers = async () => { + const fetchUsersResult = await fetchData('https://some-api.com/users') + + // whenever we get back a result, we should check for exceptions first + if (fetchUsersResult.isException) { + const exc = fetchUsersResult() + // handle a certain type of exception individually + if (exc instanceof NetworkException) { + return exception('Could not fetch users. Please try again later.') + } + + // we don't really care about the `EmptyDatasetException` here, so we return an empty array + if (exc instanceof EmptyDatasetException) { + return success([]) + } + + // in all other cases, we return a general error message + return exception('Unexpected error. Please contact the support-team.') + } + + // the result can either be successful and contain our users or be of type `EmptyDatasetException` + const users = fetchUsersResult() + + // do something with the data + console.info(`successfully fetched ${users.length} users`) + + return success(users) // pass forward data +} + +// -------------------------------------------------------------------------------------------------------------------- + +const getAllUsernames = async () => { + const usersResult = await getUsers() + // even after multiple nested function calls we can make sure what all possible outcomes can be + if (usersResult.isException) { + // at the end of our program flow we finally throw the error that e.g. get's displayed to the user + throw new Error(usersResult()) + } + + // we can make sure that we have catch'ed all exceptions + // TypeScript will let you know if you forget to handle an exception + // try to remove the line that throws the Error above and you'll see an error here + guardSuccess(usersResult) // only type-safety + + // if you don't trust TypeScript ton warn you about unhandled exceptions, you can use the following line + // when this line get's executed on runtime, it will throw an error + assertSuccess(usersResult) // type-safety with additional runtime-safety + + // at the end we return the data without wrapping it, so it can be consumed in an usual way + return usersResult().map(({ name }) => name) +} + +// -------------------------------------------------------------------------------------------------------------------- + +const run = async () => { + const usernames = await getAllUsernames() + console.info(usernames) +} + +run() diff --git a/examples/input-validation.ts b/examples/input-validation.ts new file mode 100644 index 0000000..de06956 --- /dev/null +++ b/examples/input-validation.ts @@ -0,0 +1,60 @@ +// ! NOTE: You need to clone this repository locally and open it in your IDE (VS Code) to see typesafety in action. + +import { exception, success } from 'exceptionally' + +// -------------------------------------------------------------------------------------------------------------------- + +const validate = (data: Data, validator: (data: Data) => true | string) => { + const isOk = validator(data) + if (isOk === true) return success(data) + + return exception(isOk) +} + +// -------------------------------------------------------------------------------------------------------------------- + +type Post = { + author: string + title: string + content: string +} + +const postValidator = (post: Post) => { + // return meaningful error messages when something is invalid + if (!post.author) return '"author" missing' + + if (!post.title) return '"title" missing' + if (post.title.length > 120) return '"title" must be shorter than 120 characters' + + if (!post.content) return '"content" missing' + if (post.title.length < 500) return '"content" must contain at least 500 characters' + + // return `true` when validation passes + return true +} + +const savePost = async (post: Post) => { + const validationResult = validate(post, postValidator) + if (validationResult.isException) return validationResult // pass forward exception + + // saving the data when validation has passed + const id = 1 // await savePostToDatabase(post) + + return success(id) +} + +// -------------------------------------------------------------------------------------------------------------------- + +export const run = async () => { + const savePostResult = await savePost({ + author: 'John Doe', + title: 'Error handling should be easier', + content: 'Lorem ipsum dolor, sit amet consectetur adipisicing elit. Amet, qui.', + }) + + if (savePostResult.isException) throw new Error(savePostResult()) + + console.info(`new post saved: ${savePostResult()}`) +} + +run() diff --git a/tsconfig.json b/tsconfig.json index f8309a5..2538502 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,9 @@ "skipDefaultLibCheck": true, "skipLibCheck": true, "paths": { - "exceptionally": ["./src/index.ts"] + "exceptionally": ["./src/index.ts"], + "exceptionally/assert": ["./src/assert/index.ts"], + "exceptionally/utils": ["./src/utils/index.ts"] } }, "include": [