make-service
A type-safe fetch wrapper for building HTTP service clients. Zero dependencies, built on the Standard Schema spec for runtime validation with Zod, Arktype, or any compatible library.
Core API
makeService(baseURL, options?)
Creates an object with HTTP methods bound to a base URL.
import { makeService } from 'make-service'
const api = makeService('https://api.example.com/v1', {
headers: { 'content-type': 'application/json' },
})
const response = await api.get('/users')
const response = await api.post('/users', { body: { name: 'John' } })
const response = await api.put('/users/:id', { params: { id: '1' }, body: { name: 'Jane' } })
const response = await api.delete('/users/:id', { params: { id: '1' } })
const response = await api.patch('/users/:id', { params: { id: '1' }, body: { active: false } })
Available methods: get, post, put, delete, patch, head, options, connect.
makeFetcher(baseURL, options?)
Like makeService but returns a single function that accepts method in requestInit.
const fetcher = makeFetcher('https://api.example.com')
const response = await fetcher('/users', { method: 'POST', body: { name: 'John' } })
enhancedFetch(url, requestInit?)
Enhanced fetch without base URL binding. Same typed response behavior.
const response = await enhancedFetch('https://api.example.com/users')
typedResponse(response)
Wraps a native Response to add typed json() and text() methods.
const typed = typedResponse(nativeResponse)
const data = await typed.json() // unknown (not any!)
BaseOptions
type BaseOptions = {
headers?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>)
requestTransformer?: (request: EnhancedRequestInit) => EnhancedRequestInit | Promise<EnhancedRequestInit>
responseTransformer?: (response: TypedResponse) => TypedResponse | Promise<TypedResponse>
}
Dynamic headers
Pass a function (sync or async) to compute headers per request:
const api = makeService('https://api.example.com', {
headers: async () => ({
authorization: `Bearer ${await getToken()}`,
}),
})
Request transformer
Runs before headers are merged. Transform the body, add signals, etc:
import { deepSnakeKeys } from 'string-ts'
const api = makeService('https://api.example.com', {
requestTransformer: (request) => ({
...request,
body: deepSnakeKeys(request.body),
}),
})
Response transformer
Transform responses after fetch completes:
import { camelKeys } from 'string-ts'
const api = makeService('https://api.example.com', {
responseTransformer: (response) => {
const headers = camelKeys(Object.fromEntries(response.headers))
// extract tokens, update sessions, etc.
return response
},
})
Request Options (ServiceRequestInit)
Path params
URL segments with :param syntax are type-checked:
await api.get('/users/:id/posts/:postId', {
params: { id: '1', postId: '42' },
})
// Resolves to: /users/1/posts/42
Query params
await api.get('/users', { query: { page: '2', limit: '10' } })
// Resolves to: /users?page=2&limit=10
Accepts Record<string, string>, URLSearchParams, string, or entries array. Merges with existing query strings in the path.
Body
JSON-like objects are auto-stringified. FormData, Blob, ReadableStream pass through unchanged:
await api.post('/users', { body: { name: 'John', age: 30 } })
await api.post('/upload', { body: formData })
Trace
Debug hook called after fetch completes:
await api.get('/users', {
trace: (url, init, response) => {
console.log(`${init.method} ${url} → ${response.status}`)
},
})
Headers per request
Request-level headers merge with (and override) base headers. Set a value to undefined or 'undefined' to remove a base header:
await api.get('/public', { headers: { authorization: undefined } })
Typed Responses
All methods return TypedResponse — a Response with enhanced json() and text():
Generic type cast (no validation)
const users = await response.json<User[]>()
Schema validation (runtime safe)
Pass any Standard Schema-compatible schema (Zod, Arktype, etc.):
import { z } from 'zod'
const users = await response.json(
z.array(z.object({ id: z.number(), name: z.string() }))
)
On validation failure, throws ParseResponseError with .issues array.
Schema with transform
import { deepCamelKeys } from 'string-ts'
const data = await response.json(
z.object({
first_name: z.string(),
last_name: z.string(),
}).transform(deepCamelKeys)
)
// data is { firstName: string, lastName: string }
Text with types
const email = await response.text<`${string}@${string}`>()
const validated = await response.text(z.string().email())
Utility Functions
mergeHeaders(...entries)
Merge multiple HeadersInit into one Headers object:
const headers = mergeHeaders(
{ 'content-type': 'application/json' },
new Headers({ authorization: 'Bearer token' }),
)
addQueryToURL(url, params)
addQueryToURL('/users', { page: '1' }) // '/users?page=1'
addQueryToURL('/users?admin=true', { page: '1' }) // '/users?admin=true&page=1'
replaceURLParams(url, params)
replaceURLParams('/users/:id', { id: '42' }) // '/users/42'
makeGetApiURL(baseURL)
Factory for building full URLs:
const getApiURL = makeGetApiURL('https://api.example.com')
getApiURL('/users', { page: '1' }) // 'https://api.example.com/users?page=1'
ensureStringBody(body)
Stringifies JSON-like objects, passes through FormData/Blob/etc. unchanged.
ParseResponseError
Thrown on schema validation failure:
import { ParseResponseError } from 'make-service'
try {
const data = await response.json(schema)
} catch (error) {
if (error instanceof ParseResponseError) {
console.log(error.issues) // [{ message: '...', path: [...] }]
}
}
Patterns and Real-World Usage
For detailed patterns combining make-service with string-ts, composable-functions, and authentication flows, see references/patterns.md.
Source: gustavoguichard/make-service — distributed by TomeVault.