Get AI summaries of any video or article — Sign up free
The most important function in my codebase thumbnail

The most important function in my codebase

Theo - t3․gg·
5 min read

Based on Theo - t3․gg's video on YouTube. If you like this content, support the original creators by watching, liking and subscribing to their content.

TL;DR

Stop treating “return type” as proof of success in TypeScript when functions can throw; thrown errors are invisible to the type system.

Briefing

TypeScript’s biggest practical weakness isn’t missing features—it’s that thrown errors and untyped failure paths let “number” look safe even when the function sometimes fails. The core fix proposed here is to stop relying on try/catch for control flow and instead make failure explicit in the type system, so callers can’t accidentally treat an errored result as valid data.

The frustration starts with a common pattern: a function that may throw still appears to return a plain value type. In the example, an async math function sometimes throws based on Math.random. TypeScript happily infers that the awaited result is a number, so downstream code proceeds as if the value always exists. When the function throws, the error bubbles to the top scope, and the whole program path can collapse—often without any local handling that would let the code recover or produce a useful message.

A naive try/catch approach “works” but quickly becomes brittle. Catch blocks force extra indentation, and when multiple async calls are chained, errors get nested and rethrown in ways that obscure where the failure happened. Even worse, rethrowing can cause the outer layer to lose context, leaving developers with generic failures and limited debugging leverage.

The proposed remedy is a small wrapper—about 25 lines—that converts thrown exceptions into a structured return value. Instead of “throw on failure,” the wrapper returns a tuple-like result: data is either the expected value or null, and error is either null or an Error object. By moving the logic into a helper function, the caller can safely branch on error before using data, and TypeScript can narrow types accordingly. The transcript emphasizes that this is a bare-minimum baseline: it doesn’t eliminate try/catch everywhere, but it prevents the most dangerous failure mode—treating “maybe failed” work as “definitely succeeded.”

From there, the discussion expands into a spectrum of error-handling strategies. “Never throw” pushes the idea further: instead of throwing, functions return result types (success vs failure) at every layer. That enables exhaustive error handling—down to specific error variants—so the system can send the right user-facing message depending on whether authentication failed, a token check failed, a rate limit hit, or a model was offline. The tradeoff is friction: async composition becomes more awkward, and developers may need to unwrap results repeatedly.

At the top of the spectrum sits Effect, described as a deeper shift in how programs are built—less “TypeScript with better types” and more a different language-like runtime model. Effect can represent failure as part of the computation itself (e.g., effect.fail vs effect.succeed), enabling safer composition without relying on exceptions. The cost is buy-in: adopting Effect requires rewiring mental models and refactoring toward functional chaining patterns.

The takeaway is pragmatic. For most teams, start by making failure explicit with a wrapper like the one shown. Then graduate to result-based approaches like never throw when layering complexity grows. Effect becomes relevant when the goal is to make errors rare by construction, at the expense of a larger migration. The message ends with a call to stop “throwing and hoping,” because as systems scale, hidden failure paths turn into reliability and debugging debt.

Cornell Notes

TypeScript often treats a function’s return type as guaranteed, even when the function can throw at runtime. That mismatch leads to code that assumes “data exists” while failures bubble up unpredictably. A practical fix is a small try/catch wrapper that converts exceptions into an explicit result shape: either data (or null) plus an error (or null). With that structure, callers can branch on error first, letting TypeScript narrow types safely. For more complex, multi-layer systems, the transcript argues for result-based patterns like never throw, and for the most rigorous approach, Effect—though it demands a bigger mental-model shift and refactor effort.

Why does TypeScript mislead developers about safety when async functions can throw?

Because thrown errors aren’t part of the function’s return type. In the example, an async function sometimes throws based on Math.random, yet TypeScript still infers the awaited value as a number. Downstream code treats it as always present, so when an error occurs, the runtime path breaks at a higher level—often outside the local call site where handling would have been possible.

What goes wrong with “just use try/catch” in nested or multi-call async code?

Catch blocks force extra indentation and can create repetitive, hard-to-follow structures when multiple awaits are involved. If errors are rethrown, outer layers may lose context about where the failure occurred. The result is brittle control flow and less precise debugging, especially when errors propagate through several layers.

How does the 25-line wrapper improve type safety and control flow?

Instead of letting exceptions escape, the wrapper returns a structured outcome: data is either the expected value or null, and error is either null or an Error object. Callers check error first; only then do they use data. This ordering enables TypeScript narrowing so code can safely avoid adding null to numbers or using missing values.

What does “never throw” add beyond the wrapper approach?

It pushes the same principle—make failure explicit—into every layer by returning result types rather than throwing. The transcript’s example uses a verify request flow where the return type is either an “ok” variant or a specific error variant (e.g., identification failed, model failed, requests too long). Because errors are returned (not thrown), the caller can do exhaustive checks and produce precise user-facing responses.

Why does Effect represent a bigger shift than never throw?

Effect treats failure as part of the computation model (e.g., effect.fail / effect.succeed) and encourages functional chaining patterns. The transcript argues it’s not merely a library tweak; it’s closer to adopting a different way of writing TypeScript. That requires rewiring how developers compose operations and handle side effects, with a higher migration cost.

Where does the transcript place the “spectrum” of error handling?

It starts with a low-effort wrapper that prevents the most dangerous misuse of return types. Next comes never throw, which improves correctness across layers and supports exhaustive error handling, but adds friction in async composition. Effect sits at the top for systems aiming to reduce errors by construction, at the cost of deeper adoption and refactoring.

Review Questions

  1. In the math example, what specific TypeScript inference leads to unsafe downstream code, and how does the wrapper change that outcome?
  2. Compare the wrapper approach and never throw: what new capability appears when errors are returned as typed variants at every layer?
  3. What tradeoffs does the transcript associate with Effect adoption compared with never throw, especially regarding readability and team buy-in?

Key Points

  1. 1

    Stop treating “return type” as proof of success in TypeScript when functions can throw; thrown errors are invisible to the type system.

  2. 2

    A small try/catch wrapper can convert exceptions into an explicit {data, error} result shape, enabling safe branching and type narrowing.

  3. 3

    Avoid nested try/catch and rethrow patterns in multi-await code paths because they obscure where failures originate and complicate recovery.

  4. 4

    For layered systems with many failure modes, prefer result-based approaches (like never throw) so callers can do exhaustive checks and return precise user-facing messages.

  5. 5

    Use a spectrum mindset: start with the wrapper baseline, move to never throw as complexity grows, and consider Effect when you want errors handled by construction.

  6. 6

    Recognize the migration cost: never throw adds unwrapping/return-type friction, while Effect requires a deeper mental-model shift and refactor effort.

  7. 7

    Don’t rely on “throw and hope” as software scales; hidden failure paths become reliability and debugging debt.

Highlights

TypeScript can infer a function returns a number even when it sometimes throws—so “number” can be a lie unless failure is represented explicitly.
The 25-line wrapper turns exceptions into a structured outcome (data or null, plus error or null), letting callers narrow types safely.
Never throw enables exhaustive, variant-specific error handling across layers—so the system can respond differently to identification failure vs model failure vs rate-limit issues.
Effect is framed as a language-like shift in how computations represent failure, not just a better try/catch pattern.
The reliability strategy is staged: wrapper → never throw → Effect, depending on how complex and layered the codebase becomes.

Topics

  • TypeScript Error Handling
  • Result Types
  • Async Composition
  • never throw
  • Effect