@interlandi-io/restless
v1.1.0
Published
Type-safe REST API client with Zod validation and neverthrow error handling.
Readme
@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.
Quick Example
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
npm install @interlandi-io/restless zod neverthrowUsage
Defining Schemas
Define your response shapes with Zod:
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
// 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
const api = createAPI('https://api.example.com', {
getTodos,
getTodo,
createTodo,
})Making Requests
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:
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 threwHandling Different Error Types
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
const authedEndpoint = createEndpoint('get', '/protected', Data, () => ({
headers: {
Authorization: `Bearer ${getToken()}`,
},
}))URL Override
Modify the URL before the request:
const endpoint = createEndpoint('get', '/api/users', Users, () => ({
urlOverride: (url) => {
url.searchParams.set('v', '2')
return url
},
}))