TypeSpec API Design¶
How to design a Forge API contract using the @saif/platform-typespec conventions library.
Scope
This guide teaches Forge's conventions, not TypeSpec. For the language itself — syntax, models, templates, decorators — read the TypeSpec documentation. For the full surface of the conventions library, see the @saif/platform-typespec reference.
📋 Overview¶
TypeSpec is the source of truth for Forge API contracts. The compiler emits an OpenAPI 3 document, which in turn drives APIM policies, mock servers, and generated clients.
You do not write raw TypeSpec REST operations. Forge ships @saif/platform-typespec, which supplies the operation templates, error models, and auth helpers that every SAIF API shares. Projects created by saif new are already written in that vocabulary — this guide extends it.
Rule of thumb: if the library has a template or model for what you need, use it. Drop to raw TypeSpec only for the gaps.
🏗️ Project Structure¶
src/{AppName}.TypeSpec/
├── package.json
├── tspconfig.yaml
├── main.tsp
├── models/
│ ├── orders.tsp
│ └── customers.tsp
├── routes/
│ ├── orders.tsp
│ └── customers.tsp
└── ... # OpenAPI output location comes from tspconfig.yaml
Keep dependency declarations in the project's package.json, not in a second version list copied from this guide. The API template's package file supplies the scaffold's dependencies and build script (tsp compile .); the package reference covers installation. Preserve the existing project's compatible dependency set when applying these examples.
tspconfig.yaml:
emit:
- '@typespec/openapi3'
options:
'@typespec/openapi3':
emitter-output-dir: '{project-root}/../../infra/api/openapi'
output-file: 'openapi.v1.yaml'
Usage Instructions¶
Open the TypeSpec project folder (for example, src/ApplicationName1.TypeSpec) in Visual Studio Code for the TypeSpec editing tools. Authenticate to the package feed and install the project's dependencies before building:
Edit the models, operations, authorization, and mocking declarations described below, then run npm run build again. The emitter configuration above writes the OpenAPI document to infra/api/openapi; use that output for deployment and client generation rather than editing it by hand.
For a watch or formatting workflow, add scripts for tsp compile . --watch or tsp format **/*.tsp to that same project package file.
📝 Service Definition¶
Every contract starts the same way: import the platform library, bring SAIF.Platform into scope, then declare the service, servers, versions, and default auth.
import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi";
import "@typespec/openapi3";
import "@typespec/versioning";
import "@saif/platform-typespec";
using TypeSpec.Http;
using TypeSpec.Rest;
using TypeSpec.OpenAPI;
using TypeSpec.Versioning;
using SAIF.Platform;
@service(#{ title: "Order Service API" })
@server("http://localhost:21000", "localhost development endpoint")
@server(
"https://myproject.wiremockapi.cloud/",
"WireMock Cloud endpoint",
MockingServer
)
@versioned(Versions)
@extension("x-mocking", true)
@useAuth(Scopes<["Client.Read"]> | Roles<["App.Read"]>)
namespace OrderService;
enum Versions {
v1,
}
import "./models/orders.tsp";
import "./routes/orders.tsp";
MockingServer, Scopes<>, and Roles<> all come from the platform library. Do not hand-write an OAuth2Auth<> block — see Authentication.
Mocking¶
The MockingServer server entry identifies the mock backend. The namespace-level @extension("x-mocking", true) above supplies the default routing choice.
Endpoint Mocking¶
Set @extension("x-mocking", true) on an operation to route it to the mocking URL, or @extension("x-mocking", false) to route it to the application backend instead. An operation's value overrides the namespace default. See API Mocking to host or record the mock behavior.
🧱 Models¶
Models are plain TypeSpec. Two conventions make the operation templates work:
@resource("name")supplies the route segment.@keymarks the identifier used in instance routes.
using TypeSpec.Rest;
using TypeSpec.Versioning;
namespace OrderService;
@doc("Represents an order in the system")
@added(Versions.v1)
@resource("orders")
model Order {
@doc("Unique identifier")
@key
@visibility(Lifecycle.Read)
id: string;
@doc("Customer who placed the order")
customerId: string;
@doc("Order line items")
@minItems(1)
items: OrderItem[];
@doc("Order status")
status: OrderStatus;
@doc("Total order amount")
@minValue(0)
total: decimal;
@doc("When the order was created")
@visibility(Lifecycle.Read)
createdAt: utcDateTime;
@doc("When the order was last updated")
@visibility(Lifecycle.Read)
updatedAt?: utcDateTime;
}
model OrderItem {
productId: string;
@minValue(1)
quantity: int32;
@minValue(0)
unitPrice: decimal;
}
@doc("Possible order states")
enum OrderStatus {
Pending,
Confirmed,
Shipped,
Delivered,
Cancelled,
}
Do not define separate create/update request models
ResourceCreate derives its body from ResourceCreateableProperties<Order>, and ResourcePartialUpdate from ResourceUpdateableProperties<Order>. Marking server-owned fields @visibility(Lifecycle.Read) is what keeps id and createdAt out of the create body. A hand-written CreateOrderRequest duplicates the resource and drifts from it.
🛤️ Routes and Operations¶
Operations are declared with is against a platform template. The template applies @autoRoute, the HTTP verb, the REST resource decorator, and the standard error union.
using TypeSpec.Http;
using TypeSpec.Rest;
using TypeSpec.Versioning;
using SAIF.Platform;
namespace OrderService;
@autoRoute
@added(Versions.v1)
@tag("Orders")
interface Orders {
get is ResourceRead<Order>;
all is ResourceList<Order>;
@useAuth(Scopes<["Client.Write"]> | Roles<["App.Write"]>)
post is ResourceCreate<Order>;
@useAuth(Scopes<["Client.Write"]> | Roles<["App.Write"]>)
put is ResourceCreateOrReplace<Order>;
@useAuth(Scopes<["Client.Write"]> | Roles<["App.Write"]>)
patch is ResourcePartialUpdate<Order>;
@useAuth(Scopes<["Client.Delete"]> | Roles<["App.Admin"]>)
delete is ResourceDelete<Order>;
}
That produces GET /orders/{id}, GET /orders, POST /orders, PUT /orders/{id}, PATCH /orders/{id}, and DELETE /orders/{id} — each with 400, 401, 403, and 5xx already documented, and 404 where applicable.
Full parameter and default tables: operation templates reference.
Query parameters¶
Pass a parameters model as the second template argument rather than writing a raw operation.
model OrderFilter {
@doc("Filter by customer")
@query
customerId?: string;
@doc("Filter by status")
@query
status?: OrderStatus;
@doc("Records to skip")
@query
skip?: int32 = 0;
@doc("Page size")
@query
@maxValue(100)
take?: int32 = 20;
}
@autoRoute
interface Orders {
all is ResourceList<Order, OrderFilter>;
}
Nested resources¶
Declare the child's parent with @parentResource. The templates then generate the nested route and include the parent key automatically.
@resource("items")
@parentResource(Order)
model OrderLine {
@key
@visibility(Lifecycle.Read)
lineId: string;
productId: string;
@minValue(1)
quantity: int32;
}
@autoRoute
interface OrderLines {
all is ResourceList<OrderLine>; // GET /orders/{id}/items
post is ResourceCreate<OrderLine>; // POST /orders/{id}/items
delete is ResourceDelete<OrderLine>; // DELETE /orders/{id}/items/{lineId}
}
Actions¶
When an operation is a verb rather than a CRUD effect on the resource, use an action template.
@autoRoute
interface Orders {
// POST /orders/{id}/cancel
cancel is ResourceAction<Order>;
// POST /orders/{id}/refund, with a request body
refund is ResourceAction<Order, {}, RefundRequest>;
}
Long-running operations¶
Use the async action templates instead of modelling a polling contract by hand. They return Accepted (202).
@autoRoute
interface Orders {
// POST /orders/import → 202
bulkImport is ResourceCollectionActionAsync<Order, {}, BulkImportRequest>;
}
❌ Error Handling¶
Every platform template already returns RFC 7807 Problem Details, so use the library's error models instead of defining your own @error models. The error models reference lists each model, its status code, and when to use it.
To add an error, restate the full union in the template's Error parameter:
@autoRoute
interface Orders {
post is ResourceCreate<
Order,
{},
CreatedResponse<Order>,
BadRequest | Conflict | AuthErrors | ServerError
>;
}
Error is a replacement, not an addition
Whatever you pass becomes the complete error union. Omitting AuthErrors or ServerError strips 401, 403, and 5xx from the OpenAPI document and from every generated client, even though APIM still returns them at runtime.
🔐 Authentication¶
Declare the service default at the namespace and tighten it per operation. Scopes<> and Roles<> cover both identity providers in one declaration, and the union means either satisfies the requirement.
Use only these helpers: do not hand-write OAuth2Auth<> or name platform-managed delegation scopes. The Auth Helpers reference covers which claim each helper checks, the per-operation override pattern, and the names you must not use. How Authentication Works explains how each provider maps the helpers to token claims.
🔢 Versioning¶
Versioning is plain TypeSpec. Add every new model, operation, and property with @added so existing versions stay stable.
@versioned(Versions)
@service(#{ title: "Order Service API" })
namespace OrderService;
enum Versions {
v1,
v2,
}
@added(Versions.v1)
@resource("orders")
model Order {
@key
id: string;
customerId: string;
@added(Versions.v2)
priority?: OrderPriority;
}
✔️ Validation¶
Field constraints are raw TypeSpec decorators and flow through to the OpenAPI schema and generated server-side validation.
model RefundRequest {
@minLength(1)
@maxLength(100)
reason: string;
@minValue(0)
amount: decimal;
@pattern("^[A-Z]{2}[0-9]{4}$")
referenceCode?: string;
}
Reusable formats are best expressed as scalars:
@doc("Email address format")
@pattern("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
scalar email extends string;
model CustomerContact {
email: email;
}
Validation failures surface as BadRequest with the offending fields in the errors array — you do not model that response yourself.
🧭 When to Drop to Raw TypeSpec¶
The library covers resource-shaped REST. Use raw TypeSpec when an operation is genuinely outside that shape:
| Situation | Approach |
|---|---|
| Non-resource endpoint with no key or collection | Raw op with explicit @route, still returning the platform error models |
| Response the wrappers don't cover (file download, streamed body) | Custom response model composed from StatusCode<N> |
| Status code the library doesn't name | Build it from StatusCode<N> and ProblemDetailsProperties |
| Non-HTTP protocol surface | Outside the library's scope |
Omit type when extending ProblemDetails
A custom model built from StatusCode<N> and ProblemDetailsProperties must omit "type" from that spread. See Building custom status models for the worked example and the Kiota and C# server-emitter details.
Even then, keep the error union. BadRequest | AuthErrors | ServerError belongs on every operation the platform serves.
📤 Generated Output¶
npm run build compiles the contract to OpenAPI at the path configured in tspconfig.yaml:
openapi: 3.0.0
info:
title: Order Service API
version: v1
paths:
/orders:
get:
operationId: Orders_all
# ...
Clients are generated from that document. Forge .NET projects usually add a <KiotaReference> to the consuming project instead of running the CLI; see Calling APIs. To run the Kiota CLI directly:
# C# client via Kiota
kiota generate -l CSharp -o ./src/MyApp.Client -d ./infra/api/openapi/openapi.v1.yaml
The SAIF.Platform.Kiota.HttpClientLibrary CLI reference maps each kiota generate option to its KiotaReference property.
✅ Checklist¶
The conventions checklist covers contract setup, models, operations, and security. Before publishing, also confirm the build output:
-
npm run buildsucceeds with no warnings - OpenAPI emitted to
infra/api/openapi
📚 Resources¶
@saif/platform-typespecreference — full library surface@saif/platform-typespecpackage README — package installation and exports- Authorization — permission model
- TypeSpec documentation — the language itself
- RFC 7807 Problem Details