Migrating From openapi-typescript and openapi-fetch
APIful replaces both packages: apiful generate writes paths and components interfaces in openapi-typescript's shape, and a client typed from them calls your API through ofetch. Type code built on paths carries over. The calls change shape, and a failed request throws instead of returning an error.
Generate the Types With apiful generate
Install APIful, and remove openapi-typescript and openapi-fetch once nothing imports them anymore:
pnpm add apiful
pnpm remove openapi-typescript openapi-fetchyarn add apiful
yarn remove openapi-typescript openapi-fetchnpm install apiful
npm uninstall openapi-typescript openapi-fetchAPIful supports Node 24 or later, and its types require TypeScript 5.9 or later. apiful generate never loads the typescript package, so it runs the same with TypeScript 5.9, 6.0, or 7.0 installed, and both the generated types and the ones APIful ships check against all three.
APIful reads its schemas from an apiful.config.ts rather than the command line. Each one is a service with a name, and your imports and clients refer to it by that name:
# Before
npx openapi-typescript ./openapi/pet-store.yaml -o ./src/types/pet-store.d.ts// apiful.config.ts
import { defineApifulConfig } from 'apiful/config'
export default defineApifulConfig({
services: {
petStore: {
schema: './openapi/pet-store.yaml',
},
},
})# After
npx apiful generateschema takes a file path, a URL, a document object, or a function resolving to one. The command writes apiful.d.ts next to the configuration – commit it, and run apiful generate --check in CI to catch a schema change that landed without it. The CLI guide covers both, and how to split the output into one file per service.
The generator takes no options. Flags such as --enum, --immutable, or --root-types have no counterpart, and the output follows openapi-typescript's defaults, apart from the differences listed at the end of this page.
Import paths and components From apiful/schema/<service>
Each service's types live in a module named after it, so imports change their source and nothing else:
// Before
import type { components, paths } from './types/pet-store'// After
import type { components, paths } from 'apiful/schema/petStore'paths keeps openapi-typescript's shape – paths['/pet/{petId}']['get']['parameters']['path'] resolves as it did. The generated module exports no operations, webhooks, or $defs types.
Reach for the Type Helpers
apiful/schema exports a set of helpers per service, named after it, that shorten the lookups you wrote against paths:
| openapi-typescript | APIful |
|---|---|
components['schemas']['Pet'] | PetStoreModel<'Pet'> |
paths['/pet/{petId}']['get']['parameters']['path'] | PetStore<'/pet/{petId}', 'get'>['path'] |
paths['/pet/findByStatus']['get']['parameters']['query'] | PetStore<'/pet/findByStatus', 'get'>['query'] |
paths['/pet']['post']['requestBody']['content']['application/json'] | PetStore<'/pet', 'post'>['operation']['requestBody']['content']['application/json'] |
paths['/pet/{petId}']['get']['responses'][200]['content']['application/json'] | PetStore<'/pet/{petId}', 'get'>['response'] |
operations['getPetById'] | PetStore<'/pet/{petId}', 'get'>['operation'] |
response is the body of whichever media type and 2xx status the operation declares, so it needs no status code or content type. request is likewise the union of the bodies of every media type the operation accepts – index operation by its media type for one alone, as the table does for /pet. OpenAPI Type Helpers covers the rest, such as PetStoreApiPaths for the union of every path.
Declare Your Own Aliases Instead of --root-types
--root-types exported each component as a named type, such as SchemaPet. APIful has no counterpart – declare an alias for each one you use, in one module your code imports from:
import type { PetStoreModel } from 'apiful/schema'
export type Pet = PetStoreModel<'Pet'>
export type Category = PetStoreModel<'Category'>Create the Client
createOpenAPIClient takes the service name where openapi-fetch took the paths type:
// Before
import type { paths } from './types/pet-store'
import createClient from 'openapi-fetch'
const client = createClient<paths>({ baseUrl: 'https://petstore3.swagger.io/api/v3' })// After
import { createOpenAPIClient } from 'apiful/openapi'
const petStore = createOpenAPIClient<'petStore'>({
baseURL: 'https://petstore3.swagger.io/api/v3',
})The options are ofetch's, so baseUrl becomes baseURL. A name that is not in your configuration fails to compile. For a client that also carries other extensions, add openAPIBuilder to createClient instead – the types are the same.
Call an Endpoint
The client is a function. The path comes first, the method moves into the options, and the parameters move up a level:
// Before
const { data } = await client.GET('/pet/{petId}', {
params: {
path: { petId: 1 }
},
})// After
const pet = await petStore('/pet/{petId}', {
method: 'GET',
path: { petId: 1 },
})| openapi-fetch | APIful |
|---|---|
client.GET(path, …) | petStore(path, { method: 'GET', … }) |
params.path | path |
params.query | query |
params.header | headers |
params.cookie | No counterpart – set a Cookie header |
body | body |
parseAs | responseType |
querySerializer | onRequest hook |
client.use(middleware) | ofetch hooks |
method can go where the path declares get, and a body is typed from the operation – the OpenAPI extension covers which options a call needs and how a body is sent:
const pets = await petStore('/pet/findByStatus', {
query: { status: 'available' },
})
const pet = await petStore('/pet', {
method: 'POST',
body: { name: 'Rex', photoUrls: [] },
})Catch Errors Instead of Reading error
openapi-fetch resolves a request to { data, error, response }. An APIful client resolves to the body of the success response, and a 4xx or 5xx response throws a FetchError from ofetch, with the parsed body on data. FetchResponseError types that body from the schema:
// Before
const { data, error } = await client.GET('/pet/{petId}', {
params: {
path: { petId: 1 }
},
})
if (error) {
// …
}// After
import type { FetchResponseError } from 'apiful/openapi'
import type { PetStore } from 'apiful/schema'
try {
const pet = await petStore('/pet/{petId}', {
path: { petId: 1 }
})
}
catch (caught) {
const { status, data } = caught as FetchResponseError<PetStore<'/pet/{petId}', 'get'>['operation']>
}For the error body on its own – what openapi-fetch typed as error – use PetStore<'/pet/{petId}', 'get'>['error']. The OpenAPI extension explains the cast and the error types.
Move Middleware to ofetch Hooks
openapi-fetch middleware becomes ofetch hooks in the client options, which run on every request. A hook mutates the request options in place rather than returning a new Request:
// Before
client.use({
onRequest({ request }) {
request.headers.set('Authorization', `Bearer ${getAuthToken()}`)
return request
},
})// After
const petStore = createOpenAPIClient<'petStore'>({
baseURL: 'https://petstore3.swagger.io/api/v3',
onRequest({ options }) {
options.headers.set('Authorization', `Bearer ${getAuthToken()}`)
},
onResponseError({ response }) {
console.error(response.status, response._data)
},
})ofetch has four hooks: onRequest, onRequestError, onResponse, and onResponseError. A hook passed to a single call replaces the client's hook of the same name rather than running after it. Authenticating Requests builds a token refresh on them.
Read Other Formats With responseType
parseAs becomes ofetch's responseType, and the return type follows it:
const rawPet = await petStore('/pet/{petId}', {
path: { petId: 1 },
responseType: 'blob',
})
// ^? BlobWhere the Generated Types Differ
APIful starts from openapi-typescript's default output and changes the parts below:
- A path item holds only the methods the path declares, without the other verbs set to
neveror a path-levelparameterskey. Path-level parameters are merged into each operation. - An operation sits in
pathsitself rather than in a separateoperationsinterface. - A
format: binarystring, or one with a non-JSONcontentMediaType, isBlob | File. - A
multipart/form-datarequest body is typed asFormData, which ofetch sends as is, and anapplication/x-www-form-urlencodedone also accepts aURLSearchParams. - A required query, header, or cookie parameter whose schema declares a
defaultis optional. A path parameter stays required. - A property with a
defaultis optional unless the schema lists it asrequired, where openapi-typescript makes it required outside request bodies and parameters. - A query parameter with
application/jsoncontent and an object schema is typed as that schema – pass the object, and ofetch serializes it. - OpenAPI 3.2 operations are typed: the
querymethod, custom methods fromadditionalOperationsunder their lowercased name, and aquerystringparameter as the type of thequeryobject. componentsof an OpenAPI 3.2 document has amediaTypesgroup,neverwhere the document declares none.- A discriminator child inlines the parent's members into its
Omit<…>rather than referencing the parent. - A
$refinto a member of a schema that may be absent indexes throughNonNullable<…>, and one to an optional property excludesundefined. - An
anyOfbesidepropertiesis intersected with them, asoneOfis, and a branch that only listsrequiredkeys makes those properties required. - A schema of
type: nullbeside aoneOforanyOfis a union withnull. - A schema that contains itself as an array item without an object in between, such as a JSON value, types that array as
unknown[]– openapi-typescript's output fails to compile there. - A
prefixItemstuple takes further elements of theitemsschema, orunknownwithout one, unlessitems: falseormaxItemscloses it. - A schema's own
$defsis optional.