The concrete friction
JavaScript exceptions destroy error type information
In JavaScript, throw is an out-of-band jump that bypasses the type system. A Promise<T> only types the success path T; rejected promises are typed as unknown or any in catch blocks. To prevent unhandled crashes, developers frequently catch all errors and return defaults like [] or null. This conflates three fundamentally different states: valid search with 0 hits, invalid client syntax (400), and upstream provider downtime (503).
An empty collection [] is a successful result with cardinality zero ([] in Success). A provider outage is an infrastructure failure (503 in Error). Erasing this distinction makes a catastrophic database outage look identical to "no issues found", misleading users and suppressing monitoring alerts.
// Vanilla TypeScript: hides an unavailable issue service
async function searchIssues(query: string) {
try {
const response = await fetch(
"/api/issues?q=" + encodeURIComponent(query)
);
if (!response.ok) throw new Error("HTTP " + response.status);
return await response.json();
} catch (_error: unknown) {
// A 503 outage now looks exactly like a successful empty search.
return [];
}
}The mental model
Success (A) and Error (E) use distinct, typed channels
Effect<A, E, R> models expected domain failures as first-class data in the E channel. searchIssues returns Effect<ReadonlyArray<string>, InvalidQuery | IssueServiceUnavailable>. An empty array [] is delivered through the success channel A. Failures are tagged objects delivered through the error channel E. Callers must handle every tagged error variant or let the compiler propagate it.
searchIssues(query): Effect<readonly string[], SearchError>
│
┌───────────────┴───────────────┐
│ │
Success channel (A) Error channel (E)
["ISSUE-101"] or [] InvalidQuery
IssueServiceUnavailable
[] means the search completed successfully with no matches.
A tagged error means the search did not complete successfully.Minimal working example
Executable source
This is the checked source file used by the course. Read it before you run it.
import { Console, Effect } from "effect"
export type InvalidQuery = {
readonly _tag: "InvalidQuery"
readonly message: string
}
export const InvalidQuery = (
fields: Omit<InvalidQuery, "_tag">
): InvalidQuery => ({
_tag: "InvalidQuery",
...fields
})
export type IssueServiceUnavailable = {
readonly _tag: "IssueServiceUnavailable"
readonly statusCode: number
}
export const IssueServiceUnavailable = (
fields: Omit<IssueServiceUnavailable, "_tag">
): IssueServiceUnavailable => ({
_tag: "IssueServiceUnavailable",
...fields
})
type SearchError = InvalidQuery | IssueServiceUnavailable
export const searchIssues = (
query: string
): Effect.Effect<ReadonlyArray<string>, SearchError> => {
if (query.trim() === "") {
return Effect.fail(InvalidQuery({ message: "Enter a search term" }))
}
if (query === "service-down") {
return Effect.fail(IssueServiceUnavailable({ statusCode: 503 }))
}
if (query === "missing") {
return Effect.succeed([])
}
return Effect.succeed(["ISSUE-101: Add typed search errors"])
}
const describeSearch = (query: string) =>
searchIssues(query).pipe(
Effect.match({
onSuccess: (issues) =>
issues.length === 0
? "empty result is success: no issues found"
: `success: ${issues.join(", ")}`,
onFailure: (error) => `failure: ${error._tag}`
})
)
await Effect.runPromise(
Effect.gen(function* () {
yield* Console.log(yield* describeSearch("effect"))
yield* Console.log(yield* describeSearch("missing"))
yield* Console.log(yield* describeSearch(""))
yield* Console.log(yield* describeSearch("service-down"))
})
)Execute and verify
Predict the result, run the command, and compare the output with the model.
pnpm exec tsx src/course/examples/errors.tsWhich outputs are successful, including the empty result? Which outputs are failures, and why should the provider outage not become an empty result?
Reveal expected output
success: ISSUE-101: Add typed search errors
empty result is success: no issues found
failure: InvalidQuery
failure: IssueServiceUnavailableError anatomy and edge cases
The blanket catchAll recovery trap
Using Effect.catchAll(() => Effect.succeed([])) to silence compiler errors by mapping every possible failure to an empty success array.
The compiler infers Effect<readonly string[], never, R>, erasing the E channel to never. At runtime, the application fails silently during 503 outages, metrics report 100% success, and the frontend displays "No matching issues" instead of a retryable error banner.
Keep distinct failure variants in the error channel E until a boundary has sufficient domain context to handle each variant. Use Effect.catchTag or Effect.match to handle specific error tags (e.g. prompt user for InvalidQuery, retry IssueServiceUnavailable).
Active lab exercise
Exercise: keep search failures distinct
Report an empty result only when the search succeeds with no matches.
pnpm exec tsx src/exercises/exercise3/exercise.test.ts starterThe harness should fail against the starter. Inspect the assertion, change the starter implementation, and run it again.
Inspect starter code
import { Effect } from "effect"
type SearchError =
| { readonly _tag: "InvalidQuery" }
| { readonly _tag: "IssueServiceUnavailable" }
const searchIssues = (
query: string
): Effect.Effect<ReadonlyArray<string>, SearchError> => {
if (query.trim() === "") {
return Effect.fail({ _tag: "InvalidQuery" })
}
if (query === "service-down") {
return Effect.fail({ _tag: "IssueServiceUnavailable" })
}
if (query === "missing") {
return Effect.succeed([])
}
return Effect.succeed(["ISSUE-101"])
}
export const describeSearch = (query: string) =>
searchIssues(query).pipe(
Effect.match({
onSuccess: (issues) =>
issues.length === 0 ? "empty result" : `success: ${issues.join(", ")}`,
onFailure: () => "empty result"
})
)Reveal reference solution
import { Effect } from "effect"
type SearchError =
| { readonly _tag: "InvalidQuery" }
| { readonly _tag: "IssueServiceUnavailable" }
const searchIssues = (
query: string
): Effect.Effect<ReadonlyArray<string>, SearchError> => {
if (query.trim() === "") {
return Effect.fail({ _tag: "InvalidQuery" })
}
if (query === "service-down") {
return Effect.fail({ _tag: "IssueServiceUnavailable" })
}
if (query === "missing") {
return Effect.succeed([])
}
return Effect.succeed(["ISSUE-101"])
}
export const describeSearch = (query: string) =>
searchIssues(query).pipe(
Effect.match({
onSuccess: (issues) =>
issues.length === 0 ? "empty result" : `success: ${issues.join(", ")}`,
onFailure: (error) => `failure: ${error._tag}`
})
)Keep the empty-result message in onSuccess for an empty array. In onFailure, include error._tag instead of returning the same success message for every failure.