Skip to content

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.

main.tsp
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.
  • @key marks the identifier used in instance routes.
models/orders.tsp
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.

routes/orders.tsp
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.

@useAuth(Scopes<["Client.Read"]> | Roles<["App.Read"]>)
namespace OrderService;
  • Scopes<[...]> checks Client.* scopes (the scp claim) — what calling applications request.
  • Roles<[...]> checks App.* roles (the roles / user-groups claims) — 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:

infra/api/openapi/openapi.v1.yaml (generated)
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-typespec imported, using SAIF.Platform; declared
  • @versioned enum defined, every member carries @added
  • WireMock @server entry uses MockingServer

Models

  • @resource and @key set 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 @route plus verb decorators
  • Query parameters passed as the Parameters template argument
  • Any Error override still includes AuthErrors and ServerError
  • No @error models defined locally

Security

  • Namespace default @useAuth pairs Scopes<> and Roles<>
  • Write and delete operations override with stronger permissions
  • Permission names follow the convention and exist in both providers

Output

  • npm run build succeeds with no warnings
  • OpenAPI emitted to infra/api/openapi

📚 Resources