Hello. In the previous lesson, you established a clear boundary rule: data entering from configuration, terminal input, Frida, or files begins as unknown and becomes trusted only after Zod validation. That solves the question, “is this value well-formed?”
This lesson handles the next question: what should the application do when an operation cannot complete? A malformed PID, an incompatible agent message, and a process that no longer exists are normal outcomes a CLI should report without crashing. By contrast, a rejected Frida call or a library exception is not part of normal business flow, but it still needs controlled translation at the infrastructure boundary.
By the end, expected failures will be explicit typed values, while unexpected thrown values will become a stable application error. This is important for the future shared CLI/REPL: commands can fail recoverably without embedding terminal rendering or scattered try/catch blocks in every handler.
Two failure channels, two policies
JavaScript has one built-in failure mechanism: throw. TypeScript cannot declare or enforce which values a function may throw, and JavaScript permits throwing any value, not only Error instances.
For an inspection tool, that is too imprecise for normal operational conditions:
- A user enters
select not-a-pid. - A process exits after discovery but before attachment.
- An agent message fails the versioned protocol schema.
- A probe name conflicts with an existing probe.
These are expected failures: they are foreseeable parts of using the tool. They should appear in a function’s return type, so callers cannot ignore them accidentally.
Other failures are unexpected failures:
- The Frida Node binding throws synchronously.
- A promise from a native-library adapter rejects.
- A dependency has a defect or violates its documented contract.
- A non-
Errorvalue is thrown.
These cannot be enumerated exhaustively in TypeScript. The correct approach is not to pretend they are typed exceptions; it is to catch them at a narrow infrastructure boundary and convert them into one known application error variant.
How To Handle Errors Like A Senior Dev
Watch “How To Handle Errors Like A Senior Dev” by Web Dev Simplified for a practical introduction to result types and exhaustive handling. The web-specific examples are incidental; focus on the separation between an operation’s outcome and its rendering.
Watch the Result pattern to see how an explicit success-or-failure value gives TypeScript something it can narrow. Then watch exhaustive handling, especially the switch over a discriminated error reason and the compile-time check for omitted cases.
A Result<T, E> encodes this contract:
Tis the successful value.Eis the expected error value.- Every caller receives exactly one of those two outcomes.
For asynchronous work, ResultAsync<T, E> represents an eventual Result<T, E>, while retaining operations for composing the success path. We will use the focused neverthrow package rather than maintaining a home-grown result abstraction.
Install it as a production dependency:
pnpm add neverthrow
A Result does not mean “nothing can ever crash.” It means that the failures promised by the operation are visible in its type. Unexpected exceptions still exist in JavaScript; our design translates them at external boundaries rather than allowing them to leak throughout the application.
Give failures a stable application vocabulary
Avoid returning strings such as 'not found', raw Error objects, Zod errors, or Frida-specific exceptions from application-facing code. They make it difficult to handle cases consistently and couple callers to implementation details.
Create src/errors.ts as a small, dependency-light vocabulary. The variants here are intentionally modest; later modules will add errors for invalid instrumentation state, unavailable modules, unsafe memory reads, and recording failures.
import type { ProcessId } from './identifiers.js';
export type InvalidConfigurationError = Readonly<{
kind: 'invalid-configuration';
issueCount: number;
}>;
export type InvalidInputError = Readonly<{
kind: 'invalid-input';
field: 'pid';
value: string;
reason: string;
}>;
export type InvalidAgentMessageError = Readonly<{
kind: 'invalid-agent-message';
issueCount: number;
}>;
export type ProcessNotFoundError = Readonly<{
kind: 'process-not-found';
processId: ProcessId;
}>;
export type UnexpectedFailure = Readonly<{
kind: 'unexpected';
operation: string;
exception: Readonly<{
name: string;
message: string;
}>;
}>;
export type AppError =
| InvalidConfigurationError
| InvalidInputError
| InvalidAgentMessageError
| ProcessNotFoundError
| UnexpectedFailure;
The kind property is a discriminant. Once a caller checks it, TypeScript knows which other fields are valid:
function describeError(error: AppError): string {
switch (error.kind) {
case 'invalid-input':
return `${error.field}: ${error.reason}`;
case 'process-not-found':
return `No process exists with PID ${error.processId}.`;
case 'unexpected':
return `Unexpected failure while ${error.operation}.`;
case 'invalid-configuration':
return `Configuration contains ${error.issueCount} issue(s).`;
case 'invalid-agent-message':
return `Agent message contains ${error.issueCount} protocol issue(s).`;
default: {
const exhaustive: never = error;
return exhaustive;
}
}
}
The never assignment is deliberate. If you later add, say, a 'session-not-attached' error to AppError, TypeScript will reject this function until the new case is handled. That is a real architectural benefit: adding a failure mode requires consciously deciding how each presentation boundary should treat it.
Expected failures should be values from the start
A REPL’s PID parser will eventually receive text. An invalid PID is not exceptional; the user can correct it and continue working. Model it directly:
import { err, ok, type Result } from 'neverthrow';
import type { ProcessId } from './identifiers.js';
import { processIdFromValidated } from './identifiers.js';
import type { InvalidInputError } from './errors.js';
const maximumWindowsProcessId = 0xffff_ffff;
export function parseProcessId(
text: string,
): Result<ProcessId, InvalidInputError> {
const normalized = text.trim();
if (!/^[1-9]\d*$/.test(normalized)) {
return err({
kind: 'invalid-input',
field: 'pid',
value: text,
reason: 'Expected a positive decimal process ID.',
});
}
const value = Number(normalized);
if (
!Number.isSafeInteger(value) ||
value > maximumWindowsProcessId
) {
return err({
kind: 'invalid-input',
field: 'pid',
value: text,
reason: 'Expected a 32-bit Windows process ID.',
});
}
return ok(processIdFromValidated(value));
}
Notice the relationship to the previous lesson:
- The incoming REPL token is text, not a
ProcessId. - The parser checks its representation and range.
- Only then does it call
processIdFromValidated. - Failure returns a typed value rather than throwing.
The caller must choose a branch:
const parsed = parseProcessId('4420');
if (parsed.isOk()) {
const processId = parsed.value;
// processId is a ProcessId.
} else {
// parsed.error is an InvalidInputError.
}
neverthrow’s isOk() and isErr() methods are type guards. Accessing .value without proving success or .error without proving failure is not allowed.
GitHub - supermacro/neverthrow: Type-Safe Errors for JS & TypeScript · GitHub
Read the relevant portions of the neverthrow GitHub documentation to connect the result pattern to its concrete TypeScript API. Focus on which operations transform values, which operations compose fallible work, and where exception conversion belongs.
In API Documentation, first review the synchronous API entries for ok, err, Result.map, Result.andThen, and Result.match. In particular, read the andThen rationale: use map when a successful transformation cannot fail, and andThen when it returns another Result. Next, find Result.fromThrowable and read the exception wrapper explanation. Then, under Asynchronous API, review ResultAsync.fromThrowable, ResultAsync.map, ResultAsync.andThen, and ResultAsync.match. Pay close attention to the synchronous-throw warning: wrapping a promise after calling an unsafe function can be too late if that function throws before returning its promise.
Upgrade the validation boundaries from sentinels to Results
The previous lesson intentionally left parseAgentEvent() with a temporary AgentEvent | undefined return type. That prevents malformed data from entering the application, but it loses the reason for rejection. An explicit result is more useful to the caller while remaining independent of terminal output.
First, adjust the configuration loader. Configuration invalidity is fatal to startup, but it is still an expected outcome of interpreting user-controlled environment variables. A result lets the composition root decide to print an error and set an exit code without treating routine misconfiguration as an uncaught exception.
Replace the previous loadConfig function with this version; the environmentConfigSchema and AppConfig type remain unchanged:
import { err, ok, type Result } from 'neverthrow';
import type { InvalidConfigurationError } from '../errors.js';
// Keep the environmentConfigSchema and AppConfig definitions from the
// preceding lesson.
export function loadConfig(
source: unknown,
): Result<AppConfig, InvalidConfigurationError> {
const parsed = environmentConfigSchema.safeParse(source);
if (!parsed.success) {
return err({
kind: 'invalid-configuration',
issueCount: parsed.error.issues.length,
});
}
return ok({
device: parsed.data.FRIDA_DEVICE,
remoteAddress: parsed.data.FRIDA_REMOTE_ADDRESS,
maxTraceStringLength: parsed.data.TRACE_MAX_STRING_LENGTH,
});
}
This does not make invalid configuration recoverable in the sense of continuing startup. It makes the decision to stop explicit and testable.
import { loadConfig } from './boundaries/config.js';
import { describeError } from './errors.js';
async function main(): Promise<void> {
const configResult = loadConfig(process.env);
if (configResult.isErr()) {
process.stderr.write(`${describeError(configResult.error)}\n`);
process.exitCode = 2;
return;
}
await startCli(configResult.value);
}
Setting process.exitCode instead of calling process.exit() immediately gives Node an opportunity to flush output and complete any already-scheduled cleanup.
Now make the same improvement for the Frida agent-message boundary. Keep the protocol schemas from the prior lesson, but replace the temporary sentinel-returning parser:
import { err, ok, type Result } from 'neverthrow';
import type { InvalidAgentMessageError } from '../errors.js';
// Keep agentEventSchema, fridaSendEnvelopeSchema, and AgentEvent
// from the preceding lesson.
export function parseAgentEvent(
message: unknown,
): Result<AgentEvent, InvalidAgentMessageError> {
const parsed = fridaSendEnvelopeSchema.safeParse(message);
if (!parsed.success) {
return err({
kind: 'invalid-agent-message',
issueCount: parsed.error.issues.length,
});
}
return ok(Object.freeze(parsed.data.payload));
}
A message callback can now make a policy decision without guessing why it received no event:
const eventResult = parseAgentEvent(incomingMessage);
if (eventResult.isErr()) {
// Later, record this as a host diagnostic or show a concise warning.
logger.warn(describeError(eventResult.error));
return;
}
eventDispatcher.handle(eventResult.value);
The parser still has one job: validate and translate boundary data. It does not write to the terminal, decide whether a session should detach, or store data. Those decisions belong to callers at higher layers.
Translate thrown exceptions at a narrow edge
Expected failures should not be thrown from ordinary application logic. But third-party and native-facing APIs frequently throw or reject promises. Frida integration will be exactly such a boundary.
Start with a safe representation of a thrown value. Do not blindly assume cause.message exists: JavaScript permits throw 'bad state', throw 42, and throw { ... }.
Add this to src/errors.ts:
export function unexpectedFailure(
operation: string,
cause: unknown,
): UnexpectedFailure {
if (cause instanceof Error) {
return {
kind: 'unexpected',
operation,
exception: {
name: cause.name,
message: cause.message,
},
};
}
return {
kind: 'unexpected',
operation,
exception: {
name: 'NonErrorThrown',
message: 'A non-Error value was thrown.',
},
};
}
The application error carries stable context: which operation failed and a minimal diagnostic summary. It does not expose a Frida exception class, a Zod error object, or an arbitrary thrown object to application handlers.
Now consider an external asynchronous operation:
type FridaDeviceLike = Readonly<{
enumerateProcesses(): Promise<readonly unknown[]>;
}>;
It might reject its promise, and a non-async implementation could also throw before returning a promise. ResultAsync.fromThrowable() wraps the function invocation, covering both cases:
import { ResultAsync } from 'neverthrow';
import {
unexpectedFailure,
type UnexpectedFailure,
} from './errors.js';
export function enumerateExternalProcesses(
device: FridaDeviceLike,
): ResultAsync<readonly unknown[], UnexpectedFailure> {
const safeEnumerate = ResultAsync.fromThrowable(
() => device.enumerateProcesses(),
(cause) => unexpectedFailure('enumerating processes', cause),
);
return safeEnumerate();
}
The conversion point is narrow and meaningful:
- The adapter knows it is invoking an exception-based external API.
- The rest of the program knows only
UnexpectedFailure. - The operation label produces a useful diagnostic without coupling a CLI command to Frida’s error classes.
Do not wrap every function in fromThrowable reflexively. If ordinary domain code throws because of a programming defect, excessive wrapping can make defects look like routine user mistakes. Use typed results for anticipated operational alternatives, and place exception conversion where your code crosses into exception-based libraries, filesystem calls, native bindings, or similar infrastructure.
Finally, retain one outermost safety net for the executable process itself:
void main().catch((cause) => {
const error = unexpectedFailure('starting the CLI', cause);
process.stderr.write(`${describeError(error)}\n`);
process.exitCode = 1;
});
This is not a substitute for typed result contracts. It is a final containment boundary for defects that escaped all intended application paths.
Compose operations without nested checks
A command commonly consists of multiple steps, each with its own expected failure:
- Parse a textual PID.
- Query a provider for that PID.
- Produce a selected-target value.
map transforms a successful value when the transformation itself cannot fail. andThen and asyncAndThen continue only when the prior operation succeeded and the next operation itself returns a Result or ResultAsync.

Here is a compact command-level example. The provider contract already exposes its ordinary operational failures as values:
import {
type Result,
type ResultAsync,
} from 'neverthrow';
import type { ProcessId } from './identifiers.js';
import type {
InvalidInputError,
ProcessNotFoundError,
UnexpectedFailure,
} from './errors.js';
import { parseProcessId } from './parse-process-id.js';
type ProcessSummary = Readonly<{
processId: ProcessId;
name: string;
}>;
type SelectedTarget = Readonly<{
processId: ProcessId;
processName: string;
}>;
type SelectTargetError =
| InvalidInputError
| ProcessNotFoundError
| UnexpectedFailure;
export type ProcessProvider = Readonly<{
findById(
processId: ProcessId,
): ResultAsync<
ProcessSummary,
ProcessNotFoundError | UnexpectedFailure
>;
}>;
export function selectTarget(
pidText: string,
processProvider: ProcessProvider,
): ResultAsync<SelectedTarget, SelectTargetError> {
return parseProcessId(pidText)
.asyncAndThen((processId) =>
processProvider.findById(processId),
)
.map((process) => ({
processId: process.processId,
processName: process.name,
}));
}
Read this from the caller’s perspective:
- If
parseProcessId()returns anErr,findById()is never called. - If lookup returns
process-not-foundorunexpected, the final result preserves that error. - If lookup succeeds,
map()constructsSelectedTarget. - The inferred error type is the union of all possible expected failures.
There is no try/catch in this handler, no terminal output, and no nested if pyramid. The handler is also independent of whether the input originated from a one-shot CLI command or a future REPL command.
At an outer boundary, consume the result with match():
const output = await selectTarget(pidText, processProvider).match(
(target) =>
`Selected ${target.processName} (PID ${target.processId}).`,
(error) => describeError(error),
);
terminal.writeLine(output);
This is the direction of dependency you want to preserve:
- Parsing and application handlers return values or typed failures.
- Adapters translate third-party exceptions into typed failures.
- Terminal code renders those values for humans.
The future CLI and REPL will both call the same handler and use one renderer, rather than each reimplementing failure policy.
A practical policy for this project
Use this decision rule as the tool grows:
| Situation | Representation |
|---|---|
| Invalid REPL argument, malformed config, unknown command | Typed expected error in Result |
| PID does not exist, session is detached, probe name conflicts | Typed expected error in ResultAsync |
| Agent message violates the Zod protocol | Typed expected error returned by the parsing boundary |
| Frida binding throws or rejects | UnexpectedFailure created in the Frida adapter |
| A supposedly impossible state is reached | Treat it as a defect; allow the outermost safety boundary to contain and report it |
| Terminal wording, exit code, warnings | Handle at the CLI/REPL presentation boundary, not inside an application handler |
A useful code-review question is: could a caller reasonably respond to this condition? If yes, model it as a typed error value. If no, it may be a defect or external exception requiring containment at a boundary.
Key takeaways
A robust TypeScript application needs two complementary mechanisms:
- Typed
Resultvalues represent expected, actionable failures as part of an operation’s contract. - Narrow exception translation boundaries convert unpredictable JavaScript throws and rejected promises into a stable
UnexpectedFailure.
In this tool:
- Zod validation failures can now become typed configuration or agent-message errors instead of sentinels or thrown parser errors.
neverthrowprovidesok,err,map,andThen,asyncAndThen, andmatchfor explicit, composable control flow.maptransforms a successful value;andThencomposes another fallible operation and short-circuits on prior failure.ResultAsync.fromThrowable()is appropriate when invoking a third-party async API that may reject or even throw before returning a promise.- Discriminated application errors and exhaustive switches prevent new failure cases from silently going unhandled.
Next, you will write Vitest unit tests for an asynchronous TypeScript service using test doubles and deterministic assertions. The selectTarget style of handler is deliberately easy to test: a fake provider can return either ok(...) or err(...), with no real Frida process and no exception-based control flow required.
Can't find a good explanation? Sign up and we'll make it for you
Sign up