Size / Priority
Rationale
Today's context.watch(ref) delivers a Terminated(ref) message to the watcher when ref stops. That's fine for "did THAT one die?" but doesn't compose with the watcher's normal message protocol — the watcher's onReceive must learn about Terminated as a special signal type, and if the watcher watches many actors of different roles ("workers" vs "DB connection" vs "cluster peer"), the Terminated.actor reference is the only way to disambiguate.
Akka's watchWith(ref, customMsg) lets the caller specify any message to deliver on termination. The watcher's protocol stays clean:
// without watchWith — message type union must include Terminated
case Terminated(child) =>
workers.find(_.ref == child) match { ... }
dbConnections.find(_.ref == child) match { ... }
// with watchWith — termination is a domain event
context.watchWith(worker, WorkerDied(worker.name))
context.watchWith(dbConn, DbConnectionLost(dbConn.endpoint))
// receive:
case WorkerDied(name) => respawnWorker(name)
case DbConnectionLost(ep) => reconnect(ep)
The implementation is small — context.watch already exists (src/internal/ActorCell.ts:158-166). watchWith is an overload that records the custom message and delivers it instead of Terminated on the same death signal.
Reference: what Akka does
class Supervisor extends Actor {
case class WorkerDied(name: String)
context.watchWith(worker, WorkerDied("alice"))
def receive = {
case WorkerDied(name) => respawn(name)
}
}
watchWith ↔ watch overlay:
watchWith(ref, msg): on ref termination, deliver msg to self.
watch(ref): on ref termination, deliver Terminated(ref) to self.
- Replacing
watchWith with a different message for the same ref is allowed (last call wins).
unwatch(ref) removes any watch (with or without custom msg).
Design sketch — actor-ts equivalent
// src/ActorContext.ts (interface) — add overload
export interface ActorContext<TMsg = unknown> {
// ... existing ...
/** Watch `ref`; on termination, deliver `Terminated(ref)` to this actor (existing). */
watch(ref: ActorRef): ActorRef;
/**
* Watch `ref`; on termination, deliver `customMsg` to this actor instead of
* `Terminated(ref)`. Useful for domain-typed termination signals.
*
* Replaces any previous watch (with or without custom msg) of the same ref.
*/
watchWith<M extends TMsg>(ref: ActorRef, customMsg: M): ActorRef;
/** Stop watching `ref` (whether watch or watchWith was used). */
unwatch(ref: ActorRef): ActorRef;
}
// src/internal/ActorCell.ts — extend the watching machinery
class ActorCell<TMsg> {
// Existing field: this._watching: Map<string, ActorRef>
// NEW field: per-ref custom termination message
private _watchWithMessages = new Map<string, TMsg>();
watch(ref: ActorRef): ActorRef {
const key = ref.path.toString();
if (this._watching.has(key)) {
// Was watchWith → reset to plain watch
this._watchWithMessages.delete(key);
return ref;
}
this._watching.set(key, ref);
if (ref instanceof LocalActorRef) {
ref.getCell()._addWatcher(this.self);
}
return ref;
}
watchWith<M extends TMsg>(ref: ActorRef, customMsg: M): ActorRef {
const key = ref.path.toString();
this._watchWithMessages.set(key, customMsg);
if (!this._watching.has(key)) {
this._watching.set(key, ref);
if (ref instanceof LocalActorRef) {
ref.getCell()._addWatcher(this.self);
}
}
return ref;
}
unwatch(ref: ActorRef): ActorRef {
const key = ref.path.toString();
this._watchWithMessages.delete(key);
if (!this._watching.delete(key)) return ref;
if (ref instanceof LocalActorRef) {
ref.getCell()._removeWatcher(this.self);
}
return ref;
}
// In the termination notification path (where Terminated is currently delivered):
private notifyWatchers(deadRefKey: string, deadRef: ActorRef): void {
for (const [watcher, watchedRefs] of allWatchers) {
if (!watchedRefs.has(deadRefKey)) continue;
const customMsg = watcher._watchWithMessages.get(deadRefKey);
const msg = customMsg ?? new Terminated(deadRef);
watcher.self.tell(msg as never);
}
}
}
Integration with existing actor-ts subsystems
ActorCell.watch / unwatch: extend, don't replace.
Terminated message: unchanged; watch() continues to deliver it.
- Death notification path (
_addWatcher / _removeWatcher machinery): unchanged externally; the notification call site picks customMsg over Terminated.
- Typed Behaviors (
TypedActorContext): same overload added.
- Cluster remote watching: today remote watch delivers
Terminated; watchWith over the wire is more complex (the custom message must be serialisable). Recommend phase-1: local watchWith only; remote watchWith errors loudly with a clear message ("custom termination messages over remote watch require an actor-ts serializer registration"). Phase 2: wire serialisation if demand.
Out of scope / non-goals
- Multiple custom messages per ref: Akka allows only one; we follow same.
- Remote watch with custom message in phase 1: defer.
- Conditional message (e.g. different msg per termination reason): out of scope; just one message.
Open design questions
- Type-safety:
watchWith<M extends TMsg>(ref, customMsg: M) enforces the custom message is in the watcher's message type. For typed Behaviors this is natural; for untyped Actor same. Confirm the generic constraint resolves correctly in inference.
- Replacing watch with watchWith (and vice versa): sketch says "last call wins". Akka same. Document.
- Message identity over multiple termination events: a ref that's restarted (different incarnation, same path) — does the custom message fire on every termination or only once? Recommend: once per watch — calling
watchWith again after restart re-arms.
- Remote watch: Akka supports remote watchWith via serialisation. Phase 1 limitation here is intentional (less than 5% of users hit this). Document.
Test plan
- Local watchWith —
context.watchWith(workerRef, new WorkerDied('alice')); stop worker; watcher receives WorkerDied('alice'), NOT Terminated.
- Replace watch with watchWith —
context.watch(ref) then context.watchWith(ref, msg); on death, receives custom msg.
- Replace watchWith with watch —
context.watchWith(ref, msg) then context.watch(ref); on death, receives Terminated(ref).
- Multiple watchWith of different refs — watch worker-A with
WorkerDied('a') + worker-B with WorkerDied('b'); deaths produce distinct domain messages.
- Unwatch removes both —
unwatch(ref) after either watch or watchWith; on death, no message.
- Re-arm after death — watchWith ref; death; watcher receives msg; watcher restarts ref (different incarnation, same path); watchWith again; death again; receives msg again.
- Type-checked custom msg — TS compile test:
watchWith rejects a message type not assignable to the watcher's TMsg.
- Remote watchWith error —
watchWith(remoteRef, customMsg) throws or warns clearly (phase-1 limit).
- Typed Behaviors —
ctx.watchWith from inside a typed behavior; receives custom msg in receive handler.
- Regression — existing
watch / Terminated semantics unchanged.
Acceptance criteria
Size / Priority
context.watchWith.Rationale
Today's
context.watch(ref)delivers aTerminated(ref)message to the watcher whenrefstops. That's fine for "did THAT one die?" but doesn't compose with the watcher's normal message protocol — the watcher'sonReceivemust learn aboutTerminatedas a special signal type, and if the watcher watches many actors of different roles ("workers" vs "DB connection" vs "cluster peer"), theTerminated.actorreference is the only way to disambiguate.Akka's
watchWith(ref, customMsg)lets the caller specify any message to deliver on termination. The watcher's protocol stays clean:The implementation is small —
context.watchalready exists (src/internal/ActorCell.ts:158-166).watchWithis an overload that records the custom message and delivers it instead ofTerminatedon the same death signal.Reference: what Akka does
watchWith↔watchoverlay:watchWith(ref, msg): onreftermination, delivermsgto self.watch(ref): onreftermination, deliverTerminated(ref)to self.watchWithwith a different message for the samerefis allowed (last call wins).unwatch(ref)removes any watch (with or without custom msg).Design sketch — actor-ts equivalent
Integration with existing actor-ts subsystems
ActorCell.watch/unwatch: extend, don't replace.Terminatedmessage: unchanged;watch()continues to deliver it._addWatcher/_removeWatchermachinery): unchanged externally; the notification call site pickscustomMsgoverTerminated.TypedActorContext): same overload added.Terminated;watchWithover the wire is more complex (the custom message must be serialisable). Recommend phase-1: local watchWith only; remotewatchWitherrors loudly with a clear message ("custom termination messages over remote watch require an actor-ts serializer registration"). Phase 2: wire serialisation if demand.Out of scope / non-goals
Open design questions
watchWith<M extends TMsg>(ref, customMsg: M)enforces the custom message is in the watcher's message type. For typed Behaviors this is natural; for untyped Actor same. Confirm the generic constraint resolves correctly in inference.watchWithagain after restart re-arms.Test plan
context.watchWith(workerRef, new WorkerDied('alice')); stop worker; watcher receivesWorkerDied('alice'), NOTTerminated.context.watch(ref)thencontext.watchWith(ref, msg); on death, receives custommsg.context.watchWith(ref, msg)thencontext.watch(ref); on death, receivesTerminated(ref).WorkerDied('a')+ worker-B withWorkerDied('b'); deaths produce distinct domain messages.unwatch(ref)after eitherwatchorwatchWith; on death, no message.watchWithrejects a message type not assignable to the watcher's TMsg.watchWith(remoteRef, customMsg)throws or warns clearly (phase-1 limit).ctx.watchWithfrom inside a typed behavior; receives custom msg inreceivehandler.watch/Terminatedsemantics unchanged.Acceptance criteria
ActorContext.watchWith<M>(ref, customMsg)added (overload).TypedActorContext.watchWithadded.ActorCell._watchWithMessagesmap + notification-path branching.