OpenAPI specs tell you what a valid request looks like and what a valid response looks like. They do not tell you whether you are allowed to call POST /orders before POST /auth, or what happens if you call GET /invoice/{id} after DELETE /invoice/{id}. That information lives in a protocol spec, and OpenAPI is not a protocol spec.

This is the gap. You can generate every message type in your system from an OpenAPI document. You cannot generate the rules about when those messages are allowed to fly. Those rules are the session type, and they live in a different layer of abstraction.

What OpenAPI captures and what it ignores

OpenAPI is a contract for HTTP surface area. It defines paths, methods, query parameters, request bodies, response codes, and JSON schemas. This is valuable. It is also fundamentally static. Each endpoint is described in isolation. The relationships between endpoints, the state transitions they trigger, and the sequences that are legal are left as an exercise for the reader.

Session types are the opposite. A session type is a formal description of a communication protocol. It specifies the order in which messages must be sent and received, who sends what, and how the protocol branches based on message content. A binary session type might say: the client sends a Login message, then the server either responds with Success and the protocol continues, or responds with Failure and the protocol ends.

You cannot derive that sequence from an OpenAPI spec because the sequence was never written down. The OpenAPI document lists the endpoints. It does not specify that /auth must precede /orders. That constraint lives in documentation, code, or the head of the engineer who designed the API.

What you can extract: the message layer

What OpenAPI does give you is precise, machine-readable message definitions. Every request body schema, every response schema, every enum and discriminator is specified in JSON Schema. This is the raw material for the message types in a session type specification.

Here’s a concrete example. Consider this OpenAPI fragment:

paths:
  /auth:
    post:
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                username: { type: string }
                password: { type: string }
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
        '401':
          description: Unauthorized

From this, you can extract two message types: an AuthRequest containing a username and password, and an AuthResponse that is either a Success with a token or a Failure. The session type needs these types. It also needs to know that the client sends the request and the server sends the response, which OpenAPI implies through HTTP semantics.

You can automate this extraction. Here’s a Python script that parses an OpenAPI JSON spec and generates Rust-like message definitions:

import json
from dataclasses import dataclass

@dataclass
class Field:
    name: str
    type: str

@dataclass
class Message:
    name: str
    fields: list[Field]

TYPE_MAP = {"string": "String", "integer": "i64", "boolean": "bool"}

def sanitize_name(path: str, method: str, suffix: str) -> str:
    cleaned = path.replace("/", "").replace("{", "").replace("}", "")
    return f"{method.upper()}{cleaned}{suffix}"

def extract_messages(openapi_path: str) -> list[Message]:
    with open(openapi_path) as f:
        spec = json.load(f)

    messages = []
    for path, methods in spec.get("paths", {}).items():
        for method, operation in methods.items():
            if not isinstance(operation, dict):
                continue

            req = operation.get("requestBody", {})
            schema = req.get("content", {}).get("application/json", {}).get("schema", {})
            if schema:
                fields = [
                    Field(name=k, type=TYPE_MAP.get(v.get("type"), "serde_json::Value"))
                    for k, v in schema.get("properties", {}).items()
                ]
                messages.append(Message(name=sanitize_name(path, method, "Request"), fields=fields))

            for code, resp in operation.get("responses", {}).items():
                schema = resp.get("content", {}).get("application/json", {}).get("schema", {})
                if schema:
                    fields = [
                        Field(name=k, type=TYPE_MAP.get(v.get("type"), "serde_json::Value"))
                        for k, v in schema.get("properties", {}).items()
                    ]
                    messages.append(Message(name=sanitize_name(path, method, f"Response{code}"), fields=fields))

    return messages

if __name__ == "__main__":
    for m in extract_messages("api.json"):
        print(f"struct {m.name} {{")
        for f in m.fields:
            print(f"    {f.name}: {f.type},")
        print("}")

This is mechanical, but it works. It turns your OpenAPI document into structs that a session type implementation can reference. The types are accurate. The names are literal. The relationships are missing.

Where the sequence information lives

To get the protocol, you need to know the state machine. Some APIs encode this implicitly. A POST /orders returns an order ID, and subsequent GET /orders/{id} calls reference it. The protocol state includes “an order has been created.” An OpenAPI spec does not model this dependency.

There are emerging standards that try to bridge this. AsyncAPI handles event-driven protocols with channel semantics, but it still does not give you the global session type. Smithy defines operations and traits, and can model finite state machines through custom traits, but it requires explicit annotation. JSON Hyper-Schema attempted to link resources but never saw wide adoption.

For now, the practical approach is to treat the protocol as a separate artifact. You generate the message types from OpenAPI, then you write the session type by hand on top of them.

A practical hybrid: generated types plus a hand-written protocol

Here’s what this looks like in practice using Rust and the session_types crate. First, generate the message types from OpenAPI using the script above or a tool like typify. Then define the session type explicitly:

use session_types::*;

// Generated from OpenAPI
struct AuthRequest { username: String, password: String }
struct AuthSuccess { token: String }
struct AuthFailure { reason: String }
struct OrderRequest { item_id: u64, quantity: u64 }
struct OrderConfirmation { order_id: u64 }

// The protocol: authenticate, then optionally place an order
type ServerProto = Receive<AuthRequest, Offer<
    Send<AuthSuccess, Receive<OrderRequest, Send<OrderConfirmation, Eps>>>,
    Send<AuthFailure, Eps>
>>;

fn server(c: Chan<(), ServerProto>) {
    let (c, req) = c.recv();
    if authenticate(&req) {
        let c = c.sel1().send(AuthSuccess { token: "abc".into() });
        let (c, order) = c.recv();
        let c = c.send(OrderConfirmation { order_id: 42 });
        c.close();
    } else {
        let c = c.sel2().send(AuthFailure { reason: "bad creds".into() });
        c.close();
    }
}

The session type ServerProto specifies exactly what the server does. It receives an AuthRequest. Then it offers a choice: either send AuthSuccess and continue to receive an OrderRequest, or send AuthFailure and end. The OpenAPI spec gave us the structs. The session type gave us the grammar.

This is the division of labor. OpenAPI handles message shapes. Session types handle message order. One is generated, the other is designed.

The trade-offs you should know about

Automated extraction from OpenAPI has sharp edges. The first is polymorphism. OpenAPI uses oneOf and anyOf for unions, but the discriminator information does not map cleanly to session type branching. A oneOf in a response schema might represent two different success shapes, or it might represent a protocol branch. You have to read the spec to know which.

The second is HTTP-specific semantics. Session types are transport-agnostic. OpenAPI is deeply tied to HTTP methods, status codes, and headers. When you extract messages, you lose the method and path information unless you encode it into the message name. This is why the Python script above generates names like POSTAuthResponse200. It is ugly, but it preserves the provenance.

The third is partial specs. Many OpenAPI documents are generated from code and do not include all error responses. If your session type needs to handle every possible branch, an incomplete OpenAPI spec will produce an incomplete session type. The type checker will not catch missing branches if the branches were never in the source document.

How to implement this in your codebase

Start with the messages. Use openapi-typescript, typify, or a custom script to generate language-specific types from your OpenAPI schemas. Do not try to generate the protocol at this stage. Just get the structs.

Next, write the session type for the most critical flow in your system. Pick the flow where a sequencing bug would be most expensive. Authentication and payment are good candidates. Define the session type in your language of choice. Rust has session_types. There are experimental libraries for TypeScript, OCaml, and Scala. If your language lacks a session type library, write a state machine test that asserts valid sequences. It is not the same guarantee, but it catches the same class of bugs.

Finally, keep the two artifacts in sync with CI. When the OpenAPI spec changes, regenerate the message types. If a new required field is added, the session type code will fail to compile. That is the safety you are buying.

FAQ

Does this work for WebSocket or gRPC APIs?

Not directly. OpenAPI describes HTTP. For gRPC, you have protobuf schemas, which give you message types but still not sequences. For WebSockets, AsyncAPI is a closer fit, but the same limitation applies: it describes channels, not global session protocols.

Can I generate session types from AsyncAPI instead?

AsyncAPI adds channel semantics and message routing, which gets you closer. It still does not specify the global protocol state machine. You would extract message types from AsyncAPI and write the session type by hand, just like with OpenAPI.

What if my API has fifty endpoints?

Fifty endpoints means fifty message types, not one session type with fifty steps. Real protocols decompose into smaller sessions. An e-commerce API might have separate sessions for authentication, checkout, and inventory. Compose them.

Is there a tool that does all of this automatically?

Not yet. The research exists. There are papers on extracting session types from REST APIs and from choreographies. Production-ready tooling does not. For now, the hybrid approach is the pragmatic path.

Your OpenAPI spec is a dictionary. It tells you what words mean. A session type is a grammar. It tells you what sentences are legal. Generate the vocabulary from OpenAPI. Write the grammar yourself.