Guide
Build a task API
Create a task and call your API from TypeScript.
On this page
Before you start
- Bun and an empty directory for a TypeScript project.
- Learn what a contract, server, and client each do.
Build a small API that creates a task from a title. You’ll write the handler and call it from a TypeScript script, then see the new task printed in your terminal.
Everything runs in that script for now, so there’s no HTTP server to set up.
Start in an empty directory. Create a package and install the API with Zod for input validation:
bun init -ynpm init -ypnpm inityarn init -ybun add @hulla/api@2.0.0 zodnpm install @hulla/api@2.0.0 zodpnpm add @hulla/api@2.0.0 zodyarn add @hulla/api@2.0.0 zodCreate src/api/ for the three files below. This guide uses Bun to run TypeScript; you can use any of the package managers above to install dependencies.
- 1Describe the route
Say what a task looks like and how to create one. First, describe the route in a contract. Both the client and server will import this file so they agree on what to send and what to return.
src/api/contract.tsimport { defineContract, request, response, route } from '@hulla/api' import { z } from 'zod' const newTask = z.object({ title: z.string().min(1), }) export const task = z.object({ id: z.string(), title: z.string(), completed: z.boolean(), }) export const contract = defineContract({ basePath: '/api', routes: { createTask: route.post('/tasks', { body: request.json(newTask), responses: { 201: response.json(task), }, }), }, })z.string().min(1)rejects an empty title. The namecreateTaskwill also be the name of the client method, so keep it handy for step 3. - 2Add the server behavior
Give the route a handler that creates a task. Now write the handler. It receives a validated
body, gives the task an ID, and returns it with status201(Created).src/api/implementation.tsimport { defineServer } from '@hulla/api/server' import { contract } from './contract' export const implementation = defineServer(contract).implement({ createTask: ({ body, response }) => response(201, { id: crypto.randomUUID(), title: body.title, completed: false, }), })There is one route to implement, so we need one handler. If you add another route to the contract, TypeScript will ask you to implement that one too.
- 3Call the API
Send a title and print the task you get back. Create a client from the contract and connect it to the implementation with
inProcessTransport(). This lets the script call your handler directly, so there is no web server to start.src/api/example.tsimport { createClient } from '@hulla/api/client' import { inProcessTransport } from '@hulla/api/in-process' import { contract } from './contract' import { implementation } from './implementation' const api = createClient(contract, { transport: inProcessTransport(implementation), }) const result = await api.createTask({ body: { title: 'Write the quick start' }, }) console.log(result.body)Run it from the project root:
Terminalbun src/api/example.tsThe call prints a task shaped like this:
TypeScript{ id: 'generated-id', title: 'Write the quick start', completed: false, }Change the title and run it again. The response should contain your new text and a generated ID.
Try an empty title next. Validation should reject the call before your handler creates a task.
You now have a working API call. Where you take it next depends on your app:
- Use HTTP calls with Fetch to call the API from a browser or another service.
- Choose a server adapter to serve the API with Express, Fastify, Hono, or another framework.
- Read Organizing server code when the contract grows beyond one module.
- Read Calling the API to handle responses and share client setup.