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
└── tsp-output/ # Generated output
package.json:
{
"name": "@myapp/typespec",
"version": "1.0.0",
"type": "module",
"dependencies": {
"@typespec/compiler": "^1.15.0",
"@typespec/http": "^1.15.0",
"@typespec/rest": "^0.85.0",
"@typespec/openapi3": "^1.15.0",
"@typespec/versioning": "^0.85.0",
"@saif/platform-typespec": "^1.3.0"
},
"scripts": {
"build": "tsp compile .",
"watch": "tsp compile . --watch",
"format": "tsp format **/*.tsp"
}
}
tspconfig.yaml:
emit:
- '@typespec/openapi3'
options:
'@typespec/openapi3':
emitter-output-dir: '{project-root}/../../infra/api/openapi'
output-file: 'openapi.v1.yaml'
📝 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.
🧱 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. Do not define @error models.
| You need | Use |
|---|---|
| Validation failure | BadRequest (400) — field details go in the errors array |
| Missing or invalid token | Unauthorized (401) |
| Insufficient permission | Forbidden (403) |
| Both auth failures | AuthErrors |
| Missing resource | NotFound (404) |
| Duplicate or concurrency conflict | Conflict (409) |
| Well-formed but semantically invalid | UnprocessableEntity (422) |
| Any server failure | ServerError (500–599) |
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.
The full model list is in the status-codes reference.
🔐 Authentication¶
Declare the service default at the namespace and tighten it per operation. Scopes<> and Roles<> cover both identity providers in one declaration.
Scopes<[...]>checksClient.*scopes (thescpclaim) — what calling applications request.Roles<[...]>checksApp.*roles (theroles/user-groupsclaims) — what is granted to users or applications.
The union means either satisfies the requirement.
Do not hand-write OAuth2Auth<>
A raw OAuth2Auth<> block hard-codes tenant URLs and bypasses dual-provider policy generation. Scopes<> and Roles<> already declare the flow; APIM injects the real endpoints at deployment.
Names must follow the permission naming conventions and be defined in both providers — see App Permissions. Never reference user_impersonation, user-groups, or the Experience-API access scope; the platform manages those.
🔢 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 |
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:
# C# client via Kiota
kiota generate -l CSharp -o ./src/MyApp.Client -d ./infra/api/openapi/openapi.v1.yaml
✅ Checklist¶
Contract setup
-
@saif/platform-typespecimported,using SAIF.Platform;declared -
@versionedenum defined, every member carries@added - WireMock
@serverentry usesMockingServer
Models
-
@resourceand@keyset on every resource model - Server-owned fields marked
@visibility(Lifecycle.Read) - All properties documented with
@doc - No hand-written create/update request models
Operations
- Declared with
is Resource*<…>, not raw@routeplus verb decorators - Query parameters passed as the
Parameterstemplate argument - Any
Erroroverride still includesAuthErrorsandServerError - No
@errormodels defined locally
Security
- Namespace default
@useAuthpairsScopes<>andRoles<> - Write and delete operations override with stronger permissions
- Permission names follow the convention and exist in both providers
Output
-
npm run buildsucceeds with no warnings - OpenAPI emitted to
infra/api/openapi
📚 Resources¶
@saif/platform-typespecreference — full library surface- TypeSpec Contracts — project setup and build workflow
- Authorization — permission model
- TypeSpec documentation — the language itself
- RFC 7807 Problem Details