npm package: @livekit/throws-transformer
Description: TypeScript transformer that enforces error handling via branded Throws<T, E> types
Author: LiveKit
Latest version: 0.1.6

# throws-transformer

A TypeScript transformer that enforces error handling via branded `Throws<T, E>` types.

## The Problem

TypeScript doesn't have checked exceptions. When you call a function, you have no compile-time knowledge of what errors it might throw:

```typescript
function parseJSON(input: string): User {
  return JSON.parse(input); // Can throw SyntaxError - nothing tells you!
}
```

## The Solution

This transformer introduces a `Throws<T, E>` branded type that encodes possible errors in the return type:

```typescript
function parseJSON(input: string): Throws<User, SyntaxError> {
  // ...
}
```

The transformer then enforces that callers either:

1. **Catch** the declared errors, or
2. **Propagate** them by declaring them in their own return type

The `Throws<T, E>` type is intended to be an internal implementation detail — your public API
surfaces normal return types, while `Throws<>` annotations are used internally to get compile-time
safety for error handling within your codebase. See
[Pattern 4: Public API Boundary](#pattern-4-public-api-boundary) for how to strip `Throws<>` at the
edge of your public API.

The `Throws<T, E>` type is entirely opt in - ie, if a function doesn't return a branded type or call
functions within that return a branded type, the check will pass. This makes gradual migration
possible.

If you need to throw inside a `Throws`-annotated function without declaring the error in the
branded type (for example, assertion-style "panic" errors), add a `// @throws-transformer ignore`
comment above the throw and the checker will skip it:

```typescript
function parseJSON(input: string): Throws<User, SyntaxError> {
  if (input.length === 0) {
    // @throws-transformer ignore
    throw new Error('Assertion failed: input was empty');
  }

  // ...
}
```

The `// @throws-transformer ignore` comment can also include an optional reason for documentation:

```typescript
// @throws-transformer ignore - assertion errors should panic
throw new Error('Unchecked error');
```

## Installation

```bash
npm install @livekit/throws-transformer typescript
```

## VS Code Setup (Recommended)

To get real-time error squiggles in VS Code:

### Step 1: Configure tsconfig.json

```json
{
  "compilerOptions": {
    "plugins": [{ "name": "@livekit/throws-transformer" }]
  }
}
```

### Step 2: Configure VS Code to use workspace TypeScript

Create or update `.vscode/settings.json`:

```json
{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true
}
```

### Step 3: Select TypeScript version

1. Open any `.ts` file
2. Click the TypeScript version in the bottom-right status bar (e.g., "TypeScript 5.0.0")
3. Select "Use Workspace Version"

Or run the command: `TypeScript: Select TypeScript Version...`

### Step 4: Restart the TypeScript server

Run command: `TypeScript: Restart TS Server`

You should now see red squiggles for unhandled `Throws` errors!

## Build-time Errors with ts-patch

For errors during `tsc` compilation (CI, build scripts):

```bash
npm install ts-patch
npx ts-patch install
```

Add the transformer to `tsconfig.json`:

```json
{
  "compilerOptions": {
    "plugins": [
      { "name": "@livekit/throws-transformer" },
      {
        "name": "@livekit/throws-transformer/transformer",
        "transform": "@livekit/throws-transformer/transformer"
      }
    ]
  }
}
```

Now `tsc` will emit errors for unhandled throws.

## CLI Checker

For quick checks without modifying your build:

```bash
# Check specific files
npx --package @livekit/throws-transformer throws-check src/myfile.ts

# Check multiple files
npx --package @livekit/throws-transformer throws-check src/*.ts
```

## Usage

### 1. Define your functions with `Throws<T, E>`

```typescript
import { Throws } from '@livekit/throws-transformer/throws';

export class NetworkError extends Error {
  constructor(message: string = 'Network request failed') {
    super(message);
    this.name = 'NetworkError';
  }
}

export class NotFoundError extends Error {
  constructor(message: string = 'Resource not found') {
    super(message);
    this.name = 'NotFoundError';
  }
}

function fetchUser(id: string): Throws<User, NetworkError | NotFoundError> {
  if (!id) {
    throw new NotFoundError();
  }
  // ... fetch logic that might throw NetworkError
  return user;
}
```

### 2. Handle or propagate the errors

The checker will report unhandled errors:

```
Unhandled error(s) from 'fetchUser': NetworkError | NotFoundError.
Catch these errors or add 'Throws<..., NetworkError | NotFoundError>' to your function's return type.
```

## Handling Patterns

### Pattern 1: Catch and Handle

```typescript
function getUserName(id: string): string | null {
  try {
    const user = fetchUser(id);
    return user.name;
  } catch (e) {
    if (e instanceof NetworkError) {
      console.error('Network failed');
      return null;
    }
    if (e instanceof NotFoundError) {
      console.error('User not found');
      return null;
    }
    throw e; // Re-throw unknown errors
  }
}
```

### Pattern 2: Propagate in Return Type

```typescript
function fetchAndValidate(
  id: string,
): Throws<ValidatedUser, NetworkError | NotFoundError | ValidationError> {
  const user = fetchUser(id); // NetworkError | NotFoundError propagated
  const validated = validateUser(user); // ValidationError propagated
  return validated;
}
```

### Pattern 3: Partial Handling

```typescript
function fetchWithFallback(id: string): Throws<User, NetworkError> {
  try {
    return fetchUser(id);
  } catch (e) {
    if (e instanceof NotFoundError) {
      return getDefaultUser(); // Handle NotFoundError locally
    }
    throw e; // NetworkError is propagated (declared in return type)
  }
}
```

### Pattern 4: Public API Boundary

Since `Throws<T, E>` is meant to be an internal implementation detail, you'll want to strip it at
the boundary of your public API. Use a catch-and-rethrow pattern — because the caught error `e` is
typed as `unknown`, the transformer won't require you to declare it:

```typescript
// Internal function with Throws annotation
function internalFetchUser(id: string): Throws<User, NetworkError | NotFoundError> {
  if (!id) {
    throw new NotFoundError();
  }
  // ... fetch logic
  return user;
}

// Public API — clean return type, no Throws<> leaking out
export function getUser(id: string): User {
  try {
    return internalFetchUser(id);
  } catch (e) {
    throw e; // Re-throw as unknown — Throws<> is stripped at this boundary
  }
}
```

### Pattern 5: Structured Error Types

For richer error handling, you can define structured error types with reason codes, similar to
Rust's [thiserror](https://docs.rs/thiserror/latest/thiserror/) crate. This pairs well with
`Throws<>` to give callers both compile-time safety and runtime introspection:

```typescript
abstract class ReasonedError<Reason> extends Error {
  abstract readonly reason: Reason;

  // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
  cause?: unknown;

  constructor(message?: string, options?: { cause?: unknown }) {
    super(message || 'an error has occurred');
    if (typeof options?.cause !== 'undefined') {
      this.cause = options.cause;
    }
  }
}

enum PaymentErrorReason {
  InsufficientFunds = 0,
  CardDeclined = 1,
  NetworkFailure = 2,
}

class PaymentError<R extends PaymentErrorReason = PaymentErrorReason> extends ReasonedError<R> {
  readonly name = 'PaymentError';
  readonly reason: R;
  readonly reasonName: string;

  constructor(message: string, reason: R, options?: { cause?: unknown }) {
    super(message, options);
    this.reason = reason;
    this.reasonName = PaymentErrorReason[reason];
  }

  static insufficientFunds() {
    return new PaymentError('Insufficient funds', PaymentErrorReason.InsufficientFunds);
  }

  static cardDeclined() {
    return new PaymentError('Card declined', PaymentErrorReason.CardDeclined);
  }

  static networkFailure(cause?: unknown) {
    return new PaymentError('Network failure', PaymentErrorReason.NetworkFailure, {
      cause,
    });
  }
}

// Use with Throws<> — callers must handle PaymentError
function processPayment(amount: number): Throws<Receipt, PaymentError> {
  if (amount > getBalance()) {
    throw PaymentError.insufficientFunds();
  }

  let result;
  try {
    result = chargeCard(amount);
  } catch (error) {
    throw PaymentError.networkFailure(error);
  }

  return result;
}

// You can also narrow to specific reasons:
function smallPayment(
  amount: number,
): Throws<Receipt, PaymentError<PaymentErrorReason.CardDeclined>> {
  // ...
}
```

## Async Functions

Works with `Promise<Throws<T, E>>`:

```typescript
async function fetchUserAsync(id: string): Promise<Throws<User, NetworkError>> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new NetworkError();
  return res.json();
}

// ❌ Error: Unhandled NetworkError
async function getName(id: string): Promise<string> {
  const user = await fetchUserAsync(id);
  return user.name;
}

// ✅ OK: Error is handled
async function getNameSafe(id: string): Promise<string | null> {
  try {
    const user = await fetchUserAsync(id);
    return user.name;
  } catch (e) {
    if (e instanceof NetworkError) return null;
    throw e;
  }
}
```

## ThrowsPromise

`ThrowsPromise<T, E>` is a `Promise` subclass that carries error types natively. While
`Promise<Throws<T, E>>` works well for declaring async errors, the `reject` callback and `.catch()`
handler both lose type information — `reject` accepts `any`, and `.catch()` gives you `unknown`.
`ThrowsPromise` fixes this.

```typescript
import ThrowsPromise from '@livekit/throws-transformer/promise';
```

### Typed reject

The `reject` callback only accepts the declared error types:

```typescript
function fetchUser(id: string): ThrowsPromise<User, NetworkError | NotFoundError> {
  return new ThrowsPromise((resolve, reject) => {
    if (!id) {
      reject(new NotFoundError()); // OK
      return;
    }
    // reject(new TypeError()); // ← Type error: TypeError is not assignable to NetworkError | NotFoundError
    resolve(user);
  });
}
```

### Typed .catch()

The rejection reason is typed instead of `unknown`:

```typescript
async function getUserOrGuest(id: string): Promise<User> {
  return fetchUser(id).catch((e: NetworkError | NotFoundError) => {
    //                         ^ typed, not unknown
    console.warn(`Falling back to guest: ${e.message}`);
    return { id: 'guest', name: 'Guest', email: 'guest@example.com' };
  });
}
```

### Typed combinators

`ThrowsPromise.all()` and `.race()` infer the union of all error types from their inputs:

```typescript
function fetchConfig(path: string): ThrowsPromise<Config, ParseError> {
  // ...
}

async function loadDashboard(userId: string, configPath: string) {
  try {
    // Error type is automatically NetworkError | NotFoundError | ParseError
    const [user, config] = await ThrowsPromise.all([fetchUser(userId), fetchConfig(configPath)]);
    return { user, config };
  } catch (e) {
    if (e instanceof NetworkError) {
      /* ... */
    }
    if (e instanceof NotFoundError) {
      /* ... */
    }
    if (e instanceof ParseError) {
      /* ... */
    }
    throw e;
  }
}
```

### Interop with Throws

`ThrowsPromise` is fully compatible with the throws checker. Unhandled errors from a
`ThrowsPromise`-returning function are reported the same way as `Promise<Throws<T, E>>`, and errors
can be propagated via a `Throws` return type:

```typescript
// ❌ Error: Unhandled NetworkError | NotFoundError
async function unsafeGetName(id: string): Promise<string> {
  const user = await fetchUser(id);
  return user.name;
}

// ✅ OK: Errors propagated
async function safeGetUser(id: string): Promise<Throws<User, NetworkError | NotFoundError>> {
  return await fetchUser(id);
}
```

### When to use which

|                          | `Promise<Throws<T, E>>` | `ThrowsPromise<T, E>`      |
| ------------------------ | ----------------------- | -------------------------- |
| **Runtime cost**         | Zero (type-only brand)  | Minimal (Promise subclass) |
| **Typed reject**         | No (`any`)              | Yes                        |
| **Typed .catch()**       | No (`unknown`)          | Yes                        |
| **Typed .all()/.race()** | No                      | Yes                        |
| **Checker support**      | Yes                     | Yes                        |

Use `Promise<Throws<T, E>>` when you only need compile-time error tracking via the checker. Use
`ThrowsPromise<T, E>` when you also want typed rejection and catch handlers at the call site.

## API

### Types

```typescript
// Brand a return type with possible errors
type Throws<T, E extends Error = never> = T & { readonly __throws?: E };

// Extract error types from a Throws type
type ExtractErrors<T> = T extends Throws<any, infer E> ? E : never;

// Extract success type from a Throws type
type ExtractSuccess<T> = T extends Throws<infer S, any> ? S : T;
```

### Built-in Error Classes

The package includes some common error classes:

- `NetworkError`
- `NotFoundError`
- `ValidationError`
- `ParseError`

You can also define your own:

```typescript
class DatabaseError extends Error {
  constructor(message = 'Database operation failed') {
    super(message);
    this.name = 'DatabaseError';
  }
}
```

## Troubleshooting

### Errors not showing in VS Code

1. Make sure you selected "Use Workspace Version" for TypeScript
2. Run `TypeScript: Restart TS Server`
3. Check the TypeScript output panel for plugin initialization messages

### Plugin not loading

Verify the plugin is installed in `node_modules`:

```bash
ls node_modules/@livekit/throws-transformer/dist/plugin.js
```

Rebuild if necessary:

```bash
cd node_modules/@livekit/throws-transformer && npm run build
```

## Limitations

1. **Third-party libraries**: Only works with functions that use `Throws<>` annotations
2. **Dynamic throws**: Static analysis only - can't detect runtime-conditional throws
3. **VS Code only**: The language service plugin is VS Code specific (other editors may vary)

## License

Apache 2.0