npm package: @interlandi-io/restless
Description: Type-safe REST API client with Zod validation and neverthrow error handling.
Author: Michael Interlandi
Latest version: 1.1.0

# @interlandi-io/restless

> **Beta**: This library is in active development. Features may not work as expected. APIs may change.

Type-safe REST API client with Zod validation and neverthrow error handling.

This library is a love letter to [tRPC](https://github.com/trpc/trpc).

## Quick Example

```typescript
import { createAPI, createEndpoint as e } from '@interlandi-io/restless'
import z from 'zod'
import { ok, err } from 'neverthrow'

const User = z.object({
    id: z.number(),
    name: z.string(),
    email: z.string().email(),
})

const map = {
    getUsers: e(
        'get',
        '/users',
        z.object({
            users: z.array(User),
        }),
    ),
}

const api = createAPI<typeof map>('https://api.example.com', map)

// TypeScript knows the response shape automatically
const result = await api.req('getUsers')

if (result.isOk()) {
    // result.value is typed as User
    console.log(result.value.users[0].name) // autocomplete works!
} else {
    // result.error is typed as ResponseError with detailed error info
    console.log(result.error.kind) // 'HTTPError' | 'SchemaError' | 'JSONParseError' | ...
}
```

## Why Restless?

- **Type-safe**: Response types are inferred from your Zod schemas — no more `any`
- **Validated**: Responses are validated against Zod schemas at runtime
- **Error handling**: Powered by `neverthrow` — every error case is handled explicitly
- **Composable**: Build endpoints once, reuse them across API clients

## Installation

```bash
npm install @interlandi-io/restless zod neverthrow
```

## Usage

### Defining Schemas

Define your response shapes with Zod:

```typescript
const Todo = z.object({
    id: z.number(),
    title: z.string(),
    completed: z.boolean(),
    dueDate: z.string().datetime().optional(),
})

const TodoList = z.array(Todo)
```

### Creating Endpoints

```typescript
// Simple GET endpoint
const getTodos = createEndpoint('get', '/todos', TodoList)

// Endpoint with URL parameters
const getTodo = createEndpoint('get', '/todos/:id', Todo, (id: number) => ({
    urlParams: { id: id.toString() },
}))

// Endpoint with query parameters
const searchTodos = createEndpoint(
    'get',
    '/todos/search',
    TodoList,
    (query: string) => ({
        urlParams: { q: query },
    }),
)

// POST endpoint
const createTodo = createEndpoint(
    'post',
    '/todos',
    Todo,
    (data: z.infer<typeof Todo>) => ({
        body: JSON.stringify(data),
    }),
)
```

### Building an API Client

```typescript
const api = createAPI('https://api.example.com', {
    getTodos,
    getTodo,
    createTodo,
})
```

### Making Requests

```typescript
const result = await api.req('getTodos')

if (result.isOk()) {
    const todos = result.value
    // todos is typed as TodoList
} else {
    handleError(result.error)
}
```

## Error Handling

All errors are typed and exhaustive. The `ResponseError` union includes:

```typescript
type ResponseError =
    | HTTPError // Server returned non-2xx status
    | SchemaError // Response didn't match Zod schema
    | JSONParseError // Response wasn't valid JSON
    | ConfigBuilderError // Your config builder threw
    | URLOverrideError // URL override function threw
```

### Handling Different Error Types

```typescript
const result = await api.req('getTodo', 1)

if (result.isErr()) {
    const error = result.error

    switch (error.kind) {
        case 'HTTPError':
            console.log(`Server error: ${error.status} ${error.statusText}`)
            // error.text contains the raw response body
            break
        case 'SchemaError':
            console.log('Response shape invalid:', error.inner.issues)
            break
        case 'JSONParseError':
            console.log('Invalid JSON in response')
            break
        // TypeScript ensures all cases are handled
    }
}
```

## Advanced

### Custom Headers

```typescript
const authedEndpoint = createEndpoint('get', '/protected', Data, () => ({
    headers: {
        Authorization: `Bearer ${getToken()}`,
    },
}))
```

### URL Override

Modify the URL before the request:

```typescript
const endpoint = createEndpoint('get', '/api/users', Users, () => ({
    urlOverride: (url) => {
        url.searchParams.set('v', '2')
        return url
    },
}))
```