Good to continue from the domain-modeling work. The previous lesson made invalid internal states and identifier mix-ups harder to express in TypeScript. But that protection begins only after a value is inside the program. Environment variables, JSON files, terminal input, filesystem data, and Frida messages all arrive as runtime values whose actual shape TypeScript cannot prove.
This lesson establishes a boundary rule for the tool: unknown data is validated once at the edge, then converted into trusted, typed application data. We will use Zod for two realistic boundaries: startup configuration and messages received from an injected Frida agent. This creates the validation point promised by the processIdFromValidated constructor from the previous lesson.
Watch “Learn Zod In 30 Minutes” by Web Dev Simplified for a compact orientation to schemas, parsing, inferred types, and safeParse.
Watch the foundation. Focus on the difference between declaring a TypeScript type and actually checking a runtime value. The presenter uses a Vite example, but the Zod concepts apply directly to our strict Node.js ESM project.
Static types describe assumptions; schemas test reality
A TypeScript annotation is erased when your tool runs. This is perfectly valid TypeScript:
type ProcessSummary = {
processId: number;
name: string;
};
const incoming = JSON.parse('{"processId":"not-a-number","name":42}') as ProcessSummary;
The assertion tells the compiler to trust us. It does not inspect the JSON. At runtime, incoming.processId remains a string and incoming.name remains a number.
That distinction matters particularly for this project:
| Boundary | Why its data is unknown |
|---|---|
process.env | Node exposes environment-variable values as strings or absent values. |
| A future configuration file | The file might be malformed, outdated, manually edited, or supplied from an unexpected location. |
| REPL and CLI input | Text has not yet been parsed into a command or validated as a PID. |
| Frida process enumeration | It comes from an external native instrumentation library. |
| Frida agent messages | They cross a host-agent boundary and may be malformed, from an incompatible agent version, or affected by a target process. |
| Recording replay | A local NDJSON file can be incomplete or from a different tool version. |
A Zod schema is executable data describing what is accepted at runtime. It acts as a gate:
- Receive a value as
unknown. - Parse it through a schema.
- Use the typed output only if parsing succeeds.
- Keep unvalidated values out of domain and application code.
This is not merely defensive programming. It clarifies architectural responsibility: adapters and composition code deal with external representation; application code receives values that satisfy declared contracts.
Install Zod as a production dependency:
pnpm add zod

Read the official Zod “Basic usage” guide to ground the core workflow before applying it to configuration and Frida messages.
In “Parsing data,” read the parsing explanation. Then read “Handling errors,” beginning with validation failures, including the safeParse() example. Finish with “Inferring types,” especially type inference. Notice that z.infer describes a schema’s successful output, not the arbitrary value received at the boundary.
parse and safeParse express different failure policies
Every schema provides two especially useful parsing methods:
const parsed = schema.parse(input);
parse returns valid output or throws a ZodError. Use it where invalid input makes further startup impossible, such as mandatory application configuration. It is appropriate to stop before attaching to any target process when the configuration is nonsensical.
const result = schema.safeParse(input);
if (result.success) {
result.data;
} else {
result.error.issues;
}
safeParse returns a discriminated union. Its success field plays the same narrowing role as the kind fields in last lesson’s commands and instrumentation states. Use it for recoverable, repeated, or hostile boundaries: an invalid message should not crash a REPL or take down an existing instrumentation session.
Do not use either of these substitutes for validation:
const event = message as AgentEvent; // Unsafe assertion
const event: AgentEvent = message as any; // Disables type safety
They erase the compiler’s objections without examining the incoming value.
Validate startup configuration once
Environment variables are a deceptively important boundary. Although process.env.FRIDA_DEVICE may be present at runtime, its practical type is still string | undefined; it is never a reliable enum or number simply because a TypeScript declaration says so.
Create src/boundaries/config.ts. The boundaries directory is temporary organization, not a full architecture commitment; later modules will place such code behind explicit ports and adapters.
import * as z from 'zod';
const positiveIntegerFromEnvironment = z
.string()
.regex(/^[1-9]\d*$/, {
error: 'Expected a positive base-10 integer.',
})
.transform((value) => Number(value))
.pipe(z.number().int().max(4096));
const environmentConfigSchema = z
.object({
FRIDA_DEVICE: z.enum(['local', 'usb', 'remote']).default('local'),
FRIDA_REMOTE_ADDRESS: z.string().trim().min(1).optional(),
TRACE_MAX_STRING_LENGTH: positiveIntegerFromEnvironment.default(512),
})
.refine(
(config) =>
config.FRIDA_DEVICE !== 'remote' ||
config.FRIDA_REMOTE_ADDRESS !== undefined,
{
path: ['FRIDA_REMOTE_ADDRESS'],
error: 'FRIDA_REMOTE_ADDRESS is required when FRIDA_DEVICE is remote.',
},
);
export type AppConfig = Readonly<{
device: 'local' | 'usb' | 'remote';
remoteAddress: string | undefined;
maxTraceStringLength: number;
}>;
/**
* Converts an external configuration source into the application's stable
* configuration vocabulary. Invalid startup configuration is fatal.
*/
export function loadConfig(source: unknown): AppConfig {
const parsed = environmentConfigSchema.parse(source);
return {
device: parsed.FRIDA_DEVICE,
remoteAddress: parsed.FRIDA_REMOTE_ADDRESS,
maxTraceStringLength: parsed.TRACE_MAX_STRING_LENGTH,
};
}
At the composition root, configuration enters the program exactly once:
import { loadConfig } from './boundaries/config.js';
const config = loadConfig(process.env);
Several design choices here deserve attention.
Configuration is not typed twice
The schema supplies both runtime rules and output types:
FRIDA_DEVICEmust be one of three supported device modes.- An absent
FRIDA_DEVICEbecomes'local'. TRACE_MAX_STRING_LENGTHbegins as an environment string, must contain a positive base-10 integer, and emerges as a number.- A remote device requires a non-empty remote address.
The .transform(Number) call means input and output types differ. Before parsing, the maximum string length is textual; after parsing, it is numeric. This is why schema output, rather than an independently handwritten interface, should be the source of truth for boundary data.
We map uppercase environment names into an AppConfig with application-oriented names. That prevents process.env and its naming conventions from spreading through handlers and domain code.
Avoid casual numeric coercion
You may see configuration written like this:
const portSchema = z.coerce.number();
Zod’s coercion support is useful when conversion itself is the desired contract. However, broad JavaScript number coercion has surprising behavior: for example, an empty string converts to zero. Here we instead define the accepted textual representation first, then transform it. A value such as "512" is accepted; "", "-1", "10.5", and "large" are rejected.
For a security-sensitive inspection tool, making accepted representations explicit is generally easier to reason about than accepting JavaScript’s broad conversion rules.
Cross-field rules belong near the schema
A type such as this is too weak to express the remote-device requirement:
type WeakConfig = {
device: 'local' | 'usb' | 'remote';
remoteAddress?: string;
};
It allows { device: 'remote' }. The .refine() rule adds the business-relevant relationship and assigns its failure to FRIDA_REMOTE_ADDRESS, making the problem actionable.
Use the official Zod schema API selectively for the schema features we are using: number constraints, object-key behavior, and cross-field refinements.
In “Numbers,” review number validation. In “Objects,” read from unknown-key behavior through the z.strictObject example. Then read the opening of “Refinements,” from custom validation, followed by the .refine() example. Relate the path option to the remote-address error in environmentConfigSchema.
Choose an unknown-key policy deliberately
An ordinary z.object(...) strips unrecognized keys from its parsed output. That is desirable for process.env, which naturally contains many unrelated operating-system and shell variables. The output has only the three configuration values our tool recognizes.
There are two other policies:
- Strict object: reject unknown keys. Prefer it for a closed protocol, a carefully versioned JSON configuration file, or a request where a typo should be reported.
- Loose object / passthrough: retain unknown keys. Use it only where forward-compatible extension data genuinely needs to pass through the current version.
The policy is part of the boundary contract, not an incidental Zod setting.
Validate Frida-originated messages before they become events
Later, an injected agent will use Frida’s send() facility to send values to the Node.js host. Even if we write both host and agent, the host must treat the message as unknown. Agent and host bundles can drift out of version; a target process can interfere with assumptions; and Frida error messages do not have the same shape as agent event messages.
Start with a deliberately small, versioned protocol in src/boundaries/agent-message.ts:
import * as z from 'zod';
import { processIdFromValidated } from '../identifiers.js';
const processIdSchema = z
.number()
.int()
.positive()
.max(0xffff_ffff)
.transform(processIdFromValidated);
const addressSchema = z
.string()
.regex(/^0x[0-9a-f]+$/i, { error: 'Expected a hexadecimal address.' });
const functionCallEventSchema = z.strictObject({
protocolVersion: z.literal(1),
kind: z.literal('function-call'),
processId: processIdSchema,
probeId: z.string().min(1).max(128),
threadId: z.number().int().nonnegative(),
timestampMs: z.number().nonnegative(),
address: addressSchema,
});
const diagnosticEventSchema = z.strictObject({
protocolVersion: z.literal(1),
kind: z.literal('diagnostic'),
level: z.enum(['debug', 'warn', 'error']),
message: z.string().min(1).max(2048),
});
export const agentEventSchema = z.discriminatedUnion('kind', [
functionCallEventSchema,
diagnosticEventSchema,
]);
const fridaSendEnvelopeSchema = z.object({
type: z.literal('send'),
payload: agentEventSchema,
});
export type AgentEvent = Readonly<z.infer<typeof agentEventSchema>>;
/**
* An invalid event is not allowed beyond this boundary.
* The next lesson will replace this simple recovery policy with a typed
* application error that preserves validation details.
*/
export function parseAgentEvent(message: unknown): AgentEvent | undefined {
const parsed = fridaSendEnvelopeSchema.safeParse(message);
if (!parsed.success) {
return undefined;
}
return Object.freeze(parsed.data.payload);
}
The schema works at three levels.
First, it validates the outer Frida message category. A Frida message whose type is 'error' is not accidentally treated as agent data. We will model detach notifications and agent errors explicitly when implementing the Frida host lifecycle.
Second, it validates the agent’s actual payload using a discriminated union. A function-call carries an address, PID, probe ID, thread ID, and timestamp. A diagnostic carries a severity and message. Once parsing has succeeded, TypeScript narrows correctly:
const event = parseAgentEvent(incomingMessage);
if (event?.kind === 'function-call') {
// event.processId is a ProcessId
// event.address is a validated hexadecimal-address string
// event.level does not exist in this branch
}
Third, each event variant is a z.strictObject. If the agent sends a misspelled field such as threadID, or if a future protocol version unexpectedly adds fields to an unchanged version number, parsing fails rather than quietly accepting an ambiguous contract.
Validation and branded identifiers meet at the boundary
The previous lesson defined this constructor:
export function processIdFromValidated(value: number): ProcessId {
return value as ProcessId;
}
The Zod transform now gives “validated” a concrete meaning:
const processIdSchema = z
.number()
.int()
.positive()
.max(0xffff_ffff)
.transform(processIdFromValidated);
Zod runs transform only after the preceding number checks succeed. The output of processIdSchema is therefore a ProcessId, not merely a number. No handler receiving an AgentEvent needs to cast a PID.
This is a useful division of responsibilities:
- Zod establishes runtime shape and basic semantic constraints.
- Branding preserves a validated value’s meaning at compile time.
- Domain/application logic applies rules that depend on current state, permissions, or external operations.
A valid PID is not automatically an attachable PID. It might refer to a protected process, a process that exited milliseconds ago, or a target outside the tool’s authorization policy. Validation establishes well-formedness, not authorization or successful operation.
Recover without trusting
During active tracing, rejecting one malformed incoming message is usually recoverable. safeParse lets the host preserve a stable session instead of throwing into the message callback.
For now, parseAgentEvent returns undefined for invalid input. This is intentionally conservative: malformed data does not reach the application. It is not yet the final error-reporting design. In the next lesson, you will replace simple sentinel recovery with a typed result value that retains structured reasons without coupling this parser to terminal output.
A quick boundary probe can be useful while wiring the schema:
const candidate: unknown = {
type: 'send',
payload: {
protocolVersion: 1,
kind: 'function-call',
processId: 4420,
probeId: 'fixture-add',
threadId: 8104,
timestampMs: Date.now(),
address: '0x7ff6a1234000',
},
};
const event = parseAgentEvent(candidate);
// AgentEvent | undefined
Change processId to "4420", remove probeId, or add an unexpected property to the payload. Each should return undefined; none should produce a value that code can use as an AgentEvent.
Run the project checks after adding the files:
pnpm format
pnpm typecheck
pnpm lint
pnpm test
Boundary rules to carry forward
The immediate implementation is small, but it establishes rules that will scale through process discovery, probing, Windows API tracing, and replay:
- Accept external values as
unknown. Do not give them a domain type through assertion. - Put schemas beside the adapter or boundary, rather than scattering checks among command handlers.
- Use
parsefor unrecoverable startup prerequisites such as required configuration. - Use
safeParsefor recoverable runtime inputs such as agent messages, REPL commands, and replay records. - Map external names and representations into application vocabulary immediately.
- Make unknown-key behavior explicit. Environment sources can ignore unrelated keys; closed host-agent protocols should reject unexpected payload fields.
- Use transforms sparingly and transparently. They are appropriate where a validated external representation becomes a domain value, such as number-to-
ProcessId. - Never confuse validation with trust in a broader sense. Valid data may still be unauthorized, unavailable, stale, or operationally unsafe.
Key takeaways
Zod closes the gap between TypeScript’s compile-time model and the values that actually enter the process-inspection tool at runtime.
- A schema is a runtime contract;
z.inferderives its successful output type. .parse()is suitable for fatal startup configuration;.safeParse()is suitable for recoverable runtime boundaries.- Environment variables should be parsed as strings and transformed deliberately, not assumed to be numbers or enums.
.refine()handles relationships between fields, such as requiring a remote address for a remote Frida device.z.strictObject()is appropriate for a closed, versioned agent-message protocol.- A Zod transform can construct the branded
ProcessIdonly after validation, connecting runtime checks to the type-level safety established previously.
Next, you will represent expected failures with typed result values and translate unexpected exceptions into application errors. That will let configuration loaders, Frida adapters, and future command handlers report failures consistently without leaking exceptions or raw Zod details through the rest of the application.
Can't find a good explanation? Sign up and we'll make it for you
Sign up