diff --git a/.gitignore b/.gitignore index dac820de..a95dcc4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules dist/ -npm \ No newline at end of file +npm +docs diff --git a/deno.json b/deno.json index bc2deba2..66dec5f6 100644 --- a/deno.json +++ b/deno.json @@ -3,6 +3,7 @@ "tasks": { "test": "deno test --allow-env --allow-net --unstable src", "publish": "deno task build-npm && cd npm/ && npm publish", - "build-npm": "deno run -A scripts/build-npm.ts" + "build-npm": "deno run -A scripts/build-npm.ts", + "docs": "deno doc --html --name='domain-functions' ./mod.ts" } } diff --git a/deno.lock b/deno.lock index 620126d2..9b50b72a 100644 --- a/deno.lock +++ b/deno.lock @@ -1,5 +1,5 @@ { - "version": "2", + "version": "3", "remote": { "https://deno.land/std@0.140.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74", "https://deno.land/std@0.140.0/_util/os.ts": "3b4c6e27febd119d36a416d7a97bd3b0251b77c88942c8f16ee5953ea13e2e49", @@ -52,36 +52,40 @@ "https://deno.land/std@0.182.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", "https://deno.land/std@0.182.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", "https://deno.land/std@0.182.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba", - "https://deno.land/std@0.198.0/_util/diff.ts": "1a3c044aedf77647d6cac86b798c6417603361b66b54c53331b312caeb447aea", - "https://deno.land/std@0.198.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", - "https://deno.land/std@0.198.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", - "https://deno.land/std@0.198.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", - "https://deno.land/std@0.198.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", - "https://deno.land/std@0.198.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", - "https://deno.land/std@0.198.0/assert/assert_equals.ts": "a0ee60574e437bcab2dcb79af9d48dc88845f8fd559468d9c21b15fd638ef943", - "https://deno.land/std@0.198.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", - "https://deno.land/std@0.198.0/assert/assert_false.ts": "a9962749f4bf5844e3fa494257f1de73d69e4fe0e82c34d0099287552163a2dc", - "https://deno.land/std@0.198.0/assert/assert_instance_of.ts": "09fd297352a5b5bbb16da2b5e1a0d8c6c44da5447772648622dcc7df7af1ddb8", - "https://deno.land/std@0.198.0/assert/assert_is_error.ts": "b4eae4e5d182272efc172bf28e2e30b86bb1650cd88aea059e5d2586d4160fb9", - "https://deno.land/std@0.198.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", - "https://deno.land/std@0.198.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", - "https://deno.land/std@0.198.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", - "https://deno.land/std@0.198.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", - "https://deno.land/std@0.198.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", - "https://deno.land/std@0.198.0/assert/assert_object_match.ts": "d8fc2867cfd92eeacf9cea621e10336b666de1874a6767b5ec48988838370b54", - "https://deno.land/std@0.198.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", - "https://deno.land/std@0.198.0/assert/assert_strict_equals.ts": "5cf29b38b3f8dece95287325723272aa04e04dbf158d886d662fa594fddc9ed3", - "https://deno.land/std@0.198.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", - "https://deno.land/std@0.198.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", - "https://deno.land/std@0.198.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", - "https://deno.land/std@0.198.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", - "https://deno.land/std@0.198.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", - "https://deno.land/std@0.198.0/assert/mod.ts": "08d55a652c22c5da0215054b21085cec25a5da47ce4a6f9de7d9ad36df35bdee", - "https://deno.land/std@0.198.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", - "https://deno.land/std@0.198.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", - "https://deno.land/std@0.198.0/fmt/colors.ts": "a7eecffdf3d1d54db890723b303847b6e0a1ab4b528ba6958b8f2e754cf1b3bc", - "https://deno.land/std@0.198.0/testing/_test_suite.ts": "30f018feeb3835f12ab198d8a518f9089b1bcb2e8c838a8b615ab10d5005465c", - "https://deno.land/std@0.198.0/testing/bdd.ts": "3f446df5ef8e856a869e8eec54c8482590415741ff0b6358a00c43486cc15769", + "https://deno.land/std@0.206.0/assert/_constants.ts": "8a9da298c26750b28b326b297316cdde860bc237533b07e1337c021379e6b2a9", + "https://deno.land/std@0.206.0/assert/_diff.ts": "58e1461cc61d8eb1eacbf2a010932bf6a05b79344b02ca38095f9b805795dc48", + "https://deno.land/std@0.206.0/assert/_format.ts": "a69126e8a469009adf4cf2a50af889aca364c349797e63174884a52ff75cf4c7", + "https://deno.land/std@0.206.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.206.0/assert/assert_almost_equals.ts": "e15ca1f34d0d5e0afae63b3f5d975cbd18335a132e42b0c747d282f62ad2cd6c", + "https://deno.land/std@0.206.0/assert/assert_array_includes.ts": "6856d7f2c3544bc6e62fb4646dfefa3d1df5ff14744d1bca19f0cbaf3b0d66c9", + "https://deno.land/std@0.206.0/assert/assert_equals.ts": "d8ec8a22447fbaf2fc9d7c3ed2e66790fdb74beae3e482855d75782218d68227", + "https://deno.land/std@0.206.0/assert/assert_exists.ts": "407cb6b9fb23a835cd8d5ad804e2e2edbbbf3870e322d53f79e1c7a512e2efd7", + "https://deno.land/std@0.206.0/assert/assert_false.ts": "0ccbcaae910f52c857192ff16ea08bda40fdc79de80846c206bfc061e8c851c6", + "https://deno.land/std@0.206.0/assert/assert_greater.ts": "ae2158a2d19313bf675bf7251d31c6dc52973edb12ac64ac8fc7064152af3e63", + "https://deno.land/std@0.206.0/assert/assert_greater_or_equal.ts": "1439da5ebbe20855446cac50097ac78b9742abe8e9a43e7de1ce1426d556e89c", + "https://deno.land/std@0.206.0/assert/assert_instance_of.ts": "3aedb3d8186e120812d2b3a5dea66a6e42bf8c57a8bd927645770bd21eea554c", + "https://deno.land/std@0.206.0/assert/assert_is_error.ts": "c21113094a51a296ffaf036767d616a78a2ae5f9f7bbd464cd0197476498b94b", + "https://deno.land/std@0.206.0/assert/assert_less.ts": "aec695db57db42ec3e2b62e97e1e93db0063f5a6ec133326cc290ff4b71b47e4", + "https://deno.land/std@0.206.0/assert/assert_less_or_equal.ts": "5fa8b6a3ffa20fd0a05032fe7257bf985d207b85685fdbcd23651b70f928c848", + "https://deno.land/std@0.206.0/assert/assert_match.ts": "c4083f80600bc190309903c95e397a7c9257ff8b5ae5c7ef91e834704e672e9b", + "https://deno.land/std@0.206.0/assert/assert_not_equals.ts": "9f1acab95bd1f5fc9a1b17b8027d894509a745d91bac1718fdab51dc76831754", + "https://deno.land/std@0.206.0/assert/assert_not_instance_of.ts": "0c14d3dfd9ab7a5276ed8ed0b18c703d79a3d106102077ec437bfe7ed912bd22", + "https://deno.land/std@0.206.0/assert/assert_not_match.ts": "3796a5b0c57a1ce6c1c57883dd4286be13a26f715ea662318ab43a8491a13ab0", + "https://deno.land/std@0.206.0/assert/assert_not_strict_equals.ts": "ca6c6d645e95fbc873d25320efeb8c4c6089a9a5e09f92d7c1c4b6e935c2a6ad", + "https://deno.land/std@0.206.0/assert/assert_object_match.ts": "d8fc2867cfd92eeacf9cea621e10336b666de1874a6767b5ec48988838370b54", + "https://deno.land/std@0.206.0/assert/assert_rejects.ts": "45c59724de2701e3b1f67c391d6c71c392363635aad3f68a1b3408f9efca0057", + "https://deno.land/std@0.206.0/assert/assert_strict_equals.ts": "b1f538a7ea5f8348aeca261d4f9ca603127c665e0f2bbfeb91fa272787c87265", + "https://deno.land/std@0.206.0/assert/assert_string_includes.ts": "b821d39ebf5cb0200a348863c86d8c4c4b398e02012ce74ad15666fc4b631b0c", + "https://deno.land/std@0.206.0/assert/assert_throws.ts": "63784e951475cb7bdfd59878cd25a0931e18f6dc32a6077c454b2cd94f4f4bcd", + "https://deno.land/std@0.206.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.206.0/assert/equal.ts": "9f1a46d5993966d2596c44e5858eec821859b45f783a5ee2f7a695dfc12d8ece", + "https://deno.land/std@0.206.0/assert/fail.ts": "c36353d7ae6e1f7933d45f8ea51e358c8c4b67d7e7502028598fe1fea062e278", + "https://deno.land/std@0.206.0/assert/mod.ts": "37c49a26aae2b254bbe25723434dc28cd7532e444cf0b481a97c045d110ec085", + "https://deno.land/std@0.206.0/assert/unimplemented.ts": "d56fbeecb1f108331a380f72e3e010a1f161baa6956fd0f7cf3e095ae1a4c75a", + "https://deno.land/std@0.206.0/assert/unreachable.ts": "4600dc0baf7d9c15a7f7d234f00c23bca8f3eba8b140286aaca7aa998cf9a536", + "https://deno.land/std@0.206.0/fmt/colors.ts": "c51c4642678eb690dcf5ffee5918b675bf01a33fba82acf303701ae1a4f8c8d9", + "https://deno.land/std@0.206.0/testing/_test_suite.ts": "30f018feeb3835f12ab198d8a518f9089b1bcb2e8c838a8b615ab10d5005465c", + "https://deno.land/std@0.206.0/testing/bdd.ts": "3f446df5ef8e856a869e8eec54c8482590415741ff0b6358a00c43486cc15769", "https://deno.land/x/code_block_writer@12.0.0/mod.ts": "2c3448060e47c9d08604c8f40dee34343f553f33edcdfebbf648442be33205e5", "https://deno.land/x/code_block_writer@12.0.0/utils/string_utils.ts": "60cb4ec8bd335bf241ef785ccec51e809d576ff8e8d29da43d2273b69ce2a6ff", "https://deno.land/x/deno_cache@0.4.1/auth_tokens.ts": "5fee7e9155e78cedf3f6ff3efacffdb76ac1a76c86978658d9066d4fb0f7326e", @@ -118,18 +122,18 @@ "https://deno.land/x/ts_morph@18.0.0/common/typescript.js": "d5c598b6a2db2202d0428fca5fd79fc9a301a71880831a805d778797d2413c59", "https://deno.land/x/wasmbuild@0.14.1/cache.ts": "89eea5f3ce6035a1164b3e655c95f21300498920575ade23161421f5b01967f4", "https://deno.land/x/wasmbuild@0.14.1/loader.ts": "d98d195a715f823151cbc8baa3f32127337628379a02d9eb2a3c5902dbccfc02", - "https://deno.land/x/zod@v3.21.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea", - "https://deno.land/x/zod@v3.21.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", - "https://deno.land/x/zod@v3.21.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", - "https://deno.land/x/zod@v3.21.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", - "https://deno.land/x/zod@v3.21.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", - "https://deno.land/x/zod@v3.21.4/helpers/parseUtil.ts": "51a76c126ee212be86013d53a9d07f87e9ae04bb1496f2558e61b62cb74a6aa8", - "https://deno.land/x/zod@v3.21.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", - "https://deno.land/x/zod@v3.21.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", - "https://deno.land/x/zod@v3.21.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774", - "https://deno.land/x/zod@v3.21.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", - "https://deno.land/x/zod@v3.21.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", - "https://deno.land/x/zod@v3.21.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4", - "https://deno.land/x/zod@v3.21.4/types.ts": "b5d061babea250de14fc63764df5b3afa24f2b088a1d797fc060ba49a0ddff28" + "https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea", + "https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.22.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.22.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.22.4/helpers/parseUtil.ts": "f791e6e65a0340d85ad37d26cd7a3ba67126cd9957eac2b7163162155283abb1", + "https://deno.land/x/zod@v3.22.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.22.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.22.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774", + "https://deno.land/x/zod@v3.22.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.22.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.22.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4", + "https://deno.land/x/zod@v3.22.4/types.ts": "724185522fafe43ee56a52333958764c8c8cd6ad4effa27b42651df873fc151e" } } diff --git a/src/all.test.ts b/src/all.test.ts index 6386c4da..bb815b4d 100644 --- a/src/all.test.ts +++ b/src/all.test.ts @@ -4,7 +4,7 @@ import { assertEquals, assertObjectMatch, } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { all } from './domain-functions.ts' diff --git a/src/apply-environment.test.ts b/src/apply-environment.test.ts new file mode 100644 index 00000000..c35e363d --- /dev/null +++ b/src/apply-environment.test.ts @@ -0,0 +1,46 @@ +import { assertEquals, describe, it } from './test-prelude.ts' +import { z } from './test-prelude.ts' + +import { mdf } from './constructor.ts' +import { applyEnvironment } from './domain-functions.ts' + +describe('applyEnvironment', () => { + it('fails when environment fails parser', async () => { + const getEnv = mdf(z.unknown(), z.number())((_, e) => e) + + const getEnvWithEnvironment = applyEnvironment( + getEnv, + 'invalid environment', + ) + + assertEquals(await getEnvWithEnvironment('some input'), { + success: false, + errors: [], + inputErrors: [], + environmentErrors: [ + { + message: 'Expected number, received string', + path: [], + }, + ], + }) + }) + + it('should apply environment', async () => { + const getEnv = mdf(z.unknown(), z.string())((_, e) => e) + + const getEnvWithEnvironment = applyEnvironment( + getEnv, + 'constant environment', + ) + + assertEquals(await getEnvWithEnvironment('some input'), { + success: true, + data: 'constant environment', + errors: [], + inputErrors: [], + environmentErrors: [], + }) + }) +}) + diff --git a/src/branch.test.ts b/src/branch.test.ts index 79dae4b0..2d53c1d2 100644 --- a/src/branch.test.ts +++ b/src/branch.test.ts @@ -4,7 +4,7 @@ import { assertEquals, assertObjectMatch, } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { branch, pipe, all } from './domain-functions.ts' diff --git a/src/collect-sequence.test.ts b/src/collect-sequence.test.ts index b0fb6c19..cfb1c883 100644 --- a/src/collect-sequence.test.ts +++ b/src/collect-sequence.test.ts @@ -1,5 +1,5 @@ import { describe, it, assertEquals } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { collectSequence } from './domain-functions.ts' diff --git a/src/collect.test.ts b/src/collect.test.ts index 0fd1428e..418017ef 100644 --- a/src/collect.test.ts +++ b/src/collect.test.ts @@ -4,7 +4,7 @@ import { assertObjectMatch, assertEquals, } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { collect } from './domain-functions.ts' diff --git a/src/composable/composable.ts b/src/composable/composable.ts new file mode 100644 index 00000000..05b342c4 --- /dev/null +++ b/src/composable/composable.ts @@ -0,0 +1,215 @@ +import { toErrorWithMessage } from './errors.ts' +import { + Composable, + ErrorWithMessage, + Failure, + First, + Fn, + Last, + MergeObjs, + Success, + UnpackAll, + UnpackResult, +} from './types.ts' + +/** + * Merges a list of objects into a single object. + * It is a type-safe version of Object.assign. + * @param objs the list of objects to merge + * @returns the merged object + * @example + * const obj1 = { a: 1, b: 2 } + * const obj2 = { c: 3 } + * const obj3 = { d: 4 } + * const merged = mergeObjects([obj1, obj2, obj3]) + * // ^? { a: number, b: number, c: number, d: number } + */ +function mergeObjects(objs: T) { + return Object.assign({}, ...objs) as MergeObjs +} + +function success(data: T): Success { + return { success: true, data, errors: [] } +} + +function error(errors: ErrorWithMessage[]): Failure { + return { success: false, errors } +} + +/** + * Creates a composable function. + * That function is gonna catch any errors and always return a Result. + * @param fn a function to be used as a Composable + */ +function composable(fn: T): Composable { + return async (...args) => { + try { + // deno-lint-ignore no-explicit-any + const result = await fn(...(args as any[])) + return success(result) + } catch (e) { + return error([toErrorWithMessage(e)]) + } + } +} + +/** + * Creates a single function out of a chain of multiple Composables. It will pass the output of a function as the next function's input in left-to-right order. The resulting data will be the output of the rightmost function. + * @example + * import { composable as C } from 'domain-functions' + * + * const a = C.composable( + * ({ aNumber }: { aNumber: number }) => ({ aString: String(aNumber) }), + * ) + * const b = C.composable( + * ({ aString }: { aString: string }) => ({ aBoolean: aString == '1' }), + * ) + * const d = C.pipe(a, b) + * // ^? Composable<({ aNumber }: { aNumber: number }) => { aBoolean: boolean }> + */ +function pipe(...fns: T) { + return (async (...args) => { + const res = await sequence(...fns)(...args) + return !res.success ? error(res.errors) : success(res.data.at(-1)) + }) as Composable< + ( + ...args: Parameters, Composable>> + ) => UnpackResult, Composable>>> + > +} + +/** + * Creates a single function out of multiple Composables. It will pass the same input to each provided function. The functions will run in parallel. If all constituent functions are successful, The data field will be a tuple containing each function's output. + * @example + * import { composable as C } from 'domain-functions' + * + * const a = C.composable((id: number) => id + 1) + * const b = C.composable(String) + * const c = C.composable(Boolean) + * const cf = C.all(a, b, c) +// ^? Composable<(id: number) => [string, number, boolean]> + */ +function all(...fns: T) { + return (async (...args: any) => { + const results = await Promise.all(fns.map((fn) => fn(...args))) + + if (results.some(({ success }) => success === false)) { + return error(results.map(({ errors }) => errors).flat()) + } + + return success((results as Success[]).map(({ data }) => data)) + }) as unknown as Composable< + (...args: Parameters) => { + [key in keyof T]: UnpackResult>> + } + > +} + +/** + * Receives a Record of Composables, runs them all in parallel and preserves the shape of this record for the data property in successful results. + * @example + * import { composable as C } from 'domain-functions' + * + * const a = C.composable(() => '1') + * const b = C.composable(() => 2) + * const df = collect({ a, b }) +// ^? Composable<() => { a: string, b: number }> + */ +function collect>(fns: T) { + const [fn, ...fnsWithKey] = Object.entries(fns).map(([key, cf]) => + map(cf, (result) => ({ [key]: result })), + ) + return map(all(fn, ...fnsWithKey), mergeObjects) as Composable< + (...args: Parameters>) => { + [key in keyof T]: UnpackResult>> + } + > +} + +/** + * Works like `pipe` but it will collect the output of every function in a tuple. + * @example + * import { composable as C } from 'domain-functions' + * + * const a = C.compose((aNumber: number) => String(aNumber)) + * const b = C.compose((aString: string) => aString === '1') + * const cf = C.sequence(a, b) + * // ^? Composable<(aNumber: number) => [string, boolean]> + */ +function sequence(...fns: T) { + return (async (...args) => { + const [head, ...tail] = fns + + const res = await head(...args) + if (!res.success) return error(res.errors) + + const result = [res.data] + for await (const fn of tail) { + const res = await fn(result.at(-1)) + if (!res.success) return error(res.errors) + result.push(res.data) + } + return success(result) + }) as Composable< + (...args: Parameters, Composable>>) => UnpackAll + > +} + + +/** + * It takes a Composable and a predicate to apply a transformation over the resulting `data`. It only runs if the function was successfull. When the given function fails, its error is returned wihout changes. + * @example + * import { composable as C } from 'domain-functions' + * + * const increment = C.composable(({ id }: { id: number }) => id + 1) + * const incrementToString = C.map(increment, String) + * // ^? Composable + */ +function map( + fn: T, + mapper: (res: UnpackResult>) => R, +) { + return (async (...args) => { + const res = await fn(...args) + if (!res.success) return error(res.errors) + const mapped = await composable(mapper)(res.data) + if (!mapped.success) return error(mapped.errors) + return mapped + }) as Composable<(...args: Parameters) => R> +} + +/** + * Creates a new function that will apply a transformation over a resulting Failure from the given function. When the given function succeeds, its result is returned without changes. + * @example + * import { composable as C } from 'domain-functions' + * + * const increment = C.composable(({ id }: { id: number }) => id + 1) + * const incrementWithErrorSummary = C.mapError(increment, (result) => ({ + * errors: [{ message: 'Errors count: ' + result.errors.length }], + * })) + */ +function mapError( + fn: T, + mapper: (err: Omit) => Omit, +) { + return (async (...args) => { + const res = await fn(...args) + return !res.success ? error(mapper(res).errors) : success(res.data) + }) as T +} + +export { + all, + collect, + composable, + composable as cf, + composable as λ, + error, + map, + mapError, + mergeObjects, + pipe, + sequence, + success, +} + diff --git a/src/composable/errors.ts b/src/composable/errors.ts new file mode 100644 index 00000000..42ad9487 --- /dev/null +++ b/src/composable/errors.ts @@ -0,0 +1,34 @@ +import { ErrorWithMessage } from './types.ts' + +function objectHasKey( + obj: unknown, + key: T, +): obj is { [k in T]: unknown } { + return typeof obj === 'object' && obj !== null && key in obj +} + +function isErrorWithMessage(error: unknown): error is ErrorWithMessage { + return objectHasKey(error, 'message') && typeof error.message === 'string' +} + +/** + * Turns the given 'unknown' error into an ErrorWithMessage. + * @param maybeError the error to turn into an ErrorWithMessage + * @returns the ErrorWithMessage + * @example + * try {} + * catch (error) { + * const errorWithMessage = toErrorWithMessage(error) + * console.log(errorWithMessage.message) + * } + */ +function toErrorWithMessage(maybeError: unknown): ErrorWithMessage { + return { + message: isErrorWithMessage(maybeError) + ? maybeError.message + : String(maybeError), + exception: maybeError, + } +} + +export { toErrorWithMessage } diff --git a/src/composable/index.test.ts b/src/composable/index.test.ts new file mode 100644 index 00000000..1a230519 --- /dev/null +++ b/src/composable/index.test.ts @@ -0,0 +1,300 @@ +import { assertEquals, describe, it } from '../test-prelude.ts' +import { map, mapError, pipe, sequence } from './index.ts' +import type { Composable, ErrorWithMessage, Result } from './index.ts' +import { Equal, Expect } from './types.test.ts' +import { all, collect, λ } from './composable.ts' + +const voidFn = () => {} +const toString = (a: unknown) => `${a}` +const append = (a: string, b: string) => `${a}${b}` +const add = (a: number, b: number) => a + b +const asyncAdd = (a: number, b: number) => Promise.resolve(a + b) +const faultyAdd = (a: number, b: number) => { + if (a === 1) throw new Error('a is 1') + return a + b +} +const alwaysThrow = () => { + throw new Error('always throw', { cause: 'it was made for this' }) +} + +describe('composable', () => { + it('infers the types if has no arguments or return', async () => { + const fn = λ(() => {}) + const res = await fn() + + type _FN = Expect void>>> + type _R = Expect>> + + assertEquals(res, { success: true, data: undefined, errors: [] }) + }) + + it('infers the types if has arguments and a return', async () => { + const fn = λ(add) + const res = await fn(1, 2) + + type _FN = Expect< + Equal number>> + > + type _R = Expect>> + + assertEquals(res, { success: true, data: 3, errors: [] }) + }) + + it('infers the types of async functions', async () => { + const fn = λ(asyncAdd) + const res = await fn(1, 2) + + type _FN = Expect< + Equal number>> + > + type _R = Expect>> + + assertEquals(res, { success: true, data: 3, errors: [] }) + }) + + it('catch errors', async () => { + const fn = λ(faultyAdd) + const res = await fn(1, 2) + + type _FN = Expect< + Equal number>> + > + type _R = Expect>> + + assertEquals(res.success, false) + assertEquals(res.errors![0].message, 'a is 1') + }) +}) + +describe('pipe', () => { + it('sends the results of the first function to the second and infers types', async () => { + const fn = pipe(λ(add), λ(toString)) + const res = await fn(1, 2) + + type _FN = Expect< + Equal string>> + > + type _R = Expect>> + + assertEquals(res, { success: true, data: '3', errors: [] }) + }) + + it('catches the errors from function A', async () => { + const fn = pipe(λ(faultyAdd), λ(toString)) + const res = await fn(1, 2) + + type _FN = Expect< + Equal string>> + > + type _R = Expect>> + + assertEquals(res.success, false) + assertEquals(res.errors![0].message, 'a is 1') + }) + + it('catches the errors from function B', async () => { + const fn = pipe(λ(add), λ(alwaysThrow), λ(toString)) + // TODO this should not type check + const res = await fn(1, 2) + + // TODO this should be a type error + type _FN = Expect< + Equal string>> + > + type _R = Expect>> + + assertEquals(res.success, false) + assertEquals(res.errors![0].message, 'always throw') + assertEquals( + // deno-lint-ignore no-explicit-any + (res.errors[0] as any).exception?.cause, + 'it was made for this', + ) + }) +}) + +describe('sequence', () => { + it('sends the results of the first function to the second and saves every step of the result', async () => { + const fn = sequence(λ(add), λ(toString)) + const res = await fn(1, 2) + + type _FN = Expect< + Equal [number, string]>> + > + type _R = Expect>> + + assertEquals(res, { success: true, data: [3, '3'], errors: [] }) + }) + + it('catches the errors from function A', async () => { + const fn = sequence(λ(faultyAdd), λ(toString)) + const res = await fn(1, 2) + + type _FN = Expect< + Equal [number, string]>> + > + type _R = Expect>> + + assertEquals(res.success, false) + assertEquals(res.errors![0].message, 'a is 1') + }) +}) + +describe('all', () => { + it('executes all functions using the same input returning a tuple with every result when all are successful', async () => { + const fn = all(λ(add), λ(toString), λ(voidFn)) + + const res = await fn(1, 2) + + assertEquals(res, { success: true, data: [3, '1', undefined], errors: [] }) + }) +}) + +describe('collect', () => { + it('collects the results of an object of Composables into a result with same format', async () => { + const fn = collect({ + add: λ(add), + string: λ(toString), + void: λ(voidFn), + }) + const res = await fn(1, 2) + + type _FN = Expect< + Equal< + typeof fn, + Composable< + (...args: [] | [a: number, b: number] | [a: unknown]) => { + add: number + string: string + void: void + } + > + > + > + type _R = Expect< + Equal> + > + + assertEquals(res, { + success: true, + data: { add: 3, string: '1', void: undefined }, + errors: [], + }) + }) + + it('uses the same arguments for every function', async () => { + const fn = collect({ + add: λ(add), + string: λ(append), + }) + const res = await fn(1, 2) + + type _FN = Expect< + Equal< + typeof fn, + Composable< + (...args: [a: number, b: number] | [a: string, b: string]) => { + add: number + string: string + } + > + > + > + type _R = Expect>> + assertEquals(res, { + success: true, + data: { add: 3, string: '12' }, + errors: [], + }) + }) + + it('collects the errors in the error array', async () => { + const fn = collect({ + error1: λ(faultyAdd), + error2: λ(faultyAdd), + }) + const res = await fn(1, 2) + + type _FN = Expect< + Equal< + typeof fn, + Composable< + ( + a: number, + b: number, + ) => { + error1: number + error2: number + } + > + > + > + type _R = Expect< + Equal> + > + + assertEquals(res.success, false) + assertEquals(res.errors![0].message, 'a is 1') + assertEquals(res.errors![1].message, 'a is 1') + }) +}) + +describe('map', () => { + it('maps over an Composable function successful result', async () => { + const fn = map(λ(add), (a) => a + 1 === 4) + const res = await fn(1, 2) + + type _FN = Expect< + Equal boolean>> + > + type _R = Expect>> + + assertEquals(res, { success: true, data: true, errors: [] }) + }) + + it('maps over a composition', async () => { + const fn = map(pipe(λ(add), λ(toString)), (a) => typeof a === 'string') + const res = await fn(1, 2) + + type _FN = Expect< + Equal boolean>> + > + type _R = Expect>> + + assertEquals(res, { success: true, data: true, errors: [] }) + }) + + it('does not do anything when the function fails', async () => { + const fn = map(λ(faultyAdd), (a) => a + 1 === 4) + const res = await fn(1, 2) + + type _FN = Expect< + Equal boolean>> + > + type _R = Expect>> + + assertEquals(res.success, false) + assertEquals(res.errors![0].message, 'a is 1') + }) +}) + +const cleanError = (err: ErrorWithMessage) => ({ + message: err.message + '!!!', +}) +describe('mapError', () => { + it('maps over the error results of an Composable function', async () => { + const fn = mapError(λ(faultyAdd), ({ errors }) => ({ + errors: errors.map(cleanError), + })) + const res = await fn(1, 2) + + type _FN = Expect< + Equal number>> + > + type _R = Expect>> + + assertEquals(res.success, false) + assertEquals(res.errors![0].message, 'a is 1!!!') + }) +}) + diff --git a/src/composable/index.ts b/src/composable/index.ts new file mode 100644 index 00000000..5b4aef60 --- /dev/null +++ b/src/composable/index.ts @@ -0,0 +1,3 @@ +export type { Composable, Result, ErrorWithMessage } from './types.ts' +export { toErrorWithMessage } from './errors.ts' +export { composable, pipe, map, mapError, sequence } from './composable.ts' diff --git a/src/composable/types.test.ts b/src/composable/types.test.ts new file mode 100644 index 00000000..670ce9a6 --- /dev/null +++ b/src/composable/types.test.ts @@ -0,0 +1,69 @@ +// deno-lint-ignore-file ban-ts-comment no-namespace no-unused-vars +import { + assertEquals, + describe, + it, +} from '../test-prelude.ts' +import * as Subject from './types.ts' + +export type Expect = T +export type Equal = + // prettier is removing the parens thus worsening readability + // prettier-ignore + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) + ? true + : false + +namespace MergeObjs { + const obj1 = { a: 1, b: 2 } as const + const obj2 = {} + const obj3 = { c: 3, d: 4 } as const + + type Result = Subject.MergeObjs<[typeof obj1, typeof obj2, typeof obj3]> + + type test1 = Expect> + type test2 = Expect> +} + +namespace TupleToUnion { + type Result = Subject.TupleToUnion<[1, 2, 3]> + + type test = Expect> +} + +namespace Last { + type test1 = Expect, 3>> + type test2 = Expect, 1>> + type test3 = Expect, never>> +} + +namespace Prettify { + type test1 = Expect< + Equal< + Subject.Prettify<{ a: number } & { b: string }>, + { a: number; b: string } + > + > + type error1 = Expect< + // @ts-expect-error + Equal< + Subject.Prettify<{ a: number } & { b: string }>, + { a: number } & { b: string } + > + > +} + +namespace AtLeastOne { + type Result = Subject.AtLeastOne<{ a: 1; b: 2 }> + + const test1: Result = { a: 1 } + const test2: Result = { b: 2 } + const test3: Result = { a: 1, b: 2 } + // @ts-expect-error + const error1: Result = {} + // @ts-expect-error + const error2: Result = { a: 1, c: 3 } +} + +describe('type tests', () => + it('should have no ts errors', () => assertEquals(true, true))) diff --git a/src/composable/types.ts b/src/composable/types.ts new file mode 100644 index 00000000..246aaf90 --- /dev/null +++ b/src/composable/types.ts @@ -0,0 +1,100 @@ +/** + * Returns the last item of a tuple type. + * @example + * type MyTuple = [string, number] + * type Result = Last + * // ^? number + */ +type Last = T extends [...infer _I, infer L] + ? L + : never + +type First = T extends [infer F, ...infer _I] + ? F + : never +type ErrorWithMessage = { + message: string + exception?: unknown +} +type Failure = { + success: false, + errors: Array +} +type Success = { + success: true, + data: T, + errors: [] +} +type Result = Success | Failure + +type Fn = (...args: any[]) => any +type Composable = ( + ...args: Parameters +) => Promise>>> + +type UnpackResult = Awaited extends Result ? R : never + +type UnpackAll = { + [K in keyof List]: UnpackResult> +} + +/** + * Merges the data types of a list of objects. + * @example + * type MyObjs = [ + * { a: string }, + * { b: number }, + * ] + * type MyData = MergeObjs + * // ^? { a: string, b: number } + */ +type MergeObjs = Objs extends [ + infer first, + ...infer rest, +] + ? MergeObjs & first>> + : output + +type Prettify = { + [K in keyof T]: T[K] + // deno-lint-ignore ban-types +} & {} + +/** + * Converts a tuple type to a union type. + * @example + * type MyTuple = [string, number] + * type MyUnion = TupleToUnion + * // ^? string | number + */ +type TupleToUnion = T[number] + +/** + * It is similar to Partial but it requires at least one property to be defined. + * @example + * type MyType = AtLeastOne<{ a: string, b: number }> + * const a: MyType = { a: 'hello' } + * const b: MyType = { b: 123 } + * const c: MyType = { a: 'hello', b: 123 } + * // The following won't compile: + * const d: MyType = {} + */ +type AtLeastOne }> = Partial & U[keyof U] + + +export type { + AtLeastOne, + Composable, + ErrorWithMessage, + First, + Fn, + Last, + MergeObjs, + Prettify, + Result, + TupleToUnion, + UnpackAll, + UnpackResult, + Success, + Failure +} diff --git a/src/constructor.test.ts b/src/constructor.test.ts index 0db58c9a..44f60d28 100644 --- a/src/constructor.test.ts +++ b/src/constructor.test.ts @@ -4,9 +4,9 @@ import { describe, it, } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' -import { mdf } from './constructor.ts' +import { mdf, toComposable } from './constructor.ts' import { EnvironmentError, InputError, @@ -15,6 +15,42 @@ import { } from './errors.ts' import type { DomainFunction, SuccessResult } from './types.ts' import type { Equal, Expect } from './types.test.ts' +import { Composable } from './composable/index.ts' + +describe('toComposable', () => { + it('returns a Composable with the same computation and all input errors in errors field', async () => { + const handler = mdf(z.string())(() => 'no input!') + const c = toComposable(handler) + type _R = Expect< + Equal< + typeof c, + Composable<(input?: unknown, environment?: unknown) => string> + > + > + + assertEquals(await c(), { + success: false, + errors: [{ message: 'Required' }], + }) + }) + + it('returns a Composable with the same computation and same success result (we just care about the structural typing match)', async () => { + const handler = mdf()(() => 'no input!') + const c = toComposable(handler) + type _R = Expect< + Equal< + typeof c, + Composable<(input?: unknown, environment?: unknown) => string> + > + > + + assertObjectMatch(await c(), { + success: true, + data: 'no input!', + errors: [], + }) + }) +}) describe('makeDomainFunction', () => { describe('when it has no input', () => { @@ -300,3 +336,4 @@ describe('makeDomainFunction', () => { }) }) }) + diff --git a/src/constructor.ts b/src/constructor.ts index 08b7a8a5..693606f5 100644 --- a/src/constructor.ts +++ b/src/constructor.ts @@ -1,77 +1,29 @@ -import { - EnvironmentError, - InputError, - InputErrors, - ResultError, -} from './errors.ts' -import { schemaError, toErrorWithMessage } from './errors.ts' -import { formatSchemaErrors } from './utils.ts' -import type { DomainFunction, ParserSchema, Result } from './types.ts' +import { errorResultToFailure, failureToErrorResult } from './errors.ts' +import type { + DomainFunction, + ParserIssue, + ParserSchema, + SchemaError, +} from './types.ts' +import { Composable } from './composable/index.ts' +import { λ } from './composable/composable.ts' -/** - * A functions that turns the result of its callback into a Result object. - * @example - * const result = await safeResult(() => ({ - * message: 'hello', - * })) - * // the type of result is Result<{ message: string }> - * if (result.success) { - * console.log(result.data.message) - * } - * - * const result = await safeResult(() => { - * throw new Error('something went wrong') - * }) - * // the type of result is Result - * if (!result.success) { - * console.log(result.errors[0].message) - * } - */ -async function safeResult(fn: () => T): Promise> { - try { - return { - success: true, - data: await fn(), - errors: [], - inputErrors: [], - environmentErrors: [], - } - } catch (error) { - if (error instanceof InputError) { - return { - success: false, - errors: [], - environmentErrors: [], - inputErrors: [schemaError(error.message, error.path)], - } - } - if (error instanceof EnvironmentError) { - return { - success: false, - errors: [], - environmentErrors: [schemaError(error.message, error.path)], - inputErrors: [], - } - } - if (error instanceof InputErrors) { - return { - success: false, - errors: [], - environmentErrors: [], - inputErrors: error.errors.map((e) => schemaError(e.message, e.path)), - } - } - if (error instanceof ResultError) return error.result +function dfResultFromcomposable(fn: T) { + return (async (...args) => { + const r = await fn(...args) - return { - success: false, - errors: [toErrorWithMessage(error)], - inputErrors: [], - environmentErrors: [], - } - } + return r.success + ? { ...r, inputErrors: [], environmentErrors: [] } + : failureToErrorResult(r) + }) as Composable<(...args: Parameters) => R> } +function formatSchemaErrors(errors: ParserIssue[]): SchemaError[] { + return errors.map((error) => { + const { path, message } = error + return { path: path.map(String), message } + }) +} /** * Creates a domain function. * After giving the input and environment schemas, you can pass a handler function that takes type safe input and environment. That function is gonna catch any errors and always return a Result. @@ -92,31 +44,52 @@ function makeDomainFunction( environmentSchema?: ParserSchema, ) { return function (handler: (input: I, environment: E) => Output) { - return function (input, environment = {}) { - return safeResult(async () => { - const envResult = await ( - environmentSchema ?? objectSchema - ).safeParseAsync(environment) - const result = await (inputSchema ?? undefinedSchema).safeParseAsync( - input, - ) - - if (!result.success || !envResult.success) { - throw new ResultError({ - inputErrors: result.success - ? [] - : formatSchemaErrors(result.error.issues), - environmentErrors: envResult.success - ? [] - : formatSchemaErrors(envResult.error.issues), - }) - } - return handler(result.data as I, envResult.data as E) - }) - } as DomainFunction> + return fromComposable( + λ(handler), + inputSchema, + environmentSchema, + ) as DomainFunction> } } +function toComposable( + df: DomainFunction, +) { + return ((input = undefined, environment = {}) => + df(input, environment).then((r) => + r.success ? r : errorResultToFailure(r), + )) as unknown as Composable<(input?: I, environment?: E) => O> +} + +function fromComposable( + fn: A, + inputSchema?: ParserSchema, + environmentSchema?: ParserSchema, +) { + return async function (input, environment = {}) { + const envResult = await (environmentSchema ?? objectSchema).safeParseAsync( + environment, + ) + const result = await (inputSchema ?? undefinedSchema).safeParseAsync(input) + + if (!result.success || !envResult.success) { + return { + success: false, + errors: [], + inputErrors: result.success + ? [] + : formatSchemaErrors(result.error.issues), + environmentErrors: envResult.success + ? [] + : formatSchemaErrors(envResult.error.issues), + } + } + return dfResultFromcomposable(fn)( + ...([result.data as I, envResult.data as E] as Parameters), + ) + } as DomainFunction>> +} + const objectSchema: ParserSchema> = { safeParseAsync: (data: unknown) => { if (Object.prototype.toString.call(data) !== '[object Object]') { @@ -142,4 +115,11 @@ const undefinedSchema: ParserSchema = { }, } -export { makeDomainFunction, makeDomainFunction as mdf, safeResult } +export { + dfResultFromcomposable, + fromComposable, + makeDomainFunction, + makeDomainFunction as mdf, + toComposable, +} + diff --git a/src/domain-functions.ts b/src/domain-functions.ts index b05f4032..fef2eaf8 100644 --- a/src/domain-functions.ts +++ b/src/domain-functions.ts @@ -1,19 +1,59 @@ -import { ResultError } from './errors.ts' -import { isListOfSuccess, mergeObjects } from './utils.ts' +import { failureToErrorResult, ResultError } from './errors.ts' +import * as A from './composable/composable.ts' import type { DomainFunction, ErrorData, + Last, MergeObjs, Result, + SuccessResult, TupleToUnion, UnpackAll, UnpackData, UnpackDFObject, UnpackResult, } from './types.ts' -import type { Last } from './types.ts' -import type { SuccessResult } from './types.ts' -import { safeResult } from './constructor.ts' +import { dfResultFromcomposable } from './constructor.ts' +import { toErrorWithMessage } from './composable/errors.ts' + +/** + * A functions that turns the result of its callback into a Result object. + * @example + * const result = await safeResult(() => ({ + * message: 'hello', + * })) + * // the type of result is Result<{ message: string }> + * if (result.success) { + * console.log(result.data.message) + * } + * + * const result = await safeResult(() => { + * throw new Error('something went wrong') + * }) + * // the type of result is Result + * if (!result.success) { + * console.log(result.errors[0].message) + * } + */ +function safeResult(fn: () => T): Promise> { + return dfResultFromcomposable(A.λ(fn))() as Promise> +} + +/** + * Takes a function with 2 parameters and partially applies the second one. + * This is useful when one wants to use a domain function having a fixed environment. + * @example + * import { mdf, applyEnvironment } from 'domain-functions' + * + * const endOfDay = mdf(z.date(), z.object({ timezone: z.string() }))((date, { timezone }) => ...) + * const endOfDayUTC = applyEnvironment(endOfDay, { timezone: 'UTC' }) + * // ^? (input: unknown) => Promise> + */ +function applyEnvironment< + Fn extends (input: unknown, environment: unknown) => unknown, +>(df: Fn, environment: unknown) { + return (input: unknown) => df(input, environment) as ReturnType +} /** * Creates a single domain function out of multiple domain functions. It will pass the same input and environment to each provided function. The functions will run in parallel. If all constituent functions are successful, The data field will be a tuple containing each function's output. @@ -21,32 +61,19 @@ import { safeResult } from './constructor.ts' * import { mdf, all } from 'domain-functions' * * const a = mdf(z.object({ id: z.number() }))(({ id }) => String(id)) -const b = mdf(z.object({ id: z.number() }))(({ id }) => id + 1) -const c = mdf(z.object({ id: z.number() }))(({ id }) => Boolean(id)) -const df = all(a, b, c) -// ^? DomainFunction<[string, number, boolean]> + * const b = mdf(z.object({ id: z.number() }))(({ id }) => id + 1) + * const c = mdf(z.object({ id: z.number() }))(({ id }) => Boolean(id)) + * const df = all(a, b, c) +// ^? DomainFunction<[string, number, boolean]> */ function all( ...fns: Fns ): DomainFunction> { return ((input, environment) => { - return safeResult(async () => { - const results = await Promise.all( - fns.map((fn) => (fn as DomainFunction)(input, environment)), - ) - - if (!isListOfSuccess(results)) { - throw new ResultError({ - errors: results.map(({ errors }) => errors).flat(), - inputErrors: results.map(({ inputErrors }) => inputErrors).flat(), - environmentErrors: results - .map(({ environmentErrors }) => environmentErrors) - .flat(), - }) - } - - return results.map(({ data }) => data) - }) + const [first, ...rest] = fns.map((df) => + A.λ(() => fromSuccess(df)(input, environment)), + ) + return dfResultFromcomposable(A.all(first, ...rest))() }) as DomainFunction> } @@ -56,9 +83,9 @@ function all( * import { mdf, collect } from 'domain-functions' * * const a = mdf(z.object({}))(() => '1') -const b = mdf(z.object({}))(() => 2) -const df = collect({ a, b }) -// ^? DomainFunction<{ a: string, b: number }> + * const b = mdf(z.object({}))(() => 2) + * const df = collect({ a, b }) +// ^? DomainFunction<{ a: string, b: number }> */ function collect>( fns: Fns, @@ -66,7 +93,7 @@ function collect>( const dfsWithKey = Object.entries(fns).map(([key, df]) => map(df, (result) => ({ [key]: result })), ) - return map(all(...dfsWithKey), mergeObjects) as DomainFunction< + return map(all(...dfsWithKey), A.mergeObjects) as DomainFunction< UnpackDFObject > } @@ -119,7 +146,7 @@ function first( function merge>[]>( ...fns: Fns ): DomainFunction>> { - return map(all(...fns), mergeObjects) + return map(all(...fns), A.mergeObjects) } /** @@ -167,12 +194,12 @@ function collectSequence>( [keys[i]]: o, })), ), - mergeObjects, + A.mergeObjects, ) as DomainFunction> } /** - * Works like `pipe` but it will collect the output of every function in a tuple, similar to `all`. + * Works like `pipe` but it will collect the output of every function in a tuple. * @example * import { mdf, sequence } from 'domain-functions' * @@ -185,21 +212,10 @@ function sequence( ...fns: Fns ): DomainFunction> { return function (input: unknown, environment?: unknown) { - return safeResult(async () => { - const results = [] - let currResult: undefined | Result - for await (const fn of fns) { - const result = await fn( - currResult?.success ? currResult.data : input, - environment, - ) - if (!result.success) throw new ResultError(result) - currResult = result - results.push(result.data) - } - - return results - }) + const [first, ...rest] = fns.map((df) => + A.λ(fromSuccess(applyEnvironment(df, environment))), + ) + return dfResultFromcomposable(A.sequence(first, ...rest))(input) } as DomainFunction> } @@ -216,12 +232,13 @@ function map( dfn: DomainFunction, mapper: (element: O) => R, ): DomainFunction { - return async (input, environment) => { - const result = await dfn(input, environment) - if (!result.success) return result - - return safeResult(() => mapper(result.data)) - } + return ((input, environment) => + dfResultFromcomposable( + A.map( + A.λ(() => fromSuccess(dfn)(input, environment)), + mapper, + ), + )()) as DomainFunction } /** @@ -347,13 +364,20 @@ function trace>( ): (fn: DomainFunction) => DomainFunction { return (fn) => async (input, environment) => { const result = await fn(input, environment) - await traceFn({ input, environment, result } as TraceData>) - return result + try { + await traceFn({ input, environment, result } as TraceData< + UnpackResult + >) + return result + } catch (e) { + return failureToErrorResult(A.error([toErrorWithMessage(e)])) + } } } export { all, + applyEnvironment, branch, collect, collectSequence, @@ -363,6 +387,8 @@ export { mapError, merge, pipe, + safeResult, sequence, trace, } + diff --git a/src/errors.ts b/src/errors.ts index 8b7a76aa..d06b7d59 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,41 +1,12 @@ +import { Failure } from './composable/types.ts' import type { + AtLeastOne, + ErrorData, + ErrorResult, ErrorWithMessage, SchemaError, - ErrorResult, - ErrorData, - AtLeastOne, } from './types.ts' -function isErrorWithMessage(error: unknown): error is ErrorWithMessage { - return ( - typeof error === 'object' && - error !== null && - 'message' in error && - typeof (error as Record).message === 'string' - ) -} - -/** - * Turns the given 'unknown' error into an ErrorWithMessage. - * @param maybeError the error to turn into an ErrorWithMessage - * @returns the ErrorWithMessage - * @example - * try {} - * catch (error) { - * const errorWithMessage = toErrorWithMessage(error) - * console.log(errorWithMessage.message) - * } - */ -function toErrorWithMessage(maybeError: unknown): ErrorWithMessage { - const message = isErrorWithMessage(maybeError) - ? maybeError.message - : String(maybeError) - return { - message, - exception: maybeError, - } -} - /** * Creates a SchemaError (used in inputErrors and environmentErrors) from the given message and path. * @param message the error message @@ -127,12 +98,81 @@ class ResultError extends Error { } } +function schemaErrorToErrorWithMessage(se: SchemaError): ErrorWithMessage { + return { + message: `${se.path.join('.')} ${se.message}`.trim(), + } +} +function errorResultToFailure({ + errors, + inputErrors, + environmentErrors, +}: ErrorResult): Failure { + return { + success: false, + errors: [ + ...errors, + ...inputErrors.map(schemaErrorToErrorWithMessage), + ...environmentErrors.map(schemaErrorToErrorWithMessage), + ], + } +} + +function failureToErrorResult({ errors }: Failure): ErrorResult { + return { + success: false, + errors: errors + .filter( + ({ exception }) => + !( + exception instanceof InputError || + exception instanceof InputErrors || + exception instanceof EnvironmentError + ), + ) + .flatMap((e) => + e.exception instanceof ResultError ? e.exception.result.errors : e, + ), + inputErrors: errors.flatMap(({ exception }) => + exception instanceof InputError + ? [ + { + path: exception.path.split('.'), + message: exception.message, + }, + ] + : exception instanceof InputErrors + ? exception.errors.map((e) => ({ + path: e.path.split('.'), + message: e.message, + })) + : exception instanceof ResultError + ? exception.result.inputErrors + : [], + ), + environmentErrors: errors.flatMap(({ exception }) => + exception instanceof EnvironmentError + ? [ + { + path: exception.path.split('.'), + message: exception.message, + }, + ] + : exception instanceof ResultError + ? exception.result.environmentErrors + : [], + ), + } +} + export { + EnvironmentError, errorMessagesFor, - schemaError, - toErrorWithMessage, + errorResultToFailure, + failureToErrorResult, InputError, - EnvironmentError, InputErrors, ResultError, + schemaError, } + diff --git a/src/first.test.ts b/src/first.test.ts index 4815782a..c4b39084 100644 --- a/src/first.test.ts +++ b/src/first.test.ts @@ -1,5 +1,5 @@ import { describe, it, assertEquals } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { first } from './domain-functions.ts' diff --git a/src/from-success.test.ts b/src/from-success.test.ts index 4253fd9d..95e4e776 100644 --- a/src/from-success.test.ts +++ b/src/from-success.test.ts @@ -1,5 +1,5 @@ import { describe, it, assertEquals, assertRejects } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { fromSuccess } from './domain-functions.ts' diff --git a/src/index.ts b/src/index.ts index c5cb689c..122800ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,16 @@ -export { makeDomainFunction, mdf, safeResult } from './constructor.ts' +export { + fromComposable, + makeDomainFunction, + mdf, + toComposable, +} from './constructor.ts' export * from './domain-functions.ts' export * from './input-resolvers.ts' export * from './errors.ts' -export { mergeObjects } from './utils.ts' +export { mergeObjects } from './composable/composable.ts' +export type { Composable } from './composable/index.ts' +import * as composable from './composable/index.ts' +export { composable } export type { AtLeastOne, DomainFunction, @@ -23,3 +31,4 @@ export type { UnpackResult, UnpackSuccess, } from './types.ts' + diff --git a/src/map-error.test.ts b/src/map-error.test.ts index 6471a2cf..dc37dcb5 100644 --- a/src/map-error.test.ts +++ b/src/map-error.test.ts @@ -1,5 +1,5 @@ import { describe, it, assertEquals } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { mapError } from './domain-functions.ts' diff --git a/src/map.test.ts b/src/map.test.ts index 71f77012..a2eb09e5 100644 --- a/src/map.test.ts +++ b/src/map.test.ts @@ -1,5 +1,5 @@ import { describe, it, assertEquals } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { map } from './domain-functions.ts' diff --git a/src/merge.test.ts b/src/merge.test.ts index b5e625f2..be0414d7 100644 --- a/src/merge.test.ts +++ b/src/merge.test.ts @@ -4,7 +4,7 @@ import { assertEquals, assertObjectMatch, } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { merge } from './domain-functions.ts' diff --git a/src/pipe.test.ts b/src/pipe.test.ts index e11dd13e..756f0777 100644 --- a/src/pipe.test.ts +++ b/src/pipe.test.ts @@ -1,5 +1,5 @@ import { describe, it, assertEquals } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { pipe } from './domain-functions.ts' diff --git a/src/sequence.test.ts b/src/sequence.test.ts index ea6148b7..e6357426 100644 --- a/src/sequence.test.ts +++ b/src/sequence.test.ts @@ -1,5 +1,5 @@ import { describe, it, assertEquals } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { sequence } from './domain-functions.ts' diff --git a/src/test-prelude.ts b/src/test-prelude.ts index b356fac2..ce22a20b 100644 --- a/src/test-prelude.ts +++ b/src/test-prelude.ts @@ -1,2 +1,3 @@ -export { describe, it } from 'https://deno.land/std@0.198.0/testing/bdd.ts' -export { assertEquals, assertRejects, assertObjectMatch } from 'https://deno.land/std@0.198.0/assert/mod.ts' +export { describe, it } from "https://deno.land/std@0.206.0/testing/bdd.ts" +export { assertEquals, assertRejects, assertObjectMatch } from "https://deno.land/std@0.206.0/assert/mod.ts" +export { z } from "https://deno.land/x/zod@v3.22.4/mod.ts" diff --git a/src/trace.test.ts b/src/trace.test.ts index a06397ab..61dc90c8 100644 --- a/src/trace.test.ts +++ b/src/trace.test.ts @@ -1,5 +1,10 @@ -import { describe, it, assertEquals } from './test-prelude.ts' -import { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts' +import { + assertEquals, + assertObjectMatch, + describe, + it, +} from './test-prelude.ts' +import { z } from './test-prelude.ts' import { mdf } from './constructor.ts' import { fromSuccess, trace } from './domain-functions.ts' @@ -7,6 +12,24 @@ import type { DomainFunction } from './types.ts' import type { Equal, Expect } from './types.test.ts' describe('trace', () => { + it('converts trace exceptions to df failures', async () => { + const a = mdf(z.object({ id: z.number() }))(({ id }) => id + 1) + + const c = trace(() => { + throw new Error('Problem in tracing') + })(a) + type _R = Expect>> + + const result = await c({ id: 1 }) + + assertObjectMatch(result, { + success: false, + errors: [{ message: 'Problem in tracing' }], + inputErrors: [], + environmentErrors: [], + }) + }) + it('intercepts inputs and outputs of a given domain function', async () => { const a = mdf(z.object({ id: z.number() }))(({ id }) => id + 1) @@ -35,3 +58,4 @@ describe('trace', () => { }) }) }) + diff --git a/src/types.ts b/src/types.ts index 26565d31..6cd59769 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,18 +1,9 @@ -/** - * Items in the errors array returned by failed domain functions. - */ -type ErrorWithMessage = { - message: string - exception?: unknown -} +import { Failure, Success } from './composable/types.ts' /** * A successful domain function result. */ -type SuccessResult = { - success: true - data: T - errors: [] +type SuccessResult = Success & { inputErrors: [] environmentErrors: [] } @@ -20,9 +11,7 @@ type SuccessResult = { /** * A failed domain function result. */ -type ErrorResult = { - success: false - errors: ErrorWithMessage[] +type ErrorResult = Failure & { inputErrors: SchemaError[] environmentErrors: SchemaError[] } @@ -104,60 +93,6 @@ type UnpackDFObject> = | { [K in keyof Obj]: UnpackData } | never -/** - * Merges the data types of a list of objects. - * @example - * type MyObjs = [ - * { a: string }, - * { b: number }, - * ] - * type MyData = MergeObjs - * // ^? { a: string, b: number } - */ -type MergeObjs = Objs extends [ - infer first, - ...infer rest, -] - ? MergeObjs & first>> - : output - -type Prettify = { - [K in keyof T]: T[K] - // deno-lint-ignore ban-types -} & {} - -/** - * Converts a tuple type to a union type. - * @example - * type MyTuple = [string, number] - * type MyUnion = TupleToUnion - * // ^? string | number - */ -type TupleToUnion = T[number] - -/** - * Returns the last item of a tuple type. - * @example - * type MyTuple = [string, number] - * type Result = Last - * // ^? number - */ -type Last = T extends [...infer _I, infer L] - ? L - : never - -/** - * It is similar to Partial but it requires at least one property to be defined. - * @example - * type MyType = AtLeastOne<{ a: string, b: number }> - * const a: MyType = { a: 'hello' } - * const b: MyType = { b: 123 } - * const c: MyType = { a: 'hello', b: 123 } - * // The following won't compile: - * const d: MyType = {} - */ -type AtLeastOne }> = Partial & U[keyof U] - /** * A parsing error when validating the input or environment schemas. * This will be transformed into a `SchemaError` before being returned from the domain function. @@ -186,22 +121,25 @@ type ParserSchema = { export type { AtLeastOne, - DomainFunction, - ErrorData, - ErrorResult, ErrorWithMessage, Last, MergeObjs, + TupleToUnion, +} from './composable/types.ts' +export type { + DomainFunction, + ErrorData, + ErrorResult, ParserIssue, ParserResult, ParserSchema, Result, SchemaError, SuccessResult, - TupleToUnion, UnpackAll, UnpackData, UnpackDFObject, UnpackResult, UnpackSuccess, } + diff --git a/src/utils.test.ts b/src/utils.test.ts deleted file mode 100644 index c87a7eab..00000000 --- a/src/utils.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -// deno-lint-ignore-file ban-ts-comment no-namespace -import { describe, it, assertEquals } from './test-prelude.ts' -import * as subject from './utils.ts' -import type { Result, SuccessResult } from './types.ts' -import type { Equal, Expect } from './types.test.ts' - -namespace isListOfSuccess { - const results = [ - { - success: true, - data: true, - errors: [], - inputErrors: [], - environmentErrors: [], - } as Result, - ] - if (!subject.isListOfSuccess(results)) throw new Error('failing test') - - type test = Expect[]>> - // @ts-expect-error - type error = Expect[]>> -} - -describe('util tests', () => - it('should have no ts errors', () => assertEquals(true, true))) diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index bb0e187f..00000000 --- a/src/utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { MergeObjs, ParserIssue, Result, SchemaError, SuccessResult } from './types.ts' - -function formatSchemaErrors(errors: ParserIssue[]): SchemaError[] { - return errors.map((error) => { - const { path, message } = error - return { path: path.map(String), message } - }) -} - -function isListOfSuccess(result: Result[]): result is SuccessResult[] { - return result.every(({ success }) => success === true) -} - -/** - * Merges a list of objects into a single object. - * It is a type-safe version of Object.assign. - * @param objs the list of objects to merge - * @returns the merged object - * @example - * const obj1 = { a: 1, b: 2 } - * const obj2 = { c: 3 } - * const obj3 = { d: 4 } - * const merged = mergeObjects([obj1, obj2, obj3]) - * // ^? { a: number, b: number, c: number, d: number } - */ -function mergeObjects(objs: T) { - return Object.assign({}, ...objs) as MergeObjs -} - -export { formatSchemaErrors, mergeObjects, isListOfSuccess }