From 8969812d7e993070b18e89c39fed4d6df00a6935 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Fri, 16 Jan 2026 19:27:15 +0100 Subject: [PATCH 1/2] feat(ensnode-sdk): define a generic `Result` type --- packages/ensnode-sdk/src/shared/index.ts | 1 + .../ensnode-sdk/src/shared/result/index.ts | 2 + .../ensnode-sdk/src/shared/result/types.ts | 63 ++++++ .../src/shared/result/utils.test.ts | 198 ++++++++++++++++++ .../ensnode-sdk/src/shared/result/utils.ts | 95 +++++++++ 5 files changed, 359 insertions(+) create mode 100644 packages/ensnode-sdk/src/shared/result/index.ts create mode 100644 packages/ensnode-sdk/src/shared/result/types.ts create mode 100644 packages/ensnode-sdk/src/shared/result/utils.test.ts create mode 100644 packages/ensnode-sdk/src/shared/result/utils.ts diff --git a/packages/ensnode-sdk/src/shared/index.ts b/packages/ensnode-sdk/src/shared/index.ts index e628311f5..99b64f257 100644 --- a/packages/ensnode-sdk/src/shared/index.ts +++ b/packages/ensnode-sdk/src/shared/index.ts @@ -10,6 +10,7 @@ export * from "./interpretation"; export * from "./labelhash"; export * from "./null-bytes"; export * from "./numbers"; +export * from "./result"; export * from "./root-registry"; export * from "./serialize"; export * from "./serialized-types"; diff --git a/packages/ensnode-sdk/src/shared/result/index.ts b/packages/ensnode-sdk/src/shared/result/index.ts new file mode 100644 index 000000000..9e034905e --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/index.ts @@ -0,0 +1,2 @@ +export * from "./types"; +export * from "./utils"; diff --git a/packages/ensnode-sdk/src/shared/result/types.ts b/packages/ensnode-sdk/src/shared/result/types.ts new file mode 100644 index 000000000..6473f3c86 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/types.ts @@ -0,0 +1,63 @@ +/** + * This module defines a standardized way to represent the outcome of operations, + * encapsulating both successful results and error results. + */ + +/** + * Possible Result Codes. + */ +export const ResultCodes = { + Ok: "ok", + Error: "error", +} as const; + +export type ResultCode = (typeof ResultCodes)[keyof typeof ResultCodes]; + +/** + * Value type useful for `ResultOk` type. + */ +export interface ResultOkValue { + valueCode: ValueCodeType; +} + +/** + * Result Ok returned by a successful operation call. + */ +export interface ResultOk { + resultCode: typeof ResultCodes.Ok; + value: ValueType; +} + +/** + * Value type useful for `ResultError` type. + */ +export interface ResultErrorValue { + errorCode: ErrorCodeType; +} + +/** + * Result Error returned by a failed operation call. + */ +export interface ResultError { + resultCode: typeof ResultCodes.Error; + value: ErrorType; +} + +/** + * Result returned by an operation. + * + * Guarantees: + * - `resultCode` indicates if operation succeeded or failed. + * - `value` describes the outcome of the operation, for example + * - {@link ResultOkValue} for successful operation call. + * - {@link ResultErrorValue} for failed operation call. + */ +export type Result = ResultOk | ResultError; + +/** + * Type for marking error as a transient one. + * + * It's useful for downstream consumers to know, so they can attempt fetching + * the result once again. + */ +export type ErrorTransient = ErrorType & { transient: true }; diff --git a/packages/ensnode-sdk/src/shared/result/utils.test.ts b/packages/ensnode-sdk/src/shared/result/utils.test.ts new file mode 100644 index 000000000..008ca377d --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/utils.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from "vitest"; + +import type { ErrorTransient, Result, ResultError, ResultOk } from "./types"; +import { + errorTransient, + isErrorTransient, + isResult, + isResultError, + isResultOk, + resultError, + resultOk, +} from "./utils"; + +describe("Result type", () => { + describe("Developer Experience", () => { + // Correct value codes that the test operation can return. + const TestOpValueCodes = { Found: "FOUND", NotFound: "NOT_FOUND" } as const; + + // Example of result ok: no records were found for the given request + interface TestOpResultOkNotFound { + valueCode: typeof TestOpValueCodes.NotFound; + } + + // Example of result ok: some records were found for the given request + interface TestOpResultFound { + valueCode: typeof TestOpValueCodes.Found; + records: string[]; + } + + // Union type collecting all ResultOk subtypes + type TestOpResultOk = TestOpResultOkNotFound | TestOpResultFound; + + // Error codes that the test operation can return. + const TestOpErrorCodes = { + InvalidRequest: "INVALID_REQUEST", + TransientIssue: "TRANSIENT_ISSUE", + } as const; + + // Example of result error: invalid request + interface TestOpResultErrorInvalidRequest { + errorCode: typeof TestOpErrorCodes.InvalidRequest; + message: string; + } + + // Example of result error: transient issue, simulates ie. indexing status not ready + type TestOpResultErrorTransientIssue = ErrorTransient<{ + errorCode: typeof TestOpErrorCodes.TransientIssue; + }>; + + // Union type collecting all ResultError subtypes + type TestOpError = TestOpResultErrorInvalidRequest | TestOpResultErrorTransientIssue; + + // Result type for test operation + type TestOpResult = Result; + + interface TestOperationParams { + name: string; + simulate?: { + transientIssue?: boolean; + }; + } + + // An example of operation returning a Result object + function testOperation(params: TestOperationParams): TestOpResult { + // Check if need to simulate transient server issue + if (params.simulate?.transientIssue) { + return resultError( + errorTransient({ + errorCode: TestOpErrorCodes.TransientIssue, + }), + ) satisfies ResultError; + } + + // Check if request is valid + if (params.name.endsWith(".eth") === false) { + return resultError({ + errorCode: TestOpErrorCodes.InvalidRequest, + message: `Invalid request, 'name' must end with '.eth'. Provided name: '${params.name}'.`, + }) satisfies ResultError; + } + + // Check if requested name has any records indexed + if (params.name !== "vitalik.eth") { + return resultOk({ + valueCode: TestOpValueCodes.NotFound, + }) satisfies ResultOk; + } + + // Return records found for the requested name + return resultOk({ + valueCode: TestOpValueCodes.Found, + records: ["a", "b", "c"], + }) satisfies ResultOk; + } + + // Example ResultOk values + const testOperationResultOkFound = testOperation({ + name: "vitalik.eth", + }); + + const testOperationResultOkNotFound = testOperation({ + name: "test.eth", + }); + + // Example ResultError values + const testOperationResultErrorTransientIssue = testOperation({ + name: "vitalik.eth", + simulate: { + transientIssue: true, + }, + }); + + const testOperationResultErrorInvalidRequest = testOperation({ + name: "test.xyz", + }); + + // Example values that are instances of Result type + const results = [ + testOperationResultOkFound, + testOperationResultOkNotFound, + testOperationResultErrorTransientIssue, + testOperationResultErrorInvalidRequest, + ]; + // Example values that are not instances of Result type + const notResults = [null, undefined, 42, "invalid", {}, { resultCode: "unknown" }]; + + describe("Type Guards", () => { + it("should identify Result types correctly", () => { + for (const maybeResult of results) { + expect(isResult(maybeResult)).toBe(true); + } + + for (const maybeResult of notResults) { + expect(isResult(maybeResult)).toBe(false); + } + }); + + it("should identify ResultOk types correctly", () => { + expect(isResultOk(testOperationResultOkFound)).toBe(true); + expect(isResultOk(testOperationResultOkNotFound)).toBe(true); + expect(isResultOk(testOperationResultErrorTransientIssue)).toBe(false); + expect(isResultOk(testOperationResultErrorInvalidRequest)).toBe(false); + + for (const resultOkExample of results.filter((result) => isResultOk(result))) { + const { value } = resultOkExample; + + switch (value.valueCode) { + case TestOpValueCodes.Found: + expect(value).toStrictEqual({ + valueCode: TestOpValueCodes.Found, + records: ["a", "b", "c"], + } satisfies TestOpResultFound); + break; + + case TestOpValueCodes.NotFound: + expect(value).toStrictEqual({ + valueCode: TestOpValueCodes.NotFound, + } satisfies TestOpResultOkNotFound); + break; + } + } + }); + + it("should identify ResultError types correctly", () => { + expect(isResultError(testOperationResultOkFound)).toBe(false); + expect(isResultError(testOperationResultOkNotFound)).toBe(false); + expect(isResultError(testOperationResultErrorTransientIssue)).toBe(true); + expect(isResultError(testOperationResultErrorInvalidRequest)).toBe(true); + + for (const resultErrorExample of results.filter((result) => isResultError(result))) { + const { value } = resultErrorExample; + + switch (value.errorCode) { + case TestOpErrorCodes.InvalidRequest: + expect(value).toStrictEqual({ + errorCode: TestOpErrorCodes.InvalidRequest, + message: "Invalid request, 'name' must end with '.eth'. Provided name: 'test.xyz'.", + } satisfies TestOpResultErrorInvalidRequest); + break; + + case TestOpErrorCodes.TransientIssue: + expect(value).toMatchObject( + errorTransient({ + errorCode: TestOpErrorCodes.TransientIssue, + }) satisfies TestOpResultErrorTransientIssue, + ); + break; + } + } + }); + + it("should distinguish transient errors correctly", () => { + expect(isErrorTransient(testOperationResultErrorTransientIssue.value)).toBe(true); + expect(isErrorTransient(testOperationResultErrorInvalidRequest.value)).toBe(false); + }); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/result/utils.ts b/packages/ensnode-sdk/src/shared/result/utils.ts new file mode 100644 index 000000000..105f59175 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/utils.ts @@ -0,0 +1,95 @@ +/** + * This file defines utilities for working with the Result generic type. + * Functionalities should be use to enhance developer experience while + * interacting with ENSNode APIs. + */ + +import { + type ErrorTransient, + type Result, + ResultCodes, + type ResultError, + type ResultErrorValue, + type ResultOk, + type ResultOkValue, +} from "./types"; + +/** + * Build a Result Ok from provided `data`. + * + * Requires `data` to include the `valueCode` property + * It enables the consumer of a Result object to identify `data` the result hold. + */ +export function resultOk>( + value: OkType, +): ResultOk { + return { + resultCode: ResultCodes.Ok, + value, + }; +} + +/** + * Is a result an instance of ResultOk? + */ +export function isResultOk( + result: Pick, "resultCode">, +): result is ResultOk { + return result.resultCode === ResultCodes.Ok; +} + +/** + * Build a Result Error from provided `error`. + * + * Requires `error` to include the `errorCode` property + * It enables the consumer of a Result object to identify `error` the result hold. + */ +export function resultError< + const ErrorValueType, + ErrorType extends ResultErrorValue, +>(value: ErrorType): ResultError { + return { + resultCode: ResultCodes.Error, + value, + }; +} + +/** + * Is a result error? + */ +export function isResultError( + result: Pick, "resultCode">, +): result is ResultError { + return result.resultCode === ResultCodes.Error; +} + +/** + * Is value an instance of a result type? + */ +export function isResult(value: unknown): value is Result { + return ( + typeof value === "object" && + value !== null && + "resultCode" in value && + (value.resultCode === ResultCodes.Ok || value.resultCode === ResultCodes.Error) + ); +} + +/** + * Build a new instance of `error` and mark it as transient. + * + * This "mark" informs downstream consumer about the transient nature of + * the error. + */ +export function errorTransient(error: ErrorType): ErrorTransient { + return { ...error, transient: true }; +} + +/** + * Is error a transient one? + */ +export function isErrorTransient(error: ErrorType): error is ErrorTransient { + return ( + typeof error === "object" && error !== null && "transient" in error && error.transient === true + ); +} From ce0e7545b3c593ee1790c94a7a243903a80c0aed Mon Sep 17 00:00:00 2001 From: "lightwalker.eth" <126201998+lightwalker-eth@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:27:39 +0200 Subject: [PATCH 2/2] suggest "generic result" refinements (#1541) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Tomasz Kopacki --- .../src/shared/result/examples/dx-client.ts | 31 ++ .../src/shared/result/examples/dx-hook.ts | 33 ++ .../src/shared/result/examples/op-client.ts | 56 ++++ .../src/shared/result/examples/op-hook.ts | 34 ++ .../src/shared/result/examples/op-server.ts | 70 ++++ .../shared/result/examples/server-router.ts | 34 ++ .../ensnode-sdk/src/shared/result/index.ts | 5 +- .../src/shared/result/result-base.ts | 69 ++++ .../src/shared/result/result-code.ts | 143 ++++++++ .../src/shared/result/result-common.test.ts | 313 ++++++++++++++++++ .../src/shared/result/result-common.ts | 176 ++++++++++ .../ensnode-sdk/src/shared/result/types.ts | 63 ---- .../src/shared/result/utils.test.ts | 198 ----------- .../ensnode-sdk/src/shared/result/utils.ts | 95 ------ 14 files changed, 962 insertions(+), 358 deletions(-) create mode 100644 packages/ensnode-sdk/src/shared/result/examples/dx-client.ts create mode 100644 packages/ensnode-sdk/src/shared/result/examples/dx-hook.ts create mode 100644 packages/ensnode-sdk/src/shared/result/examples/op-client.ts create mode 100644 packages/ensnode-sdk/src/shared/result/examples/op-hook.ts create mode 100644 packages/ensnode-sdk/src/shared/result/examples/op-server.ts create mode 100644 packages/ensnode-sdk/src/shared/result/examples/server-router.ts create mode 100644 packages/ensnode-sdk/src/shared/result/result-base.ts create mode 100644 packages/ensnode-sdk/src/shared/result/result-code.ts create mode 100644 packages/ensnode-sdk/src/shared/result/result-common.test.ts create mode 100644 packages/ensnode-sdk/src/shared/result/result-common.ts delete mode 100644 packages/ensnode-sdk/src/shared/result/types.ts delete mode 100644 packages/ensnode-sdk/src/shared/result/utils.test.ts delete mode 100644 packages/ensnode-sdk/src/shared/result/utils.ts diff --git a/packages/ensnode-sdk/src/shared/result/examples/dx-client.ts b/packages/ensnode-sdk/src/shared/result/examples/dx-client.ts new file mode 100644 index 000000000..03fa1f445 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/dx-client.ts @@ -0,0 +1,31 @@ +/** + * Example of a simple client-side application client that calls an operation + * returning Result data model. + * + * In a real-world scenario, this could be part of a frontend application + * calling a client to send a request to a backend service and handle + * the response. + * + * In this example, we show how to handle both successful and error results + * returned by the operation. This includes a retry suggestion for + * certain error cases. + */ +import type { Address } from "viem"; + +import { ResultCodes } from "../result-code"; +import { callExampleOp } from "./op-client"; + +export const myExampleDXClient = (address: Address): void => { + const result = callExampleOp(address); + + if (result.resultCode === ResultCodes.Ok) { + // NOTE: Here the type system knows that `result` is of type `ResultExampleOpOk` + console.log(result.data.name); + } else { + // NOTE: Here the type system knows that `result` has fields for `errorMessage` and `suggestRetry` + console.error(`Error: (${result.resultCode}) - ${result.errorMessage}`); + if (result.suggestRetry) { + console.log("Try again?"); + } + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/examples/dx-hook.ts b/packages/ensnode-sdk/src/shared/result/examples/dx-hook.ts new file mode 100644 index 000000000..c21482d65 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/dx-hook.ts @@ -0,0 +1,33 @@ +/** + * Example of a simple client-side DX hook that consumes an operation + * returning Result data model. + * + * In a real-world scenario, this could be part of a React component + * calling a hook to manage async data fetching. + * + * In this example, we show how to handle both successful and error results + * returned by the operation. This includes a retry suggestion for + * certain error cases. + */ +import type { Address } from "viem"; + +import { ResultCodes } from "../result-code"; +import { useExampleOp } from "./op-hook"; + +export const myExampleDXHook = (address: Address): void => { + const result = useExampleOp(address); + + if (result.resultCode === ResultCodes.Loading) { + // NOTE: Here the type system knows that `result` is of type `ResultExampleOpLoading` + console.log("Loading..."); + } else if (result.resultCode === ResultCodes.Ok) { + // NOTE: Here the type system knows that `result` is of type `ResultExampleOpOk` + console.log(result.data.name); + } else { + // NOTE: Here the type system knows that `result` has fields for `errorMessage` and `suggestRetry` + console.error(`Error: (${result.resultCode}) - ${result.errorMessage}`); + if (result.suggestRetry) { + console.log("Try again?"); + } + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/examples/op-client.ts b/packages/ensnode-sdk/src/shared/result/examples/op-client.ts new file mode 100644 index 000000000..4d0d4be5b --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/op-client.ts @@ -0,0 +1,56 @@ +/** + * Example of a simple client-side operation that calls a server operation + * and returns Result data model. + * + * Note: In a real-world scenario, this would involve making an HTTP request + * to a server endpoint. Here, for simplicity, we directly call the server + * operation function. + * + * We also simulate client-side errors like connection errors and timeouts. + * + * If the server returns a result code that is not recognized by this client + * version, the client handles it by returning a special unrecognized operation + * result. + */ +import type { Address } from "viem"; + +import { + buildResultClientUnrecognizedOperationResult, + buildResultConnectionError, + buildResultRequestTimeout, + isRecognizedResultCodeForOperation, + type ResultClientError, +} from "../result-common"; +import { + EXAMPLE_OP_RECOGNIZED_SERVER_RESULT_CODES, + type ExampleOpServerResult, + exampleOp, +} from "./op-server"; + +export type ExampleOpClientResult = ExampleOpServerResult | ResultClientError; + +export const callExampleOp = (address: Address): ExampleOpClientResult => { + try { + const result = exampleOp(address); + + // ensure server result code is recognized by this client version + if ( + !isRecognizedResultCodeForOperation( + result.resultCode, + EXAMPLE_OP_RECOGNIZED_SERVER_RESULT_CODES, + ) + ) { + return buildResultClientUnrecognizedOperationResult(result); + } + + // return server result + return result; + } catch (error) { + // handle client-side errors + if (error === "connection-error") { + return buildResultConnectionError(); + } else { + return buildResultRequestTimeout(); + } + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/examples/op-hook.ts b/packages/ensnode-sdk/src/shared/result/examples/op-hook.ts new file mode 100644 index 000000000..e84b58b86 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/op-hook.ts @@ -0,0 +1,34 @@ +/** + * Example of a simple client-side operation hook that returns + * Result data model with Loading state. + */ +import type { Address } from "viem"; + +import type { AbstractResultLoading } from "../result-base"; +import { ResultCodes } from "../result-code"; +import { callExampleOp, type ExampleOpClientResult } from "./op-client"; + +export interface ExampleOpLoadingData { + address: Address; +} + +export interface ResultExampleOpLoading extends AbstractResultLoading {} + +export const buildResultExampleOpLoading = (address: Address): ResultExampleOpLoading => { + return { + resultCode: ResultCodes.Loading, + data: { + address, + }, + }; +}; + +export type ExampleOpHookResult = ExampleOpClientResult | ResultExampleOpLoading; + +export const useExampleOp = (address: Address): ExampleOpHookResult => { + if (Math.random() < 0.5) { + return buildResultExampleOpLoading(address); + } else { + return callExampleOp(address); + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/examples/op-server.ts b/packages/ensnode-sdk/src/shared/result/examples/op-server.ts new file mode 100644 index 000000000..3f58ceb0f --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/op-server.ts @@ -0,0 +1,70 @@ +/** + * Example of a simple server-side operation that returns Result data model. + * + * In a real-world scenario, this could be part of a backend service + * handling requests and returning structured responses. + * + * In this example, we show how to return both successful and error results + * based on input conditions. + */ +import type { Address } from "viem"; +import { zeroAddress } from "viem"; + +import type { AbstractResultOk } from "../result-base"; +import { type AssertResultCodeExact, type ExpectTrue, ResultCodes } from "../result-code"; +import { + buildResultInternalServerError, + buildResultInvalidRequest, + type ResultInternalServerError, + type ResultInvalidRequest, +} from "../result-common"; + +export interface ResultExampleOpOkData { + name: string; +} + +export interface ResultExampleOpOk extends AbstractResultOk {} + +export const buildResultExampleOpOk = (name: string): ResultExampleOpOk => { + return { + resultCode: ResultCodes.Ok, + data: { + name, + }, + }; +}; + +// NOTE: Here we define a union of all possible results returned by the server for this operation. +// We specifically call these "Server Results" because later we need to add all the possible client error results to get +// the full set of all results a client can receive from this operation. +export type ExampleOpServerResult = + | ResultExampleOpOk + | ResultInternalServerError + | ResultInvalidRequest; + +export type ExampleOpServerResultCode = ExampleOpServerResult["resultCode"]; + +export const EXAMPLE_OP_RECOGNIZED_SERVER_RESULT_CODES = [ + ResultCodes.Ok, + ResultCodes.InternalServerError, + ResultCodes.InvalidRequest, +] as const satisfies readonly ExampleOpServerResultCode[]; + +// Intentionally unused: compile-time assertion that the recognized result codes +// exactly match the union of ExampleOpServerResult["resultCode"]. +type _AssertExampleOpServerResultCodesMatch = ExpectTrue< + AssertResultCodeExact +>; + +export const exampleOp = (address: Address): ExampleOpServerResult => { + if (address === zeroAddress) { + return buildResultInvalidRequest("Address must not be the zero address"); + } + if (Math.random() < 0.5) { + return buildResultExampleOpOk("example.eth"); + } else { + return buildResultInternalServerError( + "Invariant violation: random number is not less than 0.5", + ); + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/examples/server-router.ts b/packages/ensnode-sdk/src/shared/result/examples/server-router.ts new file mode 100644 index 000000000..87a0e880e --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/server-router.ts @@ -0,0 +1,34 @@ +/** + * Example of a simple server-side router handling requests and + * returning Result data model. + * + * In a real-world scenario, this could be part of a backend service + * using a framework like Hono to route requests and return structured + * responses. + * + * In this example, we show how different results are returned + * based on the request path, including delegating to an operation + * that also returns Result data model. + */ +import type { Address } from "viem"; + +import type { AbstractResult } from "../result-base"; +import type { ResultCode } from "../result-code"; +import { buildResultInternalServerError, buildResultNotFound } from "../result-common"; +import { exampleOp } from "./op-server"; + +const _routeRequest = (path: string): AbstractResult => { + // imagine Hono router logic here + try { + if (path === "/example") { + return exampleOp("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" as Address); + } else { + // guarantee in all cases we return our Result data model + return buildResultNotFound(`Path not found: ${path}`); + } + } catch (error) { + // guarantee in all cases we return our Result data model + const errorMessage = error instanceof Error ? error.message : undefined; + return buildResultInternalServerError(errorMessage); + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/index.ts b/packages/ensnode-sdk/src/shared/result/index.ts index 9e034905e..70fbbd582 100644 --- a/packages/ensnode-sdk/src/shared/result/index.ts +++ b/packages/ensnode-sdk/src/shared/result/index.ts @@ -1,2 +1,3 @@ -export * from "./types"; -export * from "./utils"; +export * from "./result-base"; +export * from "./result-code"; +export * from "./result-common"; diff --git a/packages/ensnode-sdk/src/shared/result/result-base.ts b/packages/ensnode-sdk/src/shared/result/result-base.ts new file mode 100644 index 000000000..7f0477ef8 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/result-base.ts @@ -0,0 +1,69 @@ +import type { + ResultCode, + ResultCodeClientError, + ResultCodeServerError, + ResultCodes, +} from "./result-code"; + +/************************************************************ + * Abstract results + * + * These are base interfaces that should be extended to + * create concrete result types. + ************************************************************/ + +/** + * Abstract representation of any result. + */ +export interface AbstractResult { + /** + * The classification of the result. + */ + resultCode: TResultCode; +} + +/** + * Abstract representation of a successful result. + */ +export interface AbstractResultOk extends AbstractResult { + /** + * The data of the result. + */ + data: TDataType; +} + +/** + * Abstract representation of an error result. + */ +export interface AbstractResultError< + TResultCode extends ResultCodeServerError | ResultCodeClientError, + TDataType = undefined, +> extends AbstractResult { + /** + * A description of the error. + */ + errorMessage: string; + + /** + * Identifies if it may be relevant to retry the operation. + * + * If `false`, retrying the operation is unlikely to be helpful. + */ + suggestRetry: boolean; + + /** + * Optional data associated with the error. + */ + data?: TDataType; +} + +/** + * Abstract representation of a loading result. + */ +export interface AbstractResultLoading + extends AbstractResult { + /** + * Optional data associated with the loading operation. + */ + data?: TDataType; +} diff --git a/packages/ensnode-sdk/src/shared/result/result-code.ts b/packages/ensnode-sdk/src/shared/result/result-code.ts new file mode 100644 index 000000000..52e376ea9 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/result-code.ts @@ -0,0 +1,143 @@ +/** + * Classifies a result returned by an operation. + */ +export const ResultCodes = { + /** + * Indefinite result. + */ + Loading: "loading", + + /** + * Successful result. + */ + Ok: "ok", + + /** + * Server error: the operation failed due to an unexpected error internally within the server. + */ + InternalServerError: "internal-server-error", + + /** + * Server error: the requested resource was not found. + */ + NotFound: "not-found", + + /** + * Server error: the request was invalid. + */ + InvalidRequest: "invalid-request", + + /** + * Client error: the connection to the server failed. + */ + ConnectionError: "connection-error", + + /** + * Client error: the request timed out. + */ + RequestTimeout: "request-timeout", + + /** + * Client error: received an unrecognized result from the server for an operation. + */ + ClientUnrecognizedOperationResult: "client-unrecognized-operation-result", +} as const; + +/** + * List of ResultCodes that represent server error results. + */ +export const RESULT_CODE_SERVER_ERROR_CODES = [ + ResultCodes.InternalServerError, + ResultCodes.NotFound, + ResultCodes.InvalidRequest, +] as const; + +/** + * List of ResultCodes that represent client error results. + */ +export const RESULT_CODE_CLIENT_ERROR_CODES = [ + ResultCodes.ConnectionError, + ResultCodes.RequestTimeout, + ResultCodes.ClientUnrecognizedOperationResult, +] as const; + +/** + * List of all error codes the client can return (client-originated + relayed from server). + */ +export const RESULT_CODE_ALL_ERROR_CODES = [ + ...RESULT_CODE_CLIENT_ERROR_CODES, + ...RESULT_CODE_SERVER_ERROR_CODES, +] as const; + +/** + * List of all ResultCodes. + */ +export const RESULT_CODE_ALL_CODES = [ + ResultCodes.Loading, + ResultCodes.Ok, + ...RESULT_CODE_ALL_ERROR_CODES, +] as const; + +/** + * Classifies a result returned by an operation. + */ +export type ResultCode = (typeof ResultCodes)[keyof typeof ResultCodes]; + +/** + * ResultCode for a result that is not yet determined. + */ +export type ResultCodeIndefinite = typeof ResultCodes.Loading; + +/** + * ResultCode for a result that has been determined. + */ +export type ResultCodeDefinite = Exclude; + +/** + * ResultCode for an error result that may be determined by the server. + */ +export type ResultCodeServerError = (typeof RESULT_CODE_SERVER_ERROR_CODES)[number]; + +/** + * ResultCode for an error result that may be determined by the client. + */ +export type ResultCodeClientError = (typeof RESULT_CODE_CLIENT_ERROR_CODES)[number]; + +/************************************************************ + * Compile-time helpers to ensure invariants expected of + * definitions above are maintained and don't become + * out of sync. + ************************************************************/ + +export type ExpectTrue = T; + +export type ResultCodesFromList = List[number]; + +export type AssertResultCodeSuperset< + Union extends ResultCode, + List extends readonly ResultCode[], +> = Union extends ResultCodesFromList ? true : false; + +export type AssertResultCodeSubset< + Union extends ResultCode, + List extends readonly ResultCode[], +> = ResultCodesFromList extends Union ? true : false; + +export type AssertResultCodeExact< + Union extends ResultCode, + List extends readonly ResultCode[], +> = AssertResultCodeSuperset extends true + ? AssertResultCodeSubset extends true + ? true + : false + : false; + +/** + * Intentionally unused type alias used only for compile-time verification that + * the `ResultCode` union exactly matches the entries in `RESULT_CODE_ALL_CODES`. + * If this type ever fails to compile, it indicates that one of the above + * invariants has been broken and the result code definitions are out of sync. + */ +type _CompileTimeCheck_ResultCodeMatchesUnion = ExpectTrue< + AssertResultCodeExact +>; diff --git a/packages/ensnode-sdk/src/shared/result/result-common.test.ts b/packages/ensnode-sdk/src/shared/result/result-common.test.ts new file mode 100644 index 000000000..d5069326c --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/result-common.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from "vitest"; + +import { ResultCodes } from "./result-code"; +import { + buildResultClientUnrecognizedOperationResult, + buildResultConnectionError, + buildResultInternalServerError, + buildResultInvalidRequest, + buildResultNotFound, + buildResultRequestTimeout, + isRecognizedResultCodeForOperation, +} from "./result-common"; + +describe("Result Error Builders", () => { + describe("buildResultInternalServerError", () => { + it("should build an internal server error with custom message", () => { + const result = buildResultInternalServerError("Database connection failed"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InternalServerError, + errorMessage: "Database connection failed", + suggestRetry: true, + }); + }); + + it("should build an internal server error with default message when not provided", () => { + const result = buildResultInternalServerError(); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InternalServerError, + errorMessage: "An unknown internal server error occurred.", + suggestRetry: true, + }); + }); + + it("should respect suggestRetry parameter", () => { + const result = buildResultInternalServerError("Error", false); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InternalServerError, + errorMessage: "Error", + suggestRetry: false, + }); + }); + }); + + describe("buildResultNotFound", () => { + it("should build a not found error with custom message", () => { + const result = buildResultNotFound("User not found"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.NotFound, + errorMessage: "User not found", + suggestRetry: false, + }); + }); + + it("should build a not found error with default message when not provided", () => { + const result = buildResultNotFound(); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.NotFound, + errorMessage: "Requested resource not found.", + suggestRetry: false, + }); + }); + + it("should allow retry suggestion for not found errors", () => { + const result = buildResultNotFound("Not found", true); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.NotFound, + errorMessage: "Not found", + suggestRetry: true, + }); + }); + }); + + describe("buildResultInvalidRequest", () => { + it("should build an invalid request error with custom message", () => { + const result = buildResultInvalidRequest("Missing required field: email"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InvalidRequest, + errorMessage: "Missing required field: email", + suggestRetry: false, + }); + }); + + it("should build an invalid request error with default message when not provided", () => { + const result = buildResultInvalidRequest(); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InvalidRequest, + errorMessage: "Invalid request.", + suggestRetry: false, + }); + }); + + it("should allow retry suggestion for invalid request errors", () => { + const result = buildResultInvalidRequest("Bad input", true); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InvalidRequest, + errorMessage: "Bad input", + suggestRetry: true, + }); + }); + }); + + describe("buildResultConnectionError", () => { + it("should build a connection error with custom message", () => { + const result = buildResultConnectionError("Failed to connect to server"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ConnectionError, + errorMessage: "Failed to connect to server", + suggestRetry: true, + }); + }); + + it("should build a connection error with default message when not provided", () => { + const result = buildResultConnectionError(); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ConnectionError, + errorMessage: "Connection error.", + suggestRetry: true, + }); + }); + + it("should respect suggestRetry parameter", () => { + const result = buildResultConnectionError("Connection failed", false); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ConnectionError, + errorMessage: "Connection failed", + suggestRetry: false, + }); + }); + }); + + describe("buildResultRequestTimeout", () => { + it("should build a request timeout error with custom message", () => { + const result = buildResultRequestTimeout("Request exceeded 30 second limit"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.RequestTimeout, + errorMessage: "Request exceeded 30 second limit", + suggestRetry: true, + }); + }); + + it("should build a request timeout error with default message when not provided", () => { + const result = buildResultRequestTimeout(); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.RequestTimeout, + errorMessage: "Request timed out.", + suggestRetry: true, + }); + }); + + it("should respect suggestRetry parameter", () => { + const result = buildResultRequestTimeout("Timeout", false); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.RequestTimeout, + errorMessage: "Timeout", + suggestRetry: false, + }); + }); + }); + + describe("buildResultClientUnrecognizedOperationResult", () => { + it("should build unrecognized result with default values for unknown input", () => { + const result = buildResultClientUnrecognizedOperationResult("unknown"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "An unrecognized result for the operation occurred.", + suggestRetry: true, + }); + }); + + it("should extract errorMessage from object", () => { + const unrecognizedResult = { errorMessage: "Custom error message" }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "Custom error message", + suggestRetry: true, + }); + }); + + it("should extract suggestRetry from object", () => { + const unrecognizedResult = { suggestRetry: false }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "An unrecognized result for the operation occurred.", + suggestRetry: false, + }); + }); + + it("should extract both errorMessage and suggestRetry from object", () => { + const unrecognizedResult = { + errorMessage: "Custom error", + suggestRetry: false, + }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "Custom error", + suggestRetry: false, + }); + }); + + it("should ignore non-string errorMessage", () => { + const unrecognizedResult = { errorMessage: 123 }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "An unrecognized result for the operation occurred.", + suggestRetry: true, + }); + }); + + it("should ignore non-boolean suggestRetry", () => { + const unrecognizedResult = { suggestRetry: "true" }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "An unrecognized result for the operation occurred.", + suggestRetry: true, + }); + }); + + it("should handle null input", () => { + const result = buildResultClientUnrecognizedOperationResult(null); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "An unrecognized result for the operation occurred.", + suggestRetry: true, + }); + }); + + it("should handle object with extra properties", () => { + const unrecognizedResult = { + errorMessage: "Error occurred", + suggestRetry: false, + extraField: "ignored", + nested: { data: "also ignored" }, + }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "Error occurred", + suggestRetry: false, + }); + }); + }); + + describe("isRecognizedResultCodeForOperation", () => { + const recognizedCodes = [ + ResultCodes.InternalServerError, + ResultCodes.NotFound, + ResultCodes.InvalidRequest, + ] as const; + + it("should return true for recognized result code", () => { + expect( + isRecognizedResultCodeForOperation(ResultCodes.InternalServerError, recognizedCodes), + ).toBe(true); + }); + + it("should return false for unrecognized result code", () => { + expect(isRecognizedResultCodeForOperation(ResultCodes.ConnectionError, recognizedCodes)).toBe( + false, + ); + }); + + it("should return false for unknown string result codes", () => { + expect(isRecognizedResultCodeForOperation("UnknownCode", recognizedCodes)).toBe(false); + }); + + it("should handle empty recognized codes array", () => { + expect(isRecognizedResultCodeForOperation(ResultCodes.InternalServerError, [])).toBe(false); + }); + + it("should handle all result codes in recognized list", () => { + const allCodes = [ + ResultCodes.InternalServerError, + ResultCodes.NotFound, + ResultCodes.InvalidRequest, + ResultCodes.ConnectionError, + ResultCodes.RequestTimeout, + ResultCodes.ClientUnrecognizedOperationResult, + ] as const; + + allCodes.forEach((code) => { + expect(isRecognizedResultCodeForOperation(code, allCodes)).toBe(true); + }); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/result/result-common.ts b/packages/ensnode-sdk/src/shared/result/result-common.ts new file mode 100644 index 000000000..d13ab55a9 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/result-common.ts @@ -0,0 +1,176 @@ +/************************************************************ + * Internal Server Error + ************************************************************/ + +import type { AbstractResultError } from "./result-base"; +import { type ResultCode, ResultCodes } from "./result-code"; + +export interface ResultInternalServerError + extends AbstractResultError {} + +/** + * Builds a result object representing an internal server error. + */ +export const buildResultInternalServerError = ( + errorMessage?: string, + suggestRetry: boolean = true, +): ResultInternalServerError => { + return { + resultCode: ResultCodes.InternalServerError, + errorMessage: errorMessage ?? "An unknown internal server error occurred.", + suggestRetry, + }; +}; + +/************************************************************ + * Not Found + ************************************************************/ + +export interface ResultNotFound extends AbstractResultError {} + +/** + * Builds a result object representing a not found error. + */ +export const buildResultNotFound = ( + errorMessage?: string, + suggestRetry: boolean = false, +): ResultNotFound => { + return { + resultCode: ResultCodes.NotFound, + errorMessage: errorMessage ?? "Requested resource not found.", + suggestRetry, + }; +}; + +/************************************************************ + * Invalid Request + ************************************************************/ + +export interface ResultInvalidRequest + extends AbstractResultError {} + +/** + * Builds a result object representing an invalid request error. + */ +export const buildResultInvalidRequest = ( + errorMessage?: string, + suggestRetry: boolean = false, +): ResultInvalidRequest => { + return { + resultCode: ResultCodes.InvalidRequest, + errorMessage: errorMessage ?? "Invalid request.", + suggestRetry, + }; +}; + +/************************************************************ + * Connection Error + ************************************************************/ + +export interface ResultConnectionError + extends AbstractResultError {} + +/** + * Builds a result object representing a connection error. + */ +export const buildResultConnectionError = ( + errorMessage?: string, + suggestRetry: boolean = true, +): ResultConnectionError => { + return { + resultCode: ResultCodes.ConnectionError, + errorMessage: errorMessage ?? "Connection error.", + suggestRetry, + }; +}; + +/************************************************************ + * Request Timeout + ************************************************************/ + +export interface ResultRequestTimeout + extends AbstractResultError {} + +/** + * Builds a result object representing a request timeout error. + */ +export const buildResultRequestTimeout = ( + errorMessage?: string, + suggestRetry: boolean = true, +): ResultRequestTimeout => { + return { + resultCode: ResultCodes.RequestTimeout, + errorMessage: errorMessage ?? "Request timed out.", + suggestRetry, + }; +}; + +/************************************************************ + * Client-Unrecognized Operation Result + ************************************************************/ + +/** + * Represents an operation result with a result code that is not recognized + * by this client version. + * + * Relevant for cases where a client is running version X while the server + * is running version X+N and the server returns a result code that is not + * recognized by a client for a specific operation because the result code + * exists in version X+N for the operation on the server but not in the + * version X for the operation on the client and therefore needs + * transformation into a fallback result code for the client that is safe + * for recognition by clients that are running version X. + */ +export interface ResultClientUnrecognizedOperationResult + extends AbstractResultError {} + +/** + * Builds a result object representing an unrecognized operation result. + */ +export const buildResultClientUnrecognizedOperationResult = ( + unrecognizedResult: unknown, +): ResultClientUnrecognizedOperationResult => { + let errorMessage = "An unrecognized result for the operation occurred."; + let suggestRetry = true; + + if (typeof unrecognizedResult === "object" && unrecognizedResult !== null) { + if ( + "errorMessage" in unrecognizedResult && + typeof unrecognizedResult.errorMessage === "string" + ) { + errorMessage = unrecognizedResult.errorMessage; + } + if ( + "suggestRetry" in unrecognizedResult && + typeof unrecognizedResult.suggestRetry === "boolean" + ) { + suggestRetry = unrecognizedResult.suggestRetry; + } + } + + return { + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage, + suggestRetry, + }; +}; + +/** + * Checks if a result code is recognized for a specific operation. + */ +export const isRecognizedResultCodeForOperation = ( + resultCode: ResultCode | string, + recognizedResultCodesForOperation: readonly ResultCode[], +): boolean => { + // Checks if resultCode is one of the recognizedResultCodes for an operation + return recognizedResultCodesForOperation.includes(resultCode as ResultCode); +}; + +/************************************************************ + * All common client errors + ************************************************************/ + +export type ResultClientError = + | ResultConnectionError + | ResultRequestTimeout + | ResultClientUnrecognizedOperationResult; diff --git a/packages/ensnode-sdk/src/shared/result/types.ts b/packages/ensnode-sdk/src/shared/result/types.ts deleted file mode 100644 index 6473f3c86..000000000 --- a/packages/ensnode-sdk/src/shared/result/types.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * This module defines a standardized way to represent the outcome of operations, - * encapsulating both successful results and error results. - */ - -/** - * Possible Result Codes. - */ -export const ResultCodes = { - Ok: "ok", - Error: "error", -} as const; - -export type ResultCode = (typeof ResultCodes)[keyof typeof ResultCodes]; - -/** - * Value type useful for `ResultOk` type. - */ -export interface ResultOkValue { - valueCode: ValueCodeType; -} - -/** - * Result Ok returned by a successful operation call. - */ -export interface ResultOk { - resultCode: typeof ResultCodes.Ok; - value: ValueType; -} - -/** - * Value type useful for `ResultError` type. - */ -export interface ResultErrorValue { - errorCode: ErrorCodeType; -} - -/** - * Result Error returned by a failed operation call. - */ -export interface ResultError { - resultCode: typeof ResultCodes.Error; - value: ErrorType; -} - -/** - * Result returned by an operation. - * - * Guarantees: - * - `resultCode` indicates if operation succeeded or failed. - * - `value` describes the outcome of the operation, for example - * - {@link ResultOkValue} for successful operation call. - * - {@link ResultErrorValue} for failed operation call. - */ -export type Result = ResultOk | ResultError; - -/** - * Type for marking error as a transient one. - * - * It's useful for downstream consumers to know, so they can attempt fetching - * the result once again. - */ -export type ErrorTransient = ErrorType & { transient: true }; diff --git a/packages/ensnode-sdk/src/shared/result/utils.test.ts b/packages/ensnode-sdk/src/shared/result/utils.test.ts deleted file mode 100644 index 008ca377d..000000000 --- a/packages/ensnode-sdk/src/shared/result/utils.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { ErrorTransient, Result, ResultError, ResultOk } from "./types"; -import { - errorTransient, - isErrorTransient, - isResult, - isResultError, - isResultOk, - resultError, - resultOk, -} from "./utils"; - -describe("Result type", () => { - describe("Developer Experience", () => { - // Correct value codes that the test operation can return. - const TestOpValueCodes = { Found: "FOUND", NotFound: "NOT_FOUND" } as const; - - // Example of result ok: no records were found for the given request - interface TestOpResultOkNotFound { - valueCode: typeof TestOpValueCodes.NotFound; - } - - // Example of result ok: some records were found for the given request - interface TestOpResultFound { - valueCode: typeof TestOpValueCodes.Found; - records: string[]; - } - - // Union type collecting all ResultOk subtypes - type TestOpResultOk = TestOpResultOkNotFound | TestOpResultFound; - - // Error codes that the test operation can return. - const TestOpErrorCodes = { - InvalidRequest: "INVALID_REQUEST", - TransientIssue: "TRANSIENT_ISSUE", - } as const; - - // Example of result error: invalid request - interface TestOpResultErrorInvalidRequest { - errorCode: typeof TestOpErrorCodes.InvalidRequest; - message: string; - } - - // Example of result error: transient issue, simulates ie. indexing status not ready - type TestOpResultErrorTransientIssue = ErrorTransient<{ - errorCode: typeof TestOpErrorCodes.TransientIssue; - }>; - - // Union type collecting all ResultError subtypes - type TestOpError = TestOpResultErrorInvalidRequest | TestOpResultErrorTransientIssue; - - // Result type for test operation - type TestOpResult = Result; - - interface TestOperationParams { - name: string; - simulate?: { - transientIssue?: boolean; - }; - } - - // An example of operation returning a Result object - function testOperation(params: TestOperationParams): TestOpResult { - // Check if need to simulate transient server issue - if (params.simulate?.transientIssue) { - return resultError( - errorTransient({ - errorCode: TestOpErrorCodes.TransientIssue, - }), - ) satisfies ResultError; - } - - // Check if request is valid - if (params.name.endsWith(".eth") === false) { - return resultError({ - errorCode: TestOpErrorCodes.InvalidRequest, - message: `Invalid request, 'name' must end with '.eth'. Provided name: '${params.name}'.`, - }) satisfies ResultError; - } - - // Check if requested name has any records indexed - if (params.name !== "vitalik.eth") { - return resultOk({ - valueCode: TestOpValueCodes.NotFound, - }) satisfies ResultOk; - } - - // Return records found for the requested name - return resultOk({ - valueCode: TestOpValueCodes.Found, - records: ["a", "b", "c"], - }) satisfies ResultOk; - } - - // Example ResultOk values - const testOperationResultOkFound = testOperation({ - name: "vitalik.eth", - }); - - const testOperationResultOkNotFound = testOperation({ - name: "test.eth", - }); - - // Example ResultError values - const testOperationResultErrorTransientIssue = testOperation({ - name: "vitalik.eth", - simulate: { - transientIssue: true, - }, - }); - - const testOperationResultErrorInvalidRequest = testOperation({ - name: "test.xyz", - }); - - // Example values that are instances of Result type - const results = [ - testOperationResultOkFound, - testOperationResultOkNotFound, - testOperationResultErrorTransientIssue, - testOperationResultErrorInvalidRequest, - ]; - // Example values that are not instances of Result type - const notResults = [null, undefined, 42, "invalid", {}, { resultCode: "unknown" }]; - - describe("Type Guards", () => { - it("should identify Result types correctly", () => { - for (const maybeResult of results) { - expect(isResult(maybeResult)).toBe(true); - } - - for (const maybeResult of notResults) { - expect(isResult(maybeResult)).toBe(false); - } - }); - - it("should identify ResultOk types correctly", () => { - expect(isResultOk(testOperationResultOkFound)).toBe(true); - expect(isResultOk(testOperationResultOkNotFound)).toBe(true); - expect(isResultOk(testOperationResultErrorTransientIssue)).toBe(false); - expect(isResultOk(testOperationResultErrorInvalidRequest)).toBe(false); - - for (const resultOkExample of results.filter((result) => isResultOk(result))) { - const { value } = resultOkExample; - - switch (value.valueCode) { - case TestOpValueCodes.Found: - expect(value).toStrictEqual({ - valueCode: TestOpValueCodes.Found, - records: ["a", "b", "c"], - } satisfies TestOpResultFound); - break; - - case TestOpValueCodes.NotFound: - expect(value).toStrictEqual({ - valueCode: TestOpValueCodes.NotFound, - } satisfies TestOpResultOkNotFound); - break; - } - } - }); - - it("should identify ResultError types correctly", () => { - expect(isResultError(testOperationResultOkFound)).toBe(false); - expect(isResultError(testOperationResultOkNotFound)).toBe(false); - expect(isResultError(testOperationResultErrorTransientIssue)).toBe(true); - expect(isResultError(testOperationResultErrorInvalidRequest)).toBe(true); - - for (const resultErrorExample of results.filter((result) => isResultError(result))) { - const { value } = resultErrorExample; - - switch (value.errorCode) { - case TestOpErrorCodes.InvalidRequest: - expect(value).toStrictEqual({ - errorCode: TestOpErrorCodes.InvalidRequest, - message: "Invalid request, 'name' must end with '.eth'. Provided name: 'test.xyz'.", - } satisfies TestOpResultErrorInvalidRequest); - break; - - case TestOpErrorCodes.TransientIssue: - expect(value).toMatchObject( - errorTransient({ - errorCode: TestOpErrorCodes.TransientIssue, - }) satisfies TestOpResultErrorTransientIssue, - ); - break; - } - } - }); - - it("should distinguish transient errors correctly", () => { - expect(isErrorTransient(testOperationResultErrorTransientIssue.value)).toBe(true); - expect(isErrorTransient(testOperationResultErrorInvalidRequest.value)).toBe(false); - }); - }); - }); -}); diff --git a/packages/ensnode-sdk/src/shared/result/utils.ts b/packages/ensnode-sdk/src/shared/result/utils.ts deleted file mode 100644 index 105f59175..000000000 --- a/packages/ensnode-sdk/src/shared/result/utils.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * This file defines utilities for working with the Result generic type. - * Functionalities should be use to enhance developer experience while - * interacting with ENSNode APIs. - */ - -import { - type ErrorTransient, - type Result, - ResultCodes, - type ResultError, - type ResultErrorValue, - type ResultOk, - type ResultOkValue, -} from "./types"; - -/** - * Build a Result Ok from provided `data`. - * - * Requires `data` to include the `valueCode` property - * It enables the consumer of a Result object to identify `data` the result hold. - */ -export function resultOk>( - value: OkType, -): ResultOk { - return { - resultCode: ResultCodes.Ok, - value, - }; -} - -/** - * Is a result an instance of ResultOk? - */ -export function isResultOk( - result: Pick, "resultCode">, -): result is ResultOk { - return result.resultCode === ResultCodes.Ok; -} - -/** - * Build a Result Error from provided `error`. - * - * Requires `error` to include the `errorCode` property - * It enables the consumer of a Result object to identify `error` the result hold. - */ -export function resultError< - const ErrorValueType, - ErrorType extends ResultErrorValue, ->(value: ErrorType): ResultError { - return { - resultCode: ResultCodes.Error, - value, - }; -} - -/** - * Is a result error? - */ -export function isResultError( - result: Pick, "resultCode">, -): result is ResultError { - return result.resultCode === ResultCodes.Error; -} - -/** - * Is value an instance of a result type? - */ -export function isResult(value: unknown): value is Result { - return ( - typeof value === "object" && - value !== null && - "resultCode" in value && - (value.resultCode === ResultCodes.Ok || value.resultCode === ResultCodes.Error) - ); -} - -/** - * Build a new instance of `error` and mark it as transient. - * - * This "mark" informs downstream consumer about the transient nature of - * the error. - */ -export function errorTransient(error: ErrorType): ErrorTransient { - return { ...error, transient: true }; -} - -/** - * Is error a transient one? - */ -export function isErrorTransient(error: ErrorType): error is ErrorTransient { - return ( - typeof error === "object" && error !== null && "transient" in error && error.transient === true - ); -}