Skip to content

TypeSpec API Design

Guide for designing APIs using TypeSpec in Forge projects.


📋 Overview

TypeSpec is a language for describing APIs that generates OpenAPI specifications and client code. Forge uses TypeSpec as the source of truth for API definitions.


🏗️ Project Structure

TypeSpec Project Layout

src/{AppName}.TypeSpec/
├── package.json
├── tspconfig.yaml
├── main.tsp
├── models/
│   ├── common.tsp
│   ├── orders.tsp
│   └── customers.tsp
├── routes/
│   ├── orders.tsp
│   └── customers.tsp
└── tsp-output/              # Generated output
    ├── openapi.yaml
    └── @typespec/

Package Configuration

package.json:

{
  "name": "@myapp/typespec",
  "version": "1.0.0",
  "type": "module",
  "dependencies": {
    "@typespec/compiler": "^1.3.0",
    "@typespec/http": "^1.5.0",
    "@typespec/rest": "^1.5.0",
    "@typespec/openapi3": "^1.5.0",
    "@typespec/versioning": "^1.5.0",
    "@saif/platform-typespec": "^3.0.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"

📝 Basic Syntax

Service Definition

// main.tsp
import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi3";
import "@saif/platform-typespec";

using TypeSpec.Http;
using TypeSpec.Rest;

@service({
  title: "Order Service",
  version: "1.0.0"
})
@server("https://api.example.com", "Production")
namespace MyApp.Api;

// Import route files
import "./routes/orders.tsp";
import "./routes/customers.tsp";

Models

// models/orders.tsp
namespace MyApp.Api.Models;

@doc("Represents an order in the system")
model Order {
  @doc("Unique identifier")
  @key
  id: string;

  @doc("Customer who placed the order")
  customerId: string;

  @doc("Order line items")
  items: OrderItem[];

  @doc("Order status")
  status: OrderStatus;

  @doc("Total order amount")
  @minValue(0)
  total: decimal;

  @doc("When the order was created")
  createdAt: utcDateTime;

  @doc("When the order was last updated")
  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
}

Request/Response Models

// models/requests.tsp
namespace MyApp.Api.Models;

@doc("Request to create a new order")
model CreateOrderRequest {
  customerId: string;
  items: CreateOrderItemRequest[];
}

model CreateOrderItemRequest {
  productId: string;

  @minValue(1)
  quantity: int32;
}

@doc("Response containing a list of orders")
model OrderListResponse {
  items: Order[];

  @doc("Total count for pagination")
  totalCount: int32;

  @doc("Continuation token for next page")
  continuationToken?: string;
}

🛤️ Routes and Operations

RESTful Routes

// routes/orders.tsp
import "../models/orders.tsp";

using TypeSpec.Http;
using TypeSpec.Rest;

namespace MyApp.Api;

@route("/orders")
@tag("Orders")
interface Orders {
  @doc("List all orders")
  @get
  list(
    @query skip?: int32 = 0,
    @query take?: int32 = 20,
    @query status?: OrderStatus
  ): OrderListResponse | Error;

  @doc("Get order by ID")
  @get
  @route("{id}")
  get(@path id: string): Order | NotFoundError | Error;

  @doc("Create a new order")
  @post
  create(@body request: CreateOrderRequest): {
    @statusCode statusCode: 201;
    @body order: Order;
  } | ValidationError | Error;

  @doc("Update an existing order")
  @put
  @route("{id}")
  update(
    @path id: string,
    @body request: UpdateOrderRequest
  ): Order | NotFoundError | ValidationError | Error;

  @doc("Delete an order")
  @delete
  @route("{id}")
  delete(@path id: string): {
    @statusCode statusCode: 204;
  } | NotFoundError | Error;
}

Nested Resources

// routes/order-items.tsp
@route("/orders/{orderId}/items")
@tag("Order Items")
interface OrderItems {
  @get
  list(@path orderId: string): OrderItem[] | NotFoundError;

  @post
  add(
    @path orderId: string,
    @body item: CreateOrderItemRequest
  ): {
    @statusCode statusCode: 201;
    @body item: OrderItem;
  } | NotFoundError | ValidationError;

  @delete
  @route("{itemId}")
  remove(
    @path orderId: string,
    @path itemId: string
  ): {
    @statusCode statusCode: 204;
  } | NotFoundError;
}

❌ Error Handling

Error Models

// models/errors.tsp
namespace MyApp.Api.Models;

@error
model Error {
  @statusCode statusCode: 500;
  code: string;
  message: string;
}

@error
model NotFoundError {
  @statusCode statusCode: 404;
  code: "NOT_FOUND";
  message: string;
  resourceType: string;
  resourceId: string;
}

@error
model ValidationError {
  @statusCode statusCode: 400;
  code: "VALIDATION_ERROR";
  message: string;
  errors: ValidationErrorDetail[];
}

model ValidationErrorDetail {
  field: string;
  message: string;
  code: string;
}

@error
model UnauthorizedError {
  @statusCode statusCode: 401;
  code: "UNAUTHORIZED";
  message: string;
}

@error
model ForbiddenError {
  @statusCode statusCode: 403;
  code: "FORBIDDEN";
  message: string;
  requiredPermission?: string;
}

🔐 Authentication

OAuth2/OIDC Security

// main.tsp
import "@typespec/http";

using TypeSpec.Http;

@service({
  title: "Order Service",
  version: "1.0.0"
})
@useAuth(OAuth2Auth<[
  {
    type: OAuth2FlowType.authorizationCode,
    authorizationUrl: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
    tokenUrl: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
    scopes: ["User.Read", "User.Write"]
  }
]>)
namespace MyApp.Api;

Scope-Based Authorization

// routes/orders.tsp
@route("/orders")
@tag("Orders")
interface Orders {
  @doc("List orders - requires User.Read scope")
  @get
  list(): OrderListResponse;

  @doc("Create order - requires User.Write scope")
  @post
  create(@body request: CreateOrderRequest): Order;
}

🔢 Versioning

API Versioning

import "@typespec/versioning";

using TypeSpec.Versioning;

@versioned(Versions)
@service({
  title: "Order Service"
})
namespace MyApp.Api;

enum Versions {
  v1: "1.0.0",
  v2: "2.0.0"
}

// Model available in all versions
model Order {
  id: string;
  customerId: string;

  @added(Versions.v2)
  priority?: OrderPriority;
}

@added(Versions.v2)
enum OrderPriority {
  Low,
  Normal,
  High,
  Urgent
}

📐 Common Patterns

Pagination

// models/common.tsp
model PagedResponse<T> {
  items: T[];
  totalCount: int32;
  pageSize: int32;
  pageNumber: int32;
  hasNextPage: boolean;
  hasPreviousPage: boolean;
}

// Usage
@get
list(
  @query pageNumber?: int32 = 1,
  @query pageSize?: int32 = 20
): PagedResponse<Order>;

Search/Filter

model OrderSearchRequest {
  @doc("Filter by customer ID")
  customerId?: string;

  @doc("Filter by status")
  status?: OrderStatus;

  @doc("Filter by date range - start")
  fromDate?: utcDateTime;

  @doc("Filter by date range - end")
  toDate?: utcDateTime;

  @doc("Search in order ID or product names")
  searchText?: string;
}

@route("/search")
@post
search(@body request: OrderSearchRequest): PagedResponse<Order>;

Async Operations

model AsyncOperationResponse {
  @doc("Operation ID for status polling")
  operationId: string;

  @doc("Current operation status")
  status: OperationStatus;

  @doc("URL to poll for status")
  statusUrl: url;
}

enum OperationStatus {
  Pending,
  InProgress,
  Completed,
  Failed
}

@route("/bulk-import")
@post
bulkImport(@body request: BulkImportRequest): {
  @statusCode statusCode: 202;
  @body response: AsyncOperationResponse;
};

@route("/operations/{operationId}")
@get
getOperationStatus(@path operationId: string): AsyncOperationResponse;

✔️ Validation

Field Validation

model CreateOrderRequest {
  @minLength(1)
  @maxLength(100)
  customerId: string;

  @minItems(1)
  @maxItems(100)
  items: CreateOrderItemRequest[];

  @pattern("^[A-Z]{2}[0-9]{4}$")
  referenceCode?: string;

  @minValue(0)
  @maxValue(100)
  discountPercent?: float32;
}

Custom Decorators

// Define custom validation
@doc("Email address format")
@pattern("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")
scalar email extends string;

@doc("Phone number format")
@pattern("^\\+?[1-9]\\d{1,14}$")
scalar phoneNumber extends string;

// Usage
model CustomerContact {
  email: email;
  phone?: phoneNumber;
}

📤 Generated Output

OpenAPI Specification

TypeSpec generates OpenAPI 3.0 spec at the configured output path:

# infra/api/openapi/openapi.v1.yaml (generated)
openapi: 3.0.0
info:
  title: Order Service
  version: 1.0.0
paths:
  /orders:
    get:
      operationId: Orders_list
      # ...

Client Generation

# Generate TypeScript client
npx @typespec/openapi3 ./src/MyApp.TypeSpec

# Generate C# client using Kiota
kiota generate -l CSharp -o ./src/MyApp.Client -d ./infra/api/openapi/openapi.v1.yaml

✅ Checklist

API Design

  • Clear, descriptive operation names
  • Consistent naming conventions
  • Proper HTTP verbs for operations
  • Appropriate status codes for responses
  • Error responses documented

Models

  • All fields have @doc descriptions
  • Validation decorators applied
  • Nullable fields marked with ?
  • Enums used for fixed value sets

Security

  • Auth scheme defined
  • Scopes documented
  • Error responses for 401/403

Output

  • OpenAPI spec generates without errors
  • Output path configured correctly
  • Client generation works