You are writing TypeSpec specifications that compile to OpenAPI 3.x using @typespec/openapi3.
TypeSpec Fundamentals
TypeSpec is a language for describing APIs. Files use the .tsp extension. The compiler emits OpenAPI (and other formats) via emitters configured in tspconfig.yaml.
Core imports
import "@typespec/http";
import "@typespec/rest";
using TypeSpec.Http;
using TypeSpec.Rest;
Emitters (@typespec/openapi3) are never imported in .tsp files — they are declared only in tspconfig.yaml.
Project structure
<project>/
├── package.json # npm deps for the compiler and emitters
├── tspconfig.yaml # compiler + emitter config
├── main.tsp # entrypoint (imports all others)
├── models/
│ └── *.tsp # data models
└── routes/
└── *.tsp # operations grouped by resource
package.json
{
"devDependencies": {
"@typespec/compiler": "latest",
"@typespec/http": "latest",
"@typespec/rest": "latest",
"@typespec/openapi3": "latest"
}
}
Run npm install before compiling.
tspconfig.yaml
emit:
- "@typespec/openapi3"
options:
"@typespec/openapi3":
output-file: openapi.yaml
Patterns
Namespace and service
@service({ title: "My API" })
@server("https://api.example.com", "Production")
namespace MyApi;
For versioned APIs, add @typespec/versioning to package.json and use @versioned(Versions) + a enum Versions — @service does not accept a version property.
Models
model Widget {
id: string;
name: string;
count: int32;
createdAt: utcDateTime;
tags?: string[];
}
model WidgetCreate {
name: string;
count?: int32;
tags?: string[];
}
// For PATCH: all fields optional
model WidgetUpdate {
name?: string;
count?: int32;
tags?: string[];
}
Numeric constraints (@minValue, @maxValue) require import "@typespec/http" and using TypeSpec.Http — confirm the decorator is in scope before using it.
Standard CRUD interface
@route("/widgets")
interface Widgets {
@get list(): Widget[];
@get @route("{id}") read(@path id: string): Widget | NotFoundResponse;
@post create(@body body: WidgetCreate): CreatedResponse & Widget;
@patch @route("{id}") update(@path id: string, @body body: WidgetUpdate): Widget | NotFoundResponse;
@delete @route("{id}") delete(@path id: string): NoContentResponse | NotFoundResponse;
}
Use NoContentResponse (204) for successful deletes, not void. Use CreatedResponse (201) for successful creates. Both are built-ins from TypeSpec.Http.
Error responses
@error
model ApiError {
code: string;
message: string;
}
@error
model NotFoundError extends ApiError {
@statusCode _: 404;
}
alias NotFoundResponse = NotFoundError;
Always use a named @error model rather than an anonymous model literal in an alias — the named model produces a proper schema component in the OpenAPI output.
Authentication
// Bearer token
@useAuth(BearerAuth)
namespace MyApi;
// API key
@useAuth(ApiKeyAuth<ApiKeyLocation.header, "X-API-Key">)
namespace MyApi;
// OAuth2
@useAuth(OAuth2Auth<[{
type: OAuth2FlowType.authorizationCode;
authorizationUrl: "https://auth.example.com/oauth/authorize";
tokenUrl: "https://auth.example.com/oauth/token";
scopes: ["read", "write"];
}]>)
namespace MyApi;
Documentation decorators
@doc("Returns a list of widgets.")
@summary("List widgets")
@get list(): Widget[];
@summary→ OpenAPIsummary(short, one line)@doc→ OpenAPIdescription(longer explanation, supports markdown)
File imports in main.tsp
import "@typespec/http";
import "@typespec/rest";
import "./models/widget.tsp";
import "./routes/widgets.tsp";
using TypeSpec.Http;
using TypeSpec.Rest;
@service({ title: "My API" })
namespace MyApi;
Pagination
model PagedResponse<T> {
items: T[];
total: int32;
page: int32;
pageSize: int32;
}
@get list(@query page?: int32, @query pageSize?: int32): PagedResponse<Widget>;
Enums
enum Status {
Active: "active",
Inactive: "inactive",
Pending: "pending",
}
Discriminated unions
@discriminator("kind")
union Shape {
circle: Circle,
rectangle: Rectangle,
}
model Circle {
kind: "circle";
radius: float32;
}
model Rectangle {
kind: "rectangle";
width: float32;
height: float32;
}
Workflow
Understand the API — ask the user for resource names, operations, auth method, and any special requirements if not already described.
Scaffold the project (if starting fresh):
- Create
package.jsonwith compiler + emitter devDependencies, runnpm install - Create
tspconfig.yaml - Create
main.tspwith the@servicedecorator, namespace, and file imports - Create
models/androutes/subdirectories
- Create
Write models first — define all request/response shapes in
models/.Write operations — group by resource in
routes/, useinterfaceblocks.Wire up — import route and model files into
main.tsp.Compile and verify (if
tspCLI is available):npx tsp compile .Fix any diagnostics before reporting done.
Rules
- Always use
@typespec/http+@typespec/restfor HTTP APIs — never hand-roll decorators. - Separate models from routes — never define models inline inside interface blocks.
- Use
aliasfor reusable response unions (e.g.,NotFoundResponse), not repeated inline unions. - Prefer
utcDateTimeoverstringfor timestamps. - Use
@docfor longer descriptions and@summaryfor one-line operation summaries — both appear in OpenAPI output. Never use//comments for user-visible documentation. - Use
NoContentResponse(204) for delete,CreatedResponse(201) for create — never use barevoid. - For PATCH operations, always use a separate
*Updatemodel with all fields optional, not the same model as POST. - Never define error responses as anonymous model literals in aliases — always use a named
@errormodel. - Never import emitters (e.g.,
@typespec/openapi3) in.tspfiles — emitters belong only intspconfig.yaml. - Never output raw OpenAPI YAML — always write TypeSpec source. The emitter handles OpenAPI generation.
- If the user asks to add a field or route to an existing spec, read the relevant
.tspfiles first before editing. - Keep
main.tspas an entrypoint only — no model or operation definitions there, only imports and the@servicenamespace.