Skip to content

Commit 01fb06d

Browse files
committed
added blog
Signed-off-by: Tejesh Vaish <188414140+tridev-tej@users.noreply.github.com>
1 parent e857925 commit 01fb06d

4 files changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
---
2+
author: tejesh-vaish
3+
date: "2026-10-26"
4+
title: Handling late replies with ReScript records and events
5+
badge: community
6+
description: |
7+
Match typed events against the current state and use immutable record updates
8+
to handle deadlines, stale replies, and late completions in ReScript.
9+
---
10+
11+
A product-title update can finish after the client stops waiting. If the final reply arrives late, the client still needs to decide whether it belongs to the current attempt and whether it can change the result.
12+
13+
[Modeling uncertain outcomes with ReScript variants](./modeling-uncertain-tool-call-outcomes.mdx) gives us four states. `Pending` means we're waiting, and `Unresolved` means the deadline passed without a final reply. `Completed(string)` and `Rejected(string)` carry final results. Here those states live in a record alongside an attempt ID.
14+
15+
## Typed events and a tuple match
16+
17+
`signal` is a variant for the events we accept. A deadline has no payload, a completion carries output, and a rejection carries a reason. The `event` record pairs each signal with an attempt ID.
18+
19+
`receive` checks that ID first, then matches the tuple `(call.status, event.signal)`. Each arm specifies the state and signal it accepts. A deadline moves only `Pending` to `Unresolved`. A matching final reply can settle either state.
20+
21+
Assume replies have already been validated and authenticated, and every new attempt gets a fresh ID that is never reused. The types below describe events after that boundary.
22+
23+
Open the example with the external-link icon on the ReScript tab and select stable [ReScript 12.3.1](https://github.com/rescript-lang/rescript/releases/tag/v12.3.1). Run it in the playground to compare with the Output tab.
24+
25+
<CodeTab labels={["ReScript", "Output"]}>
26+
27+
```res example
28+
type status =
29+
| Pending
30+
| Unresolved
31+
| Completed(string)
32+
| Rejected(string)
33+
34+
type call = {attemptId: string, status: status}
35+
type signal = Deadline | CompletedReply(string) | RejectedReply(string)
36+
type event = {attemptId: string, signal: signal}
37+
38+
let receive = (call: call, event) => {
39+
if call.attemptId !== event.attemptId {
40+
call
41+
} else {
42+
switch (call.status, event.signal) {
43+
| (Pending, Deadline) => {...call, status: Unresolved}
44+
| (Pending, CompletedReply(output))
45+
| (Unresolved, CompletedReply(output)) => {...call, status: Completed(output)}
46+
| (Pending, RejectedReply(reason))
47+
| (Unresolved, RejectedReply(reason)) => {...call, status: Rejected(reason)}
48+
| (Unresolved, Deadline)
49+
| (Completed(_), _)
50+
| (Rejected(_), _) => call
51+
}
52+
}
53+
}
54+
55+
let describe = call =>
56+
switch call.status {
57+
| Pending => "Pending"
58+
| Unresolved => "Unresolved"
59+
| Completed(output) => "Completed: " + output
60+
| Rejected(reason) => "Rejected: " + reason
61+
}
62+
63+
let pending = {attemptId: "title-1", status: Pending}
64+
let waiting = pending->receive({attemptId: "title-1", signal: Deadline})
65+
waiting->describe->Console.log
66+
let stale = waiting->receive({attemptId: "title-0", signal: CompletedReply("Old title")})
67+
stale->describe->Console.log
68+
let saved = stale->receive({attemptId: "title-1", signal: CompletedReply("Title saved")})
69+
saved->describe->Console.log
70+
saved->receive({attemptId: "title-1", signal: RejectedReply("Too late")})
71+
->describe->Console.log
72+
```
73+
74+
```text
75+
Unresolved
76+
Unresolved
77+
Completed: Title saved
78+
Completed: Title saved
79+
```
80+
81+
</CodeTab>
82+
83+
## Record spread preserves earlier values
84+
85+
ReScript [record fields](../docs/manual/record.mdx) are immutable by default. `{...call, status: Unresolved}` creates a new record with the same attempt ID and a different status. The earlier `pending` value stays `Pending`, even after `waiting` becomes `Unresolved`.
86+
87+
The stale completion for `title-0` leaves `title-1` unresolved. The next completion matches `title-1` and settles it. The final rejection also matches, but the result stays completed because the terminal-state patterns return `call` unchanged. Repeated deadlines leave an unresolved attempt alone too.
88+
89+
This is a first accepted final reply policy. It keeps `Completed` and `Rejected` terminal for this client, even if later replies conflict. Conflicting replies still need reconciliation outside this function.
90+
91+
The tuple match covers every state and signal combination. In the last two patterns, `_` in the signal position matches any signal. That deliberately keeps later events from changing a final result.
92+
93+
A local timeout can't establish that the write failed, was cancelled, or rolled back. It can't establish that retrying is safe either. Before retrying the title update, check whether the first write took effect and reconcile the result with the tool.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
author: tejesh-vaish
3+
date: "2026-10-26"
4+
title: Modeling uncertain outcomes with ReScript variants
5+
badge: community
6+
description: |
7+
Use payload-carrying variants and exhaustive pattern matching in ReScript
8+
to keep a missing reply separate from a rejected update.
9+
---
10+
11+
A seller approves a product-title update. The client sends the request, then its deadline expires before a final reply arrives. The title might already have changed, so showing "Failed" could invite a retry too early.
12+
13+
I'd give that uncertainty its own ReScript constructor. In this hypothetical tool call, the client knows it stopped waiting but doesn't yet know the outcome.
14+
15+
## Variants carry the data each state needs
16+
17+
A [variant](../docs/manual/variant.mdx) gives the attempt four possible shapes. `Pending` means we're waiting. `Unresolved` means the client's deadline passed without a final reply. Neither needs a payload. `Completed(string)` carries the tool's output, and `Rejected(string)` carries the reason from a final refusal.
18+
19+
The payload belongs to its constructor. When a match reaches `Completed(output)`, `output` is a string. There's no separate optional result field to keep in sync with a status flag. An empty output is still a completion.
20+
21+
Open the example with the external-link icon on the ReScript tab and select stable [ReScript 12.3.1](https://github.com/rescript-lang/rescript/releases/tag/v12.3.1). Run it in the playground to compare with the Output tab.
22+
23+
<CodeTab labels={["ReScript", "Output"]}>
24+
25+
```res example
26+
type status =
27+
| Pending
28+
| Unresolved
29+
| Completed(string)
30+
| Rejected(string)
31+
32+
let describe = status =>
33+
switch status {
34+
| Pending => "Pending"
35+
| Unresolved => "Unresolved"
36+
| Completed(output) => `Completed: "${output}"`
37+
| Rejected(reason) => "Rejected: " + reason
38+
}
39+
40+
let outcomes = [
41+
Pending,
42+
Unresolved,
43+
Completed("Title saved"),
44+
Completed(""),
45+
Rejected("Title too long"),
46+
]
47+
48+
outcomes->Array.forEach(status => status->describe->Console.log)
49+
```
50+
51+
```text
52+
Pending
53+
Unresolved
54+
Completed: "Title saved"
55+
Completed: ""
56+
Rejected: Title too long
57+
```
58+
59+
</CodeTab>
60+
61+
## Exhaustive matches keep the cases visible
62+
63+
The constructors let ReScript infer that `describe` takes a `status`. [Pattern matching](../docs/manual/pattern-matching-destructuring.mdx) both selects the case and binds its payload. The four arms cover every constructor, so the match is exhaustive.
64+
65+
If we add a constructor to `status`, ReScript reports the missing case in `describe` because there's no catch-all. Exhaustiveness checks apply to typed ReScript values. Incoming JSON still needs validation before it can become a `status`.
66+
67+
A network error or a parse error doesn't establish a rejection. Neither does withheld approval. For the title update, `Unresolved` leaves room to check whether the write took effect before deciding whether to retry.
68+
69+
[Handling late replies with typed events and records](./handling-late-tool-replies.mdx) adds the transitions, including a completion that arrives after the deadline.

‎apps/docs/src/markdown/BlogFrontmatter.res‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ let authors = [
7272
imgUrl: "https://cdn.bsky.app/img/avatar/plain/did:plc:erifxn5qcos2zrxvogse5y5s/bafkreif6v7lrtz24vi5ekumkiwg7n7js55coekszduwhjegfmdopd7tqmi@webp",
7373
social: Bluesky("vlkpack.com"),
7474
},
75+
{
76+
username: "tejesh-vaish",
77+
fullname: "Tejesh Vaish",
78+
role: "Community Member",
79+
imgUrl: "https://avatars.githubusercontent.com/u/188414140?v=4",
80+
social: X("its_tejesh_here"),
81+
},
7582
]
7683

7784
module Badge = {

‎packages/shared/src/Path.res‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,10 @@ type t = [
6060
| #"/blog/editor-support-release-1-0"
6161
| #"/blog/enhanced-ergonomics-for-record-types"
6262
| #"/blog/first-class-dynamic-import-support"
63+
| #"/blog/handling-late-tool-replies"
6364
| #"/blog/improving-interop"
6465
| #"/blog/introducing-unified-operators"
66+
| #"/blog/modeling-uncertain-tool-call-outcomes"
6567
| #"/blog/new-rescript-logo"
6668
| #"/blog/reactive-analysis"
6769
| #"/blog/reforging-build-system"

0 commit comments

Comments
 (0)