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
└── ...                     # 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:

vsts-npm-auth -config .npmrc
npm install
npm run build

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.

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.

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.
  • @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, 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.

@useAuth(Scopes<["Client.Read"]> | Roles<["App.Read"]>)
namespace OrderService;

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:

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. 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 build succeeds with no warnings
  • OpenAPI emitted to infra/api/openapi

📚 Resources