defineApi Guide
When implementing or reviewing a FaasJS HTTP endpoint, default to defineApi.
Applicable Scenarios
- Creating a new
.api.tsmodule - Reviewing request validation, error handling, or injected helpers
- Updating routes and regenerating types after file changes
Default Workflow
- Export
default defineApi(...). - When the endpoint accepts business input, write the
schemainline indefineApiunless it is reused elsewhere. - Keep business logic direct inside
handler({ params })unless a shared boundary already exists. - Return business data directly unless protocol-level response control is required.
- After creating, renaming, or moving an API file, run
faas typesto updatesrc/.faasjs/types.d.ts. - Add a focused test with
testApi(api)(data, options?).
Minimal Example
import { defineApi } from '@faasjs/core'
import { z } from '@faasjs/utils'
export default defineApi({
schema: z.object({
name: z.string().min(1).optional(),
}),
async handler({ params }) {
return {
message: `Hello, ${params.name || 'FaasJS'}!`,
}
},
})
Rules
1. Use zod for input validation, not for internal type checks
- Use zod for validating external input (user params, config files, API payloads) at system boundaries. This is what
defineApi'sschemais for. - Do not use zod to replace
typeof/instanceof/=== nullchecks used for internal control flow. Those predicates are concise, zero-overhead, and semantically correct—zod would add code and cost with no benefit. - Zod schemas generate TypeScript types automatically, reducing boilerplate and keeping validation logic in sync with type definitions.
- Prefer defining
schemadirectly insidedefineApi. - Extract schema into a separate constant only when it is reused, shared across files, or meaningfully improves readability.
- Treat
schemaas the source of truth for external input. - If an endpoint has no business input, omit
schemainstead of defining an emptyz.object({});paramswill be typed asRecord<string, never>.
Prefer this:
export default defineApi({
schema: z.object({
id: z.coerce.number().int().positive(),
}),
async handler({ params }) {
return params.id
},
})
Instead of extracting schema early without a reuse reason.
2. Use params for business input
defineApivalidates parsed request params and passes the typed result tohandler.paramsis the parsed, validated view ofevent.params.eventkeeps the raw request payload; reach for it only when you need transport-level details or unparsed input.- Prefer
paramsover raw request fields for business logic. - Destructure only the top-level handler context as
handler({ params }); do not further destructure fields fromparams. Useparams.id,params.title, and similar property access so the source of each business value stays visible, likeprops.idin React components. - Read
event,headers, orbodyonly when transport-level behavior matters. - Let
schemacover request-shape validation at the boundary, then fail fast insidehandlerwhen domain state is invalid instead of layering extra fallback branches.
3. Choose error status deliberately
- Let Zod validation handle request-shape errors whenever possible.
- Use
HttpErrorwhen the failure is an expected client or business outcome and callers should see a non-500status. - Prefer common explicit statuses for expected failures:
400for invalid business input not covered by schema,401for unauthenticated requests,403for permission failures,404for missing scoped resources, and409for conflicts. - Use plain
throw Error(message)for unexpected internal failures or invariant breaks. A plainErrorkeeps its message in the JSON error body and responds with HTTP500. - Do not hide permission, tenant, or resource-scope failures behind broad fallback responses.
Response behavior summary:
- Zod schema failure -> validation error response from the framework
throw new HttpError({ statusCode: 409, message: 'message' })-> JSON error response with message and status409throw Error('message')-> JSON error response with message and status500
Example:
import { defineApi, HttpError } from '@faasjs/core'
import { z } from '@faasjs/utils'
export default defineApi({
schema: z.object({
title: z.string().min(1),
price: z.number().positive(),
quantity: z.number().int().positive().default(1),
}),
async handler({ params }) {
if (params.title === 'duplicate') {
throw new HttpError({
statusCode: 409,
message: 'Order title already exists',
})
}
if (params.title === 'forbidden') {
throw new HttpError({
statusCode: 403,
message: 'You cannot create this order',
})
}
if (params.title === 'explode') throw Error('Unexpected failure')
return {
id: 'demo-order',
title: params.title,
total: params.price * params.quantity,
}
},
})
4. Return business data directly by default
- Returning a plain value or object is the normal path.
- The HTTP layer will serialize it as a JSON response.
- Use
setHeader,setStatusCode,setContentType, orsetBodyonly when protocol-level control is actually needed. - If a handler returns nothing and does not set a body, the response may become
204.
5. Remember the injected HTTP helpers
defineApi handlers always receive:
paramsevent
With the HTTP plugin, handlers can also receive HTTP-related fields including:
cookiesessionheadersbodysetHeadersetContentTypesetStatusCodesetBody
Use them only when the endpoint truly needs them.
6. Support plugin-injected fields with types
If a plugin injects extra fields such as current_user, extend DefineApiInject so the handler stays type-safe.
declare module '@faasjs/core' {
interface DefineApiInject {
current_user?: {
id: number
name: string
} | null
}
}
7. Run type generation after changing APIs
After creating, renaming, or moving a .api.ts file, run:
faas types
Run this from your FaasJS app root, using the app's configured FaasJS CLI.
This updates:
src/.faasjs/types.d.ts
Do this before handing off the change, so route-to-type mappings stay in sync.
See Also
- HTTP Plugin Guide — cookie, session, and response helpers available in handlers
- Jobs Guide —
defineJobfor background work - Testing Guide — testing API endpoints with
testApi
Testing Checklist
Follow the shared Testing Guide first, then use @faasjs/dev and cover:
- success path
- invalid params ->
400 - expected business, auth, permission, missing-resource, or conflict errors via
HttpErrorwhen used - unexpected or invariant errors via plain
Error->500with the expected message - cookie/session behavior when used
Example:
import { testApi } from '@faasjs/dev'
import { describe, expect, it } from 'vite-plus/test'
import api from '../create.api'
describe('orders/api/create', () => {
const handler = testApi(api)
it('returns 400 when params are invalid', async () => {
const response = await handler({
title: '',
price: -1,
quantity: 1,
})
expect(response.statusCode).toBe(400)
expect(response.error?.message).toContain('Invalid params')
})
it('returns 409 for expected conflicts', async () => {
const response = await handler({
title: 'duplicate',
price: 10,
quantity: 1,
})
expect(response.statusCode).toBe(409)
expect(response.error?.message).toBe('Order title already exists')
})
it('returns 500 for unexpected internal failures', async () => {
const response = await handler({
title: 'explode',
price: 10,
quantity: 1,
})
expect(response.statusCode).toBe(500)
expect(response.error?.message).toBe('Unexpected failure')
})
})