Skip to content

Commit 7f7d79e

Browse files
konardclaude
andcommitted
feat(tests): add type-tests and strict error handling examples
Addresses blocking review requirements: 1. Use Match.exhaustive instead of Match.orElse in examples - Forces handling ALL schema-defined HTTP error statuses - No escape hatch that could mask unhandled cases 2. Add type-tests proving literal union preservation - expectTypeOf tests verify status is literal (200, 404, 500) not number - Proves HttpError status is union from schema (404 | 500), not number 3. Add @ts-expect-error negative tests - Proves success status cannot be error status (e.g., 200 !== 404) - Proves error status cannot be success status 4. Add strict-error-handling.ts example with E=never - Demonstrates Effect<void, never, HttpClient> after catchTags - All _tag variants handled: HttpError, TransportError, UnexpectedStatus, UnexpectedContentType, ParseError, DecodeError - Match.exhaustive in all HttpError handlers This commit proves: - Type correlation (status -> body) is preserved - Literal union types don't degrade to 'number' - E=never is achievable with proper error handling Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 47807b3 commit 7f7d79e

3 files changed

Lines changed: 437 additions & 3 deletions

File tree

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// CHANGE: Strict example demonstrating forced E=never error handling
2+
// WHY: Prove that after catchTags with Match.exhaustive, the error channel becomes 'never'
3+
// QUOTE(ТЗ): "Приёмка по смыслу: после catchTags(...) тип ошибки становится never"
4+
// REF: PR#3 blocking review from skulidropek
5+
// SOURCE: n/a
6+
// PURITY: SHELL
7+
// EFFECT: Effect<void, never, HttpClient> - all errors handled
8+
9+
import * as FetchHttpClient from "@effect/platform/FetchHttpClient"
10+
import type * as HttpClient from "@effect/platform/HttpClient"
11+
import { Console, Effect, Exit, Match } from "effect"
12+
import { createClient, type ClientOptions } from "../src/shell/api-client/create-client.js"
13+
import { dispatchercreatePet, dispatchergetPet, dispatcherlistPets } from "../src/generated/dispatch.js"
14+
import type { Paths } from "../tests/fixtures/petstore.openapi.js"
15+
16+
/**
17+
* Client configuration
18+
*/
19+
const clientOptions: ClientOptions = {
20+
baseUrl: "https://petstore.example.com",
21+
credentials: "include"
22+
}
23+
24+
const apiClient = createClient<Paths>(clientOptions)
25+
26+
// =============================================================================
27+
// STRICT EXAMPLE 1: getPet - handles 404, 500 + all boundary errors
28+
// =============================================================================
29+
30+
/**
31+
* CRITICAL: This program has E=never - all errors are explicitly handled!
32+
*
33+
* The reviewer requires:
34+
* 1. Only Match.exhaustive (no Match.orElse)
35+
* 2. All _tag variants handled via catchTags
36+
* 3. After catchTags, type becomes Effect<void, never, HttpClient>
37+
*
38+
* Schema: getPet has responses 200 (success), 404 (error), 500 (error)
39+
* Error channel: HttpError<404 | 500> | BoundaryError
40+
*
41+
* @invariant After catchTags, E = never
42+
* @effect Effect<void, never, HttpClient>
43+
*/
44+
export const getPetStrictProgram: Effect.Effect<void, never, HttpClient.HttpClient> = Effect.gen(function*() {
45+
yield* Console.log("=== getPet: Strict Error Handling ===")
46+
47+
// Execute request - yields only on 200
48+
const result = yield* apiClient.GET(
49+
"/pets/{petId}",
50+
dispatchergetPet,
51+
{ params: { petId: "123" } }
52+
)
53+
54+
// Success! TypeScript knows status is 200
55+
yield* Console.log(`Got pet: ${result.body.name}`)
56+
}).pipe(
57+
// Handle HttpError with EXHAUSTIVE matching (no orElse!)
58+
Effect.catchTag("HttpError", (error) =>
59+
Match.value(error.status).pipe(
60+
Match.when(404, () => Console.log(`Not found: ${JSON.stringify(error.body)}`)),
61+
Match.when(500, () => Console.log(`Server error: ${JSON.stringify(error.body)}`)),
62+
// CRITICAL: Match.exhaustive - forces handling ALL schema statuses
63+
// If a new status (e.g., 401) is added to schema, this will fail typecheck
64+
Match.exhaustive
65+
)),
66+
// Handle ALL boundary errors
67+
Effect.catchTag("TransportError", (e) => Console.log(`Transport error: ${e.error.message}`)),
68+
Effect.catchTag("UnexpectedStatus", (e) => Console.log(`Unexpected status: ${e.status}`)),
69+
Effect.catchTag("UnexpectedContentType", (e) => Console.log(`Unexpected content-type: ${e.actual}`)),
70+
Effect.catchTag("ParseError", (e) => Console.log(`Parse error: ${e.error.message}`)),
71+
Effect.catchTag("DecodeError", (e) => Console.log(`Decode error: ${e.error.message}`))
72+
)
73+
74+
// =============================================================================
75+
// STRICT EXAMPLE 2: createPet - handles 400, 500 + all boundary errors
76+
// =============================================================================
77+
78+
/**
79+
* createPet strict handler
80+
*
81+
* Schema: createPet has responses 201 (success), 400 (error), 500 (error)
82+
* Error channel: HttpError<400 | 500> | BoundaryError
83+
*
84+
* @invariant After catchTags, E = never
85+
* @effect Effect<void, never, HttpClient>
86+
*/
87+
export const createPetStrictProgram: Effect.Effect<void, never, HttpClient.HttpClient> = Effect.gen(function*() {
88+
yield* Console.log("=== createPet: Strict Error Handling ===")
89+
90+
const result = yield* apiClient.POST(
91+
"/pets",
92+
dispatchercreatePet,
93+
{
94+
body: JSON.stringify({ name: "Fluffy", tag: "cat" }),
95+
headers: { "Content-Type": "application/json" }
96+
}
97+
)
98+
99+
// Success! TypeScript knows status is 201
100+
yield* Console.log(`Created pet: ${result.body.id}`)
101+
}).pipe(
102+
// Handle HttpError with EXHAUSTIVE matching
103+
Effect.catchTag("HttpError", (error) =>
104+
Match.value(error.status).pipe(
105+
Match.when(400, () => Console.log(`Validation error: ${JSON.stringify(error.body)}`)),
106+
Match.when(500, () => Console.log(`Server error: ${JSON.stringify(error.body)}`)),
107+
// Match.exhaustive forces handling 400 AND 500
108+
Match.exhaustive
109+
)),
110+
// Handle ALL boundary errors
111+
Effect.catchTag("TransportError", (e) => Console.log(`Transport error: ${e.error.message}`)),
112+
Effect.catchTag("UnexpectedStatus", (e) => Console.log(`Unexpected status: ${e.status}`)),
113+
Effect.catchTag("UnexpectedContentType", (e) => Console.log(`Unexpected content-type: ${e.actual}`)),
114+
Effect.catchTag("ParseError", (e) => Console.log(`Parse error: ${e.error.message}`)),
115+
Effect.catchTag("DecodeError", (e) => Console.log(`Decode error: ${e.error.message}`))
116+
)
117+
118+
// =============================================================================
119+
// STRICT EXAMPLE 3: listPets - handles 500 + all boundary errors
120+
// =============================================================================
121+
122+
/**
123+
* listPets strict handler
124+
*
125+
* Schema: listPets has responses 200 (success), 500 (error)
126+
* Error channel: HttpError<500> | BoundaryError
127+
*
128+
* @invariant After catchTags, E = never
129+
* @effect Effect<void, never, HttpClient>
130+
*/
131+
export const listPetsStrictProgram: Effect.Effect<void, never, HttpClient.HttpClient> = Effect.gen(function*() {
132+
yield* Console.log("=== listPets: Strict Error Handling ===")
133+
134+
const result = yield* apiClient.GET(
135+
"/pets",
136+
dispatcherlistPets,
137+
{ query: { limit: 10 } }
138+
)
139+
140+
// Success! TypeScript knows status is 200
141+
yield* Console.log(`Got ${result.body.length} pets`)
142+
}).pipe(
143+
// Handle HttpError with EXHAUSTIVE matching
144+
Effect.catchTag("HttpError", (error) =>
145+
Match.value(error.status).pipe(
146+
Match.when(500, () => Console.log(`Server error: ${JSON.stringify(error.body)}`)),
147+
// Match.exhaustive - only 500 needs handling for listPets
148+
Match.exhaustive
149+
)),
150+
// Handle ALL boundary errors
151+
Effect.catchTag("TransportError", (e) => Console.log(`Transport error: ${e.error.message}`)),
152+
Effect.catchTag("UnexpectedStatus", (e) => Console.log(`Unexpected status: ${e.status}`)),
153+
Effect.catchTag("UnexpectedContentType", (e) => Console.log(`Unexpected content-type: ${e.actual}`)),
154+
Effect.catchTag("ParseError", (e) => Console.log(`Parse error: ${e.error.message}`)),
155+
Effect.catchTag("DecodeError", (e) => Console.log(`Decode error: ${e.error.message}`))
156+
)
157+
158+
// =============================================================================
159+
// MAIN: Run all strict programs
160+
// =============================================================================
161+
162+
/**
163+
* Main program combines all strict examples
164+
* Type annotation proves E=never: Effect<void, never, HttpClient>
165+
*/
166+
const mainProgram: Effect.Effect<void, never, HttpClient.HttpClient> = Effect.gen(function*() {
167+
yield* Console.log("========================================")
168+
yield* Console.log(" Strict Error Handling Examples")
169+
yield* Console.log(" (All have E=never)")
170+
yield* Console.log("========================================\n")
171+
172+
// All these programs have E=never - errors fully handled
173+
yield* getPetStrictProgram
174+
yield* Console.log("")
175+
176+
yield* createPetStrictProgram
177+
yield* Console.log("")
178+
179+
yield* listPetsStrictProgram
180+
181+
yield* Console.log("\n========================================")
182+
yield* Console.log(" All errors handled - E=never verified!")
183+
yield* Console.log("========================================")
184+
})
185+
186+
/**
187+
* Execute the program
188+
*
189+
* CRITICAL: Since mainProgram has E=never, Effect.runPromiseExit
190+
* will never fail with a typed error - only defects are possible.
191+
*/
192+
const program = mainProgram.pipe(
193+
Effect.provide(FetchHttpClient.layer)
194+
)
195+
196+
const main = async () => {
197+
const exit = await Effect.runPromiseExit(program)
198+
if (Exit.isFailure(exit)) {
199+
// This can only be a defect (unexpected exception), not a typed error
200+
console.error("Unexpected defect:", exit.cause)
201+
}
202+
}
203+
204+
main()

packages/app/examples/test-create-client.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const listAllPetsExample = Effect.gen(function*() {
7272
* Example program: Get specific pet
7373
*
7474
* Demonstrates handling multiple HTTP error statuses (404, 500).
75+
* Uses Match.exhaustive to force handling ALL schema-defined statuses.
7576
*
7677
* @pure false - performs HTTP request
7778
*/
@@ -92,12 +93,13 @@ const getPetExample = Effect.gen(function*() {
9293
yield* Console.log(`Success: Got pet "${result.body.name}"`)
9394
yield* Console.log(` Tag: ${result.body.tag ?? "none"}`)
9495
}).pipe(
95-
// Handle HTTP errors using Match for exhaustive pattern matching
96+
// Handle HTTP errors using Match.exhaustive - forces handling ALL schema statuses
97+
// CRITICAL: Match.exhaustive, not Match.orElse!
9698
Effect.catchTag("HttpError", (error) =>
9799
Match.value(error.status).pipe(
98100
Match.when(404, () => Console.log(`Not found: ${JSON.stringify(error.body)}`)),
99101
Match.when(500, () => Console.log(`Server error: ${JSON.stringify(error.body)}`)),
100-
Match.orElse(() => Console.log(`Unexpected HTTP error: ${error.status}`))
102+
Match.exhaustive // Forces handling all 404 | 500 - no escape hatch
101103
)),
102104
Effect.catchTag("TransportError", (error) =>
103105
Console.log(`Transport error: ${error.error.message}`))
@@ -107,6 +109,7 @@ const getPetExample = Effect.gen(function*() {
107109
* Example program: Create new pet
108110
*
109111
* Demonstrates handling validation errors (400).
112+
* Uses Match.exhaustive to force handling ALL schema-defined statuses.
110113
*
111114
* @pure false - performs HTTP request
112115
*/
@@ -134,11 +137,12 @@ const createPetExample = Effect.gen(function*() {
134137
yield* Console.log(` Name: ${result.body.name}`)
135138
}).pipe(
136139
// Handle HTTP errors - FORCED by TypeScript!
140+
// CRITICAL: Match.exhaustive, not Match.orElse!
137141
Effect.catchTag("HttpError", (error) =>
138142
Match.value(error.status).pipe(
139143
Match.when(400, () => Console.log(`Validation error: ${JSON.stringify(error.body)}`)),
140144
Match.when(500, () => Console.log(`Server error: ${JSON.stringify(error.body)}`)),
141-
Match.orElse(() => Console.log(`Unexpected HTTP error: ${error.status}`))
145+
Match.exhaustive // Forces handling all 400 | 500 - no escape hatch
142146
)),
143147
Effect.catchTag("TransportError", (error) =>
144148
Console.log(`Transport error: ${error.error.message}`))

0 commit comments

Comments
 (0)