Create your own
Lesson illustration

Model Process Identifiers, Sessions, Commands, and Instrumentation States with Type-Safe Types

Good to continue from the project baseline. You now have a strict ESM TypeScript project in which tsc is the authority on type correctness, while tsx, ESLint, Prettier, and Vitest support the daily feedback loop.

That strictness becomes useful when we model the facts that flow through a process-inspection tool. A Windows PID, an instrumentation session ID, and a command name may all be represented by ordinary numbers or strings at runtime, but they have different meanings. This lesson makes those meanings explicit with branded types, immutable data, and discriminated unions. These models will become the stable vocabulary beneath both the CLI/REPL and the Frida adapters.


Make identifier mix-ups fail at compile time

Consider two APIs that will eventually exist in the tool:

function selectTarget(processId: number): void {
  // ...
}

function detach(sessionId: string): void {
  // ...
}

Nothing prevents this accidental call:

detach(String(processId));

The code is syntactically valid, and TypeScript sees only a string. Variable names can help a reader, but they do not constrain callers.

Two otherwise similar identifier values, `UserId` and `productId`, are shown as separate boxes with a prohibited connection; branded types apply this same compile-time separation to values such as process IDs and session IDs.

A branded type is an intersection of a normal runtime type and a compile-time-only marker. It is not a wrapper object and does not change emitted JavaScript. It gives TypeScript a reason to distinguish values that share the same representation.

Stop Writing Your ID Types Like This

Watch “Stop Writing Your ID Types Like This” by Web Dev Simplified for the concrete failure that motivates branded IDs, followed by the mechanism TypeScript uses to keep such IDs distinct.

Watch the brand mechanism to see how a base type is intersected with a private symbol-keyed marker. Then watch the practical payoff: a mismatched ID becomes a compiler error while preserving normal string behavior at runtime. Focus on the one-way relationship: a branded value can be used where its base type is expected, but an arbitrary base value cannot be used where the specific brand is required.

Create src/identifiers.ts:

declare const brand: unique symbol;

type Brand<T, Name extends string> = T & {
  readonly [brand]: Name;
};

export type ProcessId = Brand<number, 'ProcessId'>;
export type SessionId = Brand<string, 'SessionId'>;

/**
 * Call only after a boundary has established that `value` is a valid PID.
 * The brand exists at compile time only.
 */
export function processIdFromValidated(value: number): ProcessId {
  return value as ProcessId;
}

/**
 * Call only after a boundary has established that `value` is a valid session ID.
 */
export function sessionIdFromValidated(value: string): SessionId {
  return value as SessionId;
}

There are several deliberate details here:

  • declare const brand: unique symbol exists only in the type system. It emits no JavaScript value.
  • Brand<T, Name> retains the behavior of T. A ProcessId still behaves as a number at runtime, and a SessionId still behaves as a string.
  • The Name parameter gives each identifier category a distinct structural marker.
  • The casts occur in exactly two named functions. Do not scatter as ProcessId around handlers and adapters; that would make it too easy to bypass the model.
  • FromValidated is an architectural promise, not validation itself. In the next lesson, Zod schemas will establish whether unknown external data is valid before these functions are called.

The following assignments describe the intended direction of compatibility:

declare const processId: ProcessId;
declare const sessionId: SessionId;

const rawPid: number = processId;
const rawSessionId: string = sessionId;

// These should fail during type checking:
// const wrongPid: ProcessId = 4420;
// const wrongSession: SessionId = 'session-123';
// detach(processId);

A branded identifier is assignable to its underlying type because code that writes to an API, logs a PID, or interpolates an ID still needs ordinary JavaScript behavior. The reverse direction is rejected: a number is not automatically a ProcessId, and a SessionId is not a ProcessId.

Brands improve correctness within TypeScript; they are not runtime validation, authorization, or tamper protection. JavaScript input, any, unsafe assertions, and values received from Frida can still be wrong. Treat branding as the type-level result of a validation or construction step.

Documentation - TypeScript for Functional Programmers

Read the relevant parts of the TypeScript Handbook page to connect tagged intersections, discriminated unions, and readonly data to their everyday TypeScript forms.

In the Type aliases section, read the explanation of tagged intersections. Relate FString to ProcessId: both preserve a base representation while preventing assignment in the unsafe direction. Then find the readonly and const discussion near the end of the page. Read from the mutability distinction. Focus on why JavaScript const protects a binding, whereas TypeScript readonly protects properties and readonly arrays protect their elements from assignment.


Model commands as immutable values

A command is not a line of terminal text and not a function that prints output. It is a typed description of an intended application action. Keeping it as data lets a future one-shot CLI parser and the interactive REPL both create the same command values and send them to the same handlers.

Create src/instrumentation-model.ts:

import type { ProcessId, SessionId } from './identifiers.js';

export type Command =
  | {
      readonly kind: 'list-processes';
      readonly nameContains?: string;
    }
  | {
      readonly kind: 'select-process';
      readonly processId: ProcessId;
    }
  | {
      readonly kind: 'attach';
      readonly processId: ProcessId;
    }
  | {
      readonly kind: 'detach';
      readonly sessionId: SessionId;
    }
  | {
      readonly kind: 'session-status';
      readonly sessionId: SessionId;
    };

Each member has three useful properties:

  1. kind is a literal discriminant, not a general string. A misspelling such as 'attch' is rejected.
  2. Each variant contains exactly the payload it needs. An attach command requires a ProcessId; a detach command requires a SessionId.
  3. Every property is readonly. Once a command has been constructed, code cannot silently repurpose it by changing its target.

Notice the difference between these two encodings:

// Avoid: combinations with no clear meaning are permitted.
type LooseCommand = {
  kind: 'attach' | 'detach';
  processId?: ProcessId;
  sessionId?: SessionId;
};

With LooseCommand, TypeScript permits an attach command carrying only a session ID, or a detach command carrying both fields. The type does not encode the actual rule.

By contrast, the Command union links the discriminant to the required data. Checking command.kind === 'attach' proves to TypeScript that command.processId exists and is a ProcessId.

The optional nameContains?: string merits care because the project has exactOptionalPropertyTypes enabled. Its absence means “no name filter was supplied.” Construct the unfiltered command by omitting the property:

import { processIdFromValidated } from './identifiers.js';
import type { Command } from './instrumentation-model.js';

const attachCommand = {
  kind: 'attach',
  processId: processIdFromValidated(4420),
} as const satisfies Command;

const listAllCommand = {
  kind: 'list-processes',
} as const satisfies Command;

as const preserves literal types and marks the object’s properties readonly. satisfies Command checks that the object meets the command contract without broadly changing the inferred type of the value. It is particularly useful for command fixtures and registry definitions.

readonly is intentionally a compile-time guarantee. It is shallow: if a readonly object contains a mutable nested object, that nested object remains mutable unless it is also modeled as readonly. In this lesson, identifiers and command payloads are primitive values, so shallow immutability is sufficient. Later trace events with nested payloads will require more deliberate copying and boundary handling.


Represent instrumentation status as a discriminated union

Commands describe intent. Instrumentation state describes what the application currently knows about a session.

Avoid a model like this:

type UnsafeState = {
  isAttached: boolean;
  isDetaching: boolean;
  processId?: ProcessId;
  sessionId?: SessionId;
};

This permits contradictory combinations, such as isAttached: false, isDetaching: true, and no session ID. It also forces every consumer to rediscover which fields are usable in each condition.

Instead, extend src/instrumentation-model.ts:

export type InstrumentationState =
  | {
      readonly kind: 'idle';
    }
  | {
      readonly kind: 'attaching';
      readonly processId: ProcessId;
    }
  | {
      readonly kind: 'attached';
      readonly processId: ProcessId;
      readonly sessionId: SessionId;
    }
  | {
      readonly kind: 'detaching';
      readonly processId: ProcessId;
      readonly sessionId: SessionId;
    }
  | {
      readonly kind: 'attach-failed';
      readonly processId: ProcessId;
      readonly reason: string;
    }
  | {
      readonly kind: 'detach-failed';
      readonly processId: ProcessId;
      readonly sessionId: SessionId;
      readonly reason: string;
    };

This answers useful questions directly from the type:

  • In idle, there is no claimed target process or live session.
  • In attaching, a target process is known but no session has been established yet.
  • In attached and detaching, both IDs are necessarily available.
  • Failures retain the identifiers relevant to the failed action, rather than losing diagnostic context.

This union is a model of valid snapshots. It does not yet define which transitions are permitted, how concurrent attach requests behave, or whether detachment failure is recoverable. Those are state-machine rules, and we will make them explicit in the architecture module rather than hiding them in a collection of booleans.

The compiler narrows a union through ordinary JavaScript control flow. Here is a rendering-oriented helper that is safe to call from a future terminal adapter:

export function describeState(state: InstrumentationState): string {
  switch (state.kind) {
    case 'idle':
      return 'No target process is attached.';

    case 'attaching':
      return `Attaching to PID ${state.processId}.`;

    case 'attached':
      return `Session ${state.sessionId} is attached to PID ${state.processId}.`;

    case 'detaching':
      return `Detaching session ${state.sessionId} from PID ${state.processId}.`;

    case 'attach-failed':
      return `Could not attach to PID ${state.processId}: ${state.reason}`;

    case 'detach-failed':
      return `Could not detach session ${state.sessionId}: ${state.reason}`;

    default:
      return assertNever(state);
  }
}

function assertNever(value: never): never {
  throw new Error(`Unexpected instrumentation state: ${JSON.stringify(value)}`);
}

Within case 'attached', TypeScript knows that state has both processId and sessionId. No optional chaining, non-null assertion, or type cast is required. That is the main payoff of giving each variant its own object type.

TypeScript: Documentation - Narrowing

Read the Handbook’s canonical explanation of discriminated unions and exhaustiveness checking. It directly supports the command and state models in this lesson.

In Discriminated unions, start at switch narrowing. Observe that narrowing depends on the type design: every union member has the same property name, while each member gives it a distinct literal value. Then read The never type and Exhaustiveness checking, beginning with the exhaustive-switch pattern. Compare its never assignment with the assertNever(state) helper used above.


Make new states force a deliberate decision

The default branch in describeState is not ordinary fallback behavior. Its parameter is typed as never, meaning TypeScript believes there are no values left after all known cases have been handled.

A union containing `pending`, `paid`, and `failed` is progressively narrowed as each case is handled until no member remains, producing the `never` type; adding another union member makes an exhaustive check fail during compilation.

Suppose a later feature adds a state:

type InstrumentationState =
  // Existing variants omitted
  | {
      readonly kind: 'agent-destroyed';
      readonly sessionId: SessionId;
    };

Now state in the default branch is no longer never; it may be the new agent-destroyed variant. The call assertNever(state) fails type checking. That compiler failure is valuable: it identifies every switch that must decide how to handle the newly possible state.

Use the same pattern for commands:

export function describeCommand(command: Command): string {
  switch (command.kind) {
    case 'list-processes':
      return command.nameContains === undefined
        ? 'List all processes'
        : `List processes containing "${command.nameContains}"`;

    case 'select-process':
      return `Select PID ${command.processId}`;

    case 'attach':
      return `Attach to PID ${command.processId}`;

    case 'detach':
      return `Detach session ${command.sessionId}`;

    case 'session-status':
      return `Show status for session ${command.sessionId}`;

    default:
      return assertNever(command);
  }
}

A newly added command cannot quietly receive a generic description or skip a routing decision. The compiler turns evolution of the command language into a visible maintenance task.

Run the baseline checks after adding the two modules:

pnpm format
pnpm typecheck
pnpm lint
pnpm test

For a quick compile-time verification, temporarily add a new command variant such as help without changing describeCommand. pnpm typecheck should identify the assertNever(command) call. Revert the temporary variant after confirming the behavior.


Key takeaways

You now have a compact, safer domain vocabulary for the early tool:

  • Branded types distinguish ProcessId and SessionId even though they are numbers and strings at runtime.
  • Brand construction is localized to functions that must be called only after data is trusted or validated.
  • Readonly command values describe intent without coupling that intent to terminal parsing, rendering, or Frida.
  • Discriminated unions encode which fields are present for each command and instrumentation state.
  • Exhaustiveness checks with never make additions to the command language or state model visible at compile time.
  • Type-level immutability and brands strengthen internal correctness, but neither replaces runtime validation at external boundaries.

Next, you will validate unknown configuration and runtime data with Zod schemas. That gives the branded constructors a proper boundary: external values become trusted domain values only after their shape and basic semantics have been checked.

Can't find a good explanation? Sign up and we'll make it for you

Sign up