typespec icon indicating copy to clipboard operation
typespec copied to clipboard

Proposal: Expressing Model Property Error Handling

Open steverice opened this issue 7 months ago • 0 comments

Proposal: Expressing Model Property Error Handling

This proposal introduces two decorators for the TypeSpec standard library:

  • @raises decorator: Used to indicate that a model property may be associated with specific errors.
  • @handles decorator: Used to indicate that an operation or property will handle certain types of errors, preventing them from being considered further.

The proposal also recommends that new and existing emitters support these decorators for improved error documentation and code generation.


Goals

As we (Pinterest) are building out the GraphQL emitter, we have identified a need to express complex error handling patterns that are common in GraphQL APIs and cannot be easily expressed with the existing TypeSpec error handling mechanisms.

Primary Goal: Enable Pinterest's GraphQL emitter to express GraphQL's complex field-level error patterns (null propagation, errors-as-data, resolver-level handling).

Secondary Goals:

  1. Provide a standard way for other emitters to leverage field-level error information
  2. Enable multi-protocol schemas to express error handling once and generate appropriately for each target
  3. Make error handling documentation more consistent and accurate by computing error information that must otherwise be specified manually

Terminology

These terms have specific meanings throughout the document, so we will define them here.

Operation

A TypeSpec operation which defines an action or function that can be performed. In protocol-specific contexts like HTTP/REST, operations often map to API endpoints. In other contexts like GraphQL, operations may map to queries, mutations, or resolvers. Operations are a core TypeSpec concept, not specific to any protocol.

Return type

The return type of a TypeSpec operation which defines what the operation returns when invoked. This is a core TypeSpec language concept and exists independently of protocol-specific mechanisms for returning data or errors. Different protocols may represent the return type in different ways (HTTP response bodies, GraphQL field values, etc.).

Operation error

An error that is specified in the return type of an operation in TypeSpec. Operation errors are part of the API contract and are explicitly documented as possible results of invoking the operation.

Protocol error

An error expression specific to the protocol in which it is being expressed. Protocol errors may be the result of any number of sources, e.g. operation errors, raised model property errors, or protocol processing errors. An operation error does not necessarily translate into a protocol error (it's up to the protocol emitter).


Definitions

@raises decorator

/**
 * Indicates that the use of this property may be associated with specific errors.
 *
 * @param errors The list of error models that may be associated with this property.
 *
 * @example
 *
 * ```typespec
 * model User {
 *   @raises(NotFoundError, PermissionDeniedError, InvalidURLError)
 *   profilePictureUrl: string;
 * }
 * ```
 */
extern dec raises(target: ModelProperty, ...errors: Model[]);

The @raises decorator is applied to model properties to document that certain errors may be associated with those properties. This provides valuable information for documentation and code generation, helping consumers and tools understand where errors may occur within a model.

Protocol emitters are expected to consider errors listed in @raises decorators when determining what protocol errors should be expressed.

The errors parameter is a list of models representing possible errors. Each error model must be decorated with the @error decorator.


@handles decorator

/**
 * Indicates that this operation or model property will handle certain types of errors.
 *
 * @param errors The list of error models that will be handled by this operation or model property.
 *
 * @example
 *
 * ```typespec
 * @handles(InvalidURLError) op getUser(id: string): User | NotFoundError;
 *
 * model User {
 *   @handles(PermissionDeniedError) profilePictureUrl: string;
 * }
 * ```
 */
extern dec handles(target: Operation | ModelProperty, ...errors: Model[]);

The decorator can be applied to operations or model properties. It specifies that the operation or model property will handle the listed errors, preventing them from being expressed as protocol errors.

The errors parameter is a list of models that represent the errors that will be handled by the operation or model property. Each model must be decorated with the @error decorator.


Interaction with Other TypeSpec Concepts

This section will discuss how to integrate the new decorators with existing TypeSpec concepts.

Operation Errors

Earlier we defined operation errors as errors that are specified in the return type of an operation.

Following, we'll discuss how operations errors interact with the @raises and @handles decorators.

Operation errors + @raises decorator

The @raises decorator can be used alongside an operation's return type. For example, getUser() may have a GenericError in its return type, in addition to errors that may be associated with properties like profilePictureUrl.:

model User {
  @raises(InvalidURLError)
  @handles(PermissionDeniedError)
  profilePictureUrl: string;
}
op getUser(id: string): User | GenericError;

If an error type is specified in both the operation's return type and the @raises decorator, the protocol error should include the error (once) in the list of possible errors.

Semantically, the distinction between a @raises decorator and the operation's return type is in where the error is communicated. An error on a return type is an explicit indication that the error is somehow exposed directly in that response. An error specified with @raises, on the other hand, may appear in a different location depending on if or where the error is specified in a @handles decorator — or not at all, depending on the protocol.

For instance, a bulk operation of some kind that includes the results of several sub-operations could communicate errors in a few different ways. One way would be for each of the operation in the bulk set to provide its error value as its specific return type — as indicated by an error present in the return type. Another might be for the bulk operation to aggregate all the errors that occurred in the sub-operations and communicate them somewhere in its own response, which could be accomplished by the sub-operations using the @raises decorator and the bulk operation using the @handles decorator.

Essentially, an error in a return type is opted out of any contextual handling, while an error in a @raises decorator follows the rules specified by other operations, properties, and/or contextual modifiers.

Operation errors + @handles decorator

It is possible, and valid, that an operation both @handles an error and also has a return type that includes that error. In this case, the operation will include the error in the list of possible errors for the operation.

@handles(InvalidURLError)
op getUser(id: string): User | InvalidURLError | GenericError;

Semantically, this indicates that the operation will handle the InvalidURLError error when produced by a model property, but that the operation itself may also return that error, outside the context of a model property.

This becomes important when considering error inheritance.


Interaction between @raises and @handles decorators

Model properties may have one or more error types defined in both their @raises decorator and the @handles decorator. In this case, the error is still considered possible at that property. Code emitters should treat @raises as taking precedence for code generation and documentation.

model User {
  @raises(InvalidURLError)
  @handles(PermissionDeniedError, InvalidURLError)
  profilePictureUrl: string;
}

is equivalent to:

model User {
  @raises(InvalidURLError)
  @handles(PermissionDeniedError)
  profilePictureUrl: string;
}

Error inheritance

Most languages have a way to specify that an error type inherits from another error type.

For the purposes of discussion, let's imagine we have a base error type GenericError and two errors that extend it: NotFoundError and PermissionDeniedError.

Error inheritance + @handles decorator

Error handling is often performed generically based on a base error type, allowing the developer to handle errors that were not known at the time of writing the code.

Therefore when an error is specified in the @handles decorator, and there are additional errors that extend from it, those errors will also be considered as handled.

For example, if we were to specify that getUser() handles GenericError, we are also specifying that it will handle NotFoundError and PermissionDeniedError as well as any other error that extends GenericError.

@error
model GenericError {
  message: string;
}

@error
model NotFoundError extends GenericError {}

@error
model PermissionDeniedError extends GenericError {}

@handles(GenericError)
op getUser(id: string): User;

This definition states that the protocol-specific behavior implied by the @handles(GenericError) decorator will also apply to NotFoundError and PermissionDeniedError.

Error inheritance + @raises decorator

The inheritance described above for @handles does not apply to the @raises decorator.

If a property is decorated with @raises(GenericError), it is not implying anything about whether the property can raise NotFoundError or PermissionDeniedError, even though those errors extend from GenericError.

In other words, given the following:

model Profile {
  @raises(GenericError)
  profilePictureUrl: string;
}

model User {
  @handles(NotFoundError, PermissionDeniedError)
  profile: Profile;
}

We would still consider GenericError to be a possible error at User.profile — it is not handled by the @handles decorator.

Conversely, if a property is decorated with @raises(NotFoundError), it is not considered to be decorated with @raises(GenericError).

It follows that a @raises decorator can contain multiple errors that form an inheritance hierarchy — i.e. this is not redundant.

model Profile {
  @raises(NotFoundError, PermissionDeniedError, GenericError)
  profilePictureUrl: string;
}

When combined with the @handles decorator, any error that is not covered by its own type or a supertype is considered unhandled.

model User {
  @handles(NotFoundError, PermissionDeniedError)
  profile: Profile;
}

The above example suggests that User.profile will not raise NotFoundError or PermissionDeniedError, but it may raise any other type of GenericError.

This approach aligns with the idea that error documentation should be explicit about which errors may occur at a given property, while allowing for more flexible handling in @handles.


Use in request input

The @raises and @handles decorators apply equally to input as they do to output. Just as these decorators allow developers to model and handle errors that may occur when accessing properties in a server's response, they can also be used to model and handle errors that arise when processing client-provided input. The mechanics of how these decorators are applied and how they affect the emitted document(s) remain consistent between input and output.


@raises for Input Validation Errors

When applied to model properties used on input, the @raises decorator specifies the errors that may occur during the validation or processing of client-provided data. For example, an input model for creating a user might specify that the email field can produce InvalidEmailError or MissingFieldError, while the password field can produce InvalidPasswordError:

model CreateUserRequest {
  @raises(InvalidEmailError, MissingFieldError)
  email: string;

  @raises(InvalidPasswordError)
  password: string;
}

These errors are generated by the server in response to invalid or incomplete input provided by the client. This is conceptually different from output errors, which are typically generated by the server's internal logic or data access operations.


@handles for Input-Level Error Handling

The @handles decorator can be used to specify which input-related errors are handled by the operation itself, preventing them from being propagated to the client. For example, an operation to create a user might handle InvalidEmailError internally while allowing other errors to propagate:

@handles(InvalidEmailError)
op createUser(request: CreateUserRequest): User | GenericError;

This behavior mirrors how @handles is used for output errors, allowing developers to control which errors are exposed via a protocol error and which are handled internally.

A note on context modifiers

Through the visibility system, we know that a single model property may be both an input and an output property.

It may indeed be the case that some errors are only relevant to the property when it is used as an input, while others are only relevant when it is used as an output, while still others may be relevant in both contexts.

The suggestion for the developer is to err on the side of caution and specify both input and output errors in the @raises decorator. This may cause some unnecessary error handling in clients, but this is preferable to unexpected errors.

For a more nuanced approach, we can consider applying context modifiers to errors.


Implementations and Use Cases

Below we list some proposed implementations in various emitter targets. These are meant to be illustrative of the effects of the @raises and @handles decorators, and are not proposing any of the specific syntax or implementation shown below.

HTTP/REST/OpenAPI

In a typical HTTP/REST API where operations are represented by endpoints, the @raises decorator can provide more accurate return types for operations that contain properties that may fail.

In a larger API, it may be quite difficult to track all the errors that can occur within an operation when the errors can be generated by many different layers of an API stack. The @raises decorator helps give the developer a more complete view of the errors that an operation can produce.

Let's say we have this definition of models:

import "@typespec/http";
using Http;

@error
model GenericError {
  message: string;
}

model User {
  @key id: string;
  profilePictureUrl: string;
}

Now we define an operation that uses the User model:

@route("/user/{id}")
@get
op getUser(@path id: string): User | GenericError;

This will produce the following OpenAPI:

Click to collapse
paths:
  /user/{id}:
    get:
      operationId: getUser
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        default:
          description: An unexpected error response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenericError"

Using @raises decorator

With the @raises decorator, we can specify that the profilePictureUrl property may produce errors when accessed:

@error
model NotFoundError extends GenericError {
  @statusCode _: 404;
}

@error
model PermissionDeniedError extends GenericError {
  @statusCode _: 403;
}

@error
model InvalidURLError extends GenericError {
  @statusCode _: 500;
}

model User {
  @key id: string;

  @raises(NotFoundError, PermissionDeniedError, InvalidURLError)
  profilePictureUrl: string;
}

Since the User model is used in the getUser() operation, the operation schema in the generated OpenAPI will now include the possible errors that can occur when accessing the profilePictureUrl property:

Click to collapse
paths:
  /user/{id}:
    get:
      operationId: getUser
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "403":
          description: Access is forbidden.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PermissionDeniedError"
        "404":
          description: The server cannot find the requested resource.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotFoundError"
        "500":
          description: Server error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InvalidURLError"
        default:
          description: An unexpected error response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenericError"

The definition of getUser() has not changed, but it is now emitted as if the return type was

User | NotFoundError | PermissionDeniedError | InvalidURLError | GenericError;

To implement this, the OpenAPI emitter could take advantage of TypeSpec compiler support to propagate errors from model properties to the operation's return type.


Using @handles decorator

Perhaps our getUser() operation is designed to handle the InvalidURLError error, while other operations may not do so. We can use the @handles decorator to specify that this operation will handle that error:

@route("/user/{id}")
@get
@handles(InvalidURLError)
op getUser(@path id: string): User | GenericError;

Now, despite the presence of a User.profilePictureUrl property that may produce an InvalidURLError, the OpenAPI will not include it in the list of possible errors for the getUser() operation:

Click to collapse
paths:
  /user/{id}:
    get:
      operationId: getUser
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: The request has succeeded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "403":
          description: Access is forbidden.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PermissionDeniedError"
        "404":
          description: The server cannot find the requested resource.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/NotFoundError"
        default:
          description: An unexpected error response.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/GenericError"

This is not limited to the profilePictureUrl property. Any property that is decorated with @raises(InvalidURLError) and is used in the getUser() operation will no longer add InvalidURLError to the list of possible errors for the operation.


GraphQL

In GraphQL, errors are typically propagated through the errors key in the response:

Click to collapse
{
  "data": {
    "user": null
  },
  "errors": [
    {
      "message": "User not found",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["user"],
      "extensions": {
        "code": "NOT_FOUND",
        "exception": { "stacktrace": [...] }
      }
    }
  ]
}

Since an error can occur within any GraphQL resolver, we need a way to associate errors with anything that can have a resolver — which is any operation or model property.

To represent this complexity with current TypeSpec concepts, we would perhaps need to modify the value type of the field to be a union type that includes the error type. However, this would change the shape of the TypeSpec API description to accommodate a specific protocol's error handling pattern. Other protocols like OpenAPI would now inaccurately document that the field's value can be an error type, which is unlikely to be true in practice.

Using the @raises decorator on model properties avoids this and enhances the ability of the TypeSpec document to emit multiple protocols.

Propagation and "Errors as Data"

Some GraphQL schemas use the "errors as data" pattern, where errors are included in the possible value of a field using union types. In this case, the @raises decorator can be used to specify which errors must be included in that union type.

The forthcoming GraphQL emitter will include additional decorators that can be applied to error models, similar to @typespec/http's @statusCode decorator. These decorators can be used to customize how errors in a @raises decorator are emitted in the GraphQL schema.

For example, a @propagate decorator could be used to indicate that an error type, if produced, should be propagated to parent fields. In GraphQL, this is accomplished by making a field type non-nullable — meaning that if a value cannot be produced for that field (due to an error), the error will be bubble up through parent fields, stopping at the first field which is nullable.

A @asData decorator could be used to indicate that an error type should be included in the "errors as data" pattern. This allows a GraphQL schema to opt-in to using this pattern for specific errors, while still allowing other errors (e.g. unexpected server errors) to be propagated normally.

The @handles decorator can also be used in GraphQL to specify that a field resolver will handle certain types of errors. Specifying an error in the @handles decorator will:

  • omit the error from the union return type, if the error has the @asData decorator.
  • prevent the error from triggering non-nullability of the field type, if the error has the @propagate decorator. The field may still be marked non-null through other errors or other means.

Example

This example shows all of the above in action:

Click to collapse
import "@typespec/graphql";
using GraphQL;

@error
@GraphQL.interface
model ServerError {
  message: string;
}

@error
@GraphQL.asData
@GraphQL.interface
model ClientError {
  message: string;
}

@error
@GraphQL.asData
@doc("The resource is not found.")
model NotFoundError extends ClientError {
  message: string = "Not found";
}

@error
@doc("The user does not have permission to access the resource.")
model PermissionDeniedError extends ClientError {
  message: string = "Permission denied";
}

enum Service {
  SERVICE_A,
  SERVICE_B,
}

@error
@GraphQL.propagate
@doc("A timeout occurred while waiting for a response from an upstream service.")
model UpstreamTimeoutError extends ServerError {
  service: Service; // the service that timed out
}

@error
@GraphQL.propagate
@doc("A race condition occurred.")
model RaceConditionError extends ServerError {}

@doc("Mark this entry as seen")
op markAsSeen(seen: boolean): boolean | RaceConditionError;

@GraphQL.operationFields(markAsSeen)
model ActivityEntry {
  @raises(PermissionDeniedError) ipAddress?: string;
}

// In GraphQL, fields can take arguments.
// These are specified like [operations](#operation) in TypeSpec.
@doc("Users following this user")
@handles(RaceConditionError) op followers(type?: string): User[];

@GraphQL.operationFields(followers)
model User {
  @raises(NotFoundError, PermissionDeniedError) profilePictureUrl: string;

  @doc("A log of the user's activity")
  @raises(UpstreamTimeoutError) activity: ActivityEntry[];
}

This could result in the following GraphQL:

Click to collapse
interface ClientError {
  message: String
}

interface ServerError {
  message: String
}

type NotFoundError implements ClientError {
  """
  The resource is not found.
    * This error appears in union responses.
  """
  message: String
}

type PermissionDeniedError implements ClientError {
  """
  The user does not have permission to access the resource.
  """
  message: String
}

enum Service {
  SERVICE_A
  SERVICE_B
}

type UpstreamTimeoutError implements ServerError {
  """
  A timeout occurred while waiting for a response from an upstream service.
    * This error is propagated to the parent field.
  """
  message: String
  service: Service
}

union UserProfilePictureUrlResponse =
  | String
  | NotFoundError # NotFoundError is `@asData`, so it's added to the union
  | ClientError # PermissionDeniedError does not use `@asData`, but it extends from ClientError which does

type User {
  """
  A log of the user's activity
  * this field is non-null because it `@raises(UpstreamTimeoutError)` (which propagates)
  """
  activity: [ActivityEntry!]!

  """
  Users following this user
  * this field is nullable because even though User.activity[].markAsSeen will propagate a RaceConditionError, followers `@handles(RaceConditionError)`
  """
  followers(type: String): [User!]

  profilePictureUrl: UserProfilePictureUrlResponse
}

union ActivityEntryIpAddressResponse = String | ClientError # PermissionDeniedError does not use `@asData`, but it extends from ClientError which does
type ActivityEntry {
  ipAddress: ActivityEntryIpAddressResponse

  """
  Mark this entry as seen
  * this field is non-null because it has RaceConditionError (which propagates) in its [return type](#return-type)
  """
  markAsSeen(seen: Boolean!): Boolean!
}

Protocol Buffers (Protobuf)

Protocol Buffers (Protobuf) is a language-neutral, platform-neutral mechanism for serializing structured data. While Protobuf itself does not have a built-in concept of errors, the @raises and @handles decorators can be used to model and document errors in TypeSpec, which can then be translated into Protobuf-compatible patterns. Different patterns for communicating errors in Protobuf can be expressed using additional TypeSpec decorators.

Using @raises with Protobuf

The @raises decorator can be used to specify errors that may occur when accessing a property. These errors can be represented in Protobuf by defining separate message types for each error and including them in a oneof field in the response message.

To implement this, the Protobuf emitter could take advantage of TypeSpec compiler support to propagate errors from model properties to the operation's return type.

For example:

Click to collapse
@error
@oneOfError
model NotFoundError {
  message: string;
}

@error
@oneOfError
model PermissionDeniedError {
  message: string;
}

model User {
  @raises(NotFoundError, PermissionDeniedError)
  profilePictureUrl: string;
}

op getUser(@path id: string): User;

This could be translated into the following Protobuf schema:

Click to collapse
message NotFoundError {
  string message = 1;
}

message PermissionDeniedError {
  string message = 1;
}

message User {
  string profilePictureUrl = 1;
}

message GetUserResponse {
  oneof result {
    User user = 1;
    NotFoundError not_found_error = 2;
    PermissionDeniedError permission_denied_error = 3;
  }
}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

message GetUserRequest {
  string id = 1;
}

Using gRPC Status Codes

When using Protobuf with gRPC, errors are often communicated using gRPC's built-in status codes and error details. These could be expressed in TypeSpec using a @statusCode decorator from a gRPC library, along with a generic Error model in the operation's return type.

Click to collapse
@error
model Error {
  code: gRPC.StatusCode;
  message: string;
}

@error
model NotFoundError extends Error {
  code: gRPC.StatusCode.NOT_FOUND;
}

model User {
  @raises(NotFoundError) profilePictureUrl: string;
}

op getUser(@path id: string): User | Error;

This could be translated into the following Protobuf schema:

Click to collapse
message Error {
  GrpcStatusCode code = 1;
  string message = 2;
}

message User {
  string profilePictureUrl = 1;
}

message GetUserResponse {
  oneof result {
    User user = 1;
    Error error = 2;
  }
}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse);
}

message GetUserRequest {
  string id = 1;
}

Apache Thrift

Apache Thrift supports defining exceptions as part of its IDL (Interface Definition Language), which makes it well-suited for modeling error using the @raises and @handles decorators.

Using @raises with Thrift

Exceptions specified by @raises can be represented in Thrift by defining exception types and including them in the throws clause of a service method.

For example:

Click to collapse
@error
model NotFoundError {
  message: string;
}

@error
model PermissionDeniedError {
  message: string;
}

model User {
  @raises(NotFoundError, PermissionDeniedError)
  profilePictureUrl: string;
}

op getUser(@path id: string): User;

This could be translated into the following Thrift IDL:

Click to collapse
exception NotFoundError {
  1: string message;
}

exception PermissionDeniedError {
  1: string message;
}

struct User {
  1: string profilePictureUrl;
}

service UserService {
  User getUser(1: string id) throws (
    1: NotFoundError notFoundError,
    2: PermissionDeniedError permissionDeniedError
  );
}

To implement this, the Thrift emitter could take advantage of TypeSpec compiler support to propagate errors from model properties to the operation's return type.

Using @handles with Thrift

The @handles decorator can be used to specify which exceptions are handled internally by an operation or property. In Thrift, this can be reflected by omitting the handled exceptions from the throws clause of the service method.

For example:

@handles(PermissionDeniedError)
op getUser(@path id: string): User;

If PermissionDeniedError is handled internally, the Thrift IDL would look like this:

service UserService {
  User getUser(1: string id) throws (
    1: NotFoundError notFoundError
  );
}

Client libraries

Client libraries should leverage language-specific constructs to represent fields or operations that may produce errors. For example, in languages with an error monad or result monad, such as Kotlin or Swift, these constructs should be used to represent fields decorated with @raises or operations decorated with @handles.

Example: Kotlin

In Kotlin, the Result type or sealed classes can be used. For example:

Click to collapse
sealed class Error {
    object NotFound : Error()
    object PermissionDenied : Error()
    object InvalidUrl : Error()
}

data class User(
    val id: String,
    val profilePictureUrl: Result<String> // Field with @raises decorator
)

fun getUser(id: String): Result<User> {
    // Operation with @handles decorator
    if (id.isEmpty()) {
        return Result.failure(Error.NotFound)
    }
    return Result.success(
        User(
            id = id,
            profilePictureUrl = Result.failure(Error.PermissionDenied)
        )
    )
}

This approach ensures that clients handle errors in a type-safe and idiomatic way.

Example: Swift

In Swift, the Result type can be used to represent fields or operations that may fail. For example:

Click to collapse
enum Error: Swift.Error {
    case notFound
    case permissionDenied
    case invalidUrl
}

struct User {
    let id: String
    let profilePictureUrl: Result<String, Error> // Field with @raises decorator
}

func getUser(id: String) -> Result<User, Error> {
    // Operation with @handles decorator
    if id.isEmpty {
        return .failure(.notFound)
    }
    return .success(
        User(
            id: id,
            profilePictureUrl: .failure(.permissionDenied)
        )
    )
}

This approach ensures that clients handle errors in a type-safe and idiomatic way.


Server libraries

Server libraries should generate code that includes appropriate error handling stubs. For example, in languages with an error monad or result monad, these constructs should be used to represent fields or operations that may produce errors. This allows server implementations to handle errors explicitly and propagate them as needed.

Example: Scala

In Scala, the Either type can be used to handle errors for fields and operations:

Click to collapse
sealed trait Error
case object NotFound extends Error
case object PermissionDenied extends Error
case object InvalidUrl extends Error

case class User(id: String, profilePictureUrl: Either[Error, String]) // Field with @raises decorator

def getUser(id: String): Either[Error, User] = {
  // Operation with @handles decorator
  if (id.isEmpty) {
    Left(NotFound)
  } else {
    Right(User(id, Left(PermissionDenied)))
  }
}

This approach ensures that server-side logic is clear and errors are propagated or handled as needed.

Example: Rust

In Rust, the Result type can be used to handle errors for fields and operations:

Click to collapse
fn resolve_profile_picture_url(user_id: &str) -> Result<String, Error> {
    // Simulate a permission check
    if user_id == "restricted" {
        return Err(Error::PermissionDenied);
    }
    Ok("https://example.com/profile.jpg".to_string())
}

fn get_user_handler(id: &str) -> Result<User, Error> {
    let user = User {
        id: id.to_string(),
        profile_picture_url: resolve_profile_picture_url(id),
    };
    Ok(user)
}

Here, the server explicitly handles errors when resolving the profile_picture_url field.


Real-world Use Cases

Note: While Pinterest's immediate need is GraphQL, patterns that require similar nuance in error handling appear across multiple domains.

This section is meant to demonstrate a few areas in real-world use where this proposal allows a new kind of error handling that is not currently possible. Some may be more esoteric and/or speculative than others, but the goal is to explore a wide spectrum of use cases.


Azure Logic Apps

Azure Logic Apps represents a compelling use case for field-level error handling specifications. Logic Apps workflows consist of multiple actions that can fail independently, with subsequent actions configured to handle specific failure types through "run after" settings.

Logic Apps uses execution states rather than semantic errors. Actions can result in Failed, Skipped, TimedOut, or Successful states, and subsequent actions can be configured to run after specific combinations of these states.

Consider a workflow that retrieves user data and processes it through multiple services:

// Logic Apps execution states
@error model Failed { reason: string; }
@error model TimedOut { duration: int32; }
@error model Skipped { condition: string; }

model UserProfileData {
  @raises(Failed, TimedOut) // getUserInfo action might fail or timeout
  basicInfo: UserInfo;
  
  @raises(Skipped, TimedOut) // getSocialLinks action might be skipped or timeout  
  socialMediaLinks: SocialLinks;
  
  @raises(Failed) // getProfileImage action might fail
  profileImage: ImageData;
}

@handles(Failed) // Configure "run after: Failed"
op createDefaultProfile(userData: UserProfileData): UserProfile;

@handles(TimedOut) // Configure "run after: TimedOut"  
op retryWithBackoff(userData: UserProfileData): UserProfile;

@handles(Failed, TimedOut) // Configure "run after: Failed, TimedOut"
op logErrorAndContinue(userData: UserProfileData): void;

This TypeSpec definition maps to Logic Apps execution state patterns:

  • @raises decorators specify which execution states individual actions can produce
  • @handles decorators correspond to "run after" configurations that execute subsequent actions based on specific execution states
  • Multiple state handling allows actions to run after combinations of states (e.g., both Failed and TimedOut)

When generating Logic Apps workflow definitions from TypeSpec, an emitter could:

  1. Generate appropriate "run after" configurations based on @handles decorators
  2. Create conditional logic that routes workflow execution based on action states
  3. Implement retry and error handling patterns based on the specified execution states
  4. Generate monitoring and alerting for specific failure patterns

This approach could enable Logic Apps developers to model execution state handling in TypeSpec and generate robust workflows with proper conditional routing based on action outcomes.


Netflix-style Circuit Breaker Patterns

Microservices architectures use circuit breakers for individual service calls, where different fields require different fallback strategies based on business criticality.

@error model CriticalError {}
@error model NonCriticalError {}

@error model RecommendationServiceError extends NonCriticalError {}
@error model BillingServiceError extends CriticalError {}
@error model WatchHistoryError extends NonCriticalError {}

model UserDashboard {
  @raises(RecommendationServiceError) // Can fallback to cached recommendations
  personalizedContent: Content[];
  
  @raises(BillingServiceError) // Critical - must show billing errors
  accountStatus: AccountStatus;
  
  @raises(WatchHistoryError) // Can fallback to empty state
  recentlyWatched: Video[];
}

@handles(NonCriticalError) // Handle non-critical failures
op getDashboardWithFallbacks(userId: string): UserDashboard;

This pattern allows critical errors (billing issues) to propagate while gracefully handling non-critical failures (recommendations, watch history) through fallbacks or cached data.


E-commerce: Partial Product Data

E-commerce platforms need to handle partial product availability where inventory, pricing, and content management systems can fail independently.

model ProductPage {
  @raises(InventoryServiceError) // Inventory might be temporarily unavailable
  stockStatus: StockInfo;
  
  @raises(PricingServiceError) // Pricing service might be updating
  currentPrice: Price;
  
  @raises(ContentServiceError) // CMS might be down
  productDescription: string;
}

@handles(InventoryServiceError) // Show "availability unknown" instead of failing
op getProductPageWithDefaults(productId: string): ProductPage;

This enables platforms like Shopify to show "availability unknown" or cached pricing when specific services are down, rather than showing broken product pages.


Content Management Systems: Progressive Enhancement

CMS platforms where page components can fail independently but the page should still render with graceful degradation.

model WebPage {
  @raises(CDNError) // Images might not load
  heroImage: ImageUrl;
  
  @raises(DatabaseError) // Content might be temporarily unavailable  
  mainContent: RichText;
  
  @raises(APIRateLimitError) // Social feeds might be rate-limited
  socialFeed: SocialPost[];
}

@handles(CDNError, APIRateLimitError) // Show placeholders for non-critical content
op renderPageWithDefaults(pageId: string): WebPage;

This enables progressive enhancement patterns where critical content (main text) failures propagate as errors, while non-critical elements (images, social feeds) show placeholders or cached content.


Phased Implementation Approach

We suggest implementation of this proposal follow a two-phase approach to allow for community feedback and refinement before finalizing the design.

Phase 1 (Experimental):

  • Implement @raises and @handles decorators
  • Mark as @experimental in TypeSpec core
  • Ship Pinterest GraphQL emitter as reference implementation
  • Add support in the OpenAPI emitter (needed by Pinterest)
  • Gather community feedback

Phase 2 (Stable):

  • Refine based on real-world usage
  • Add context modifiers if validated by community needs
  • Remove experimental status
  • Adopt in other emitters

Additional Considerations

The following should be considered as future enhancements to enhance interaction with the @raises and @handles decorators.

TypeSpec Compiler support for propagating errors to operations

It will be a common case for a protocol to want to "propagate" the errors specified by @raises and @handles decorators with the errors specified in the operation's return type.

To make this easier, the TypeSpec compiler may include functionality to merge the error specification defined by @raises and @handles decorators into the operation's return type.

Looking at the following example:

model Profile {
  @raises(InvalidURLError, PermissionDeniedError)
  profilePictureUrl: string;
}

model User {
  @raises(NotFoundError)
  @handles(PermissionDeniedError)
  profile: Profile;
}

@handles(NotFoundError, PrivateProfileError)
op getUser(id: string): User | GenericError | PrivateProfileError;

Some functionality in the TypeSpec compiler — let's call it getOperationErrors() — would a getUser operation type with the following signature:

op getUser(id: string): User | GenericError | InvalidURLError | PrivateProfileError;

Note the compiler has combined the return type with errors that were present in @raises decorators and not @handles decorators. In this case, that means the return type consists of:

  • User, as defined in getUser()'s return type
  • GenericError, as defined in getUser()'s return type
  • InvalidURLError, as defined in Profile's @raises decorator and not in an applicable @handles decorator
  • PrivateProfileError, as defined in getUser()'s return type. This follows the precedence rule between @raises and @handles as if the error in the return type is an implicit @raises decorator.
  • not PermissionDeniedError, as it is handled by User.profile
  • not NotFoundError, as it is handled by getUser()

Future Enhancement: Context Modifiers

Note: This is explicitly NOT part of the initial proposal. Adding context modifiers to errors introduces additional complexity, similar to the visibility system. We propose implementing the core @raises/@handles functionality first, then evaluating whether context modifiers are needed based on real usage patterns.

As an optional enhancement, we propose extending the @error decorator to include an argument for specifying context (visibility) modifiers. This would allow developers to explicitly indicate the contexts in which an error applies, such as input validation, output handling, or both. This enhancement would provide additional clarity and flexibility when modeling errors.

Proposed Definition

The @error decorator would accept an optional argument specifying one or more visibility enums.

/**
 * Specify that this model is an error type. Operations return error types when the [operation](#operation) has failed.
 *
 * @param contexts The list of contexts in which this error applies. This can be used to indicate whether the error is relevant for input, output, or both.
 *
 * @example
 * ```typespec
 * @error(Lifecycle.Create, Lifecycle.Update)
 * model PetStoreError {
 *   code: string;
 *   message: string;
 * }
 * ```
 */
extern dec error(target: Model, ...contexts: valueof EnumMember[]);

For example:

@error(Lifecycle.Create, Lifecycle.Update)
model InvalidEmailError {
  message: string;
}

@error(Lifecycle.Read)
model PermissionDeniedError {
  message: string;
}

Here, Lifecycle.Create and Lifecycle.Update indicate that InvalidEmailError applies in input contexts (e.g., when creating or updating a resource), while Lifecycle.Read indicates that PermissionDeniedError applies in output contexts (e.g., when reading a resource).

Libraries and emitters should interpret context modifiers, when applied to error models, to determine what errors should be included in different contexts. This mirrors the visibility system, and libraries and emitters should interpret the context modifiers the same way as they already do for visibility.

Examples

The following examples illustrate how the context modifiers can be used in practice.

Input Contexts

By default, errors with Lifecycle.Create, Lifecycle.Update, or Lifecycle.Delete are included when the model is used as a parameter in the respective context.

Click to collapse
@error(Lifecycle.CREATE, Lifecycle.UPDATE)
model InvalidEmailError {
  message: string;
}

model User {
  @key id: string;

  @visibility(Lifecycle.Create, Lifecycle.Update, Lifecycle.Read)
  @raises(InvalidEmailError)
  email: string;
}

op getUser(id: string): User | UserNotFound; // returns email field in response, will not raise InvalidEmailError 

op createUser(...User): User; // returns email field in response, can raise InvalidEmailError

op deleteUser(id: string): User; // does not return email field in response, will not raise InvalidEmailError
Output Contexts

By default, errors with Lifecycle.Read are included when the model is used in an output context.

op getUser(id: string): User | PermissionDeniedError | GenericError;
Both Contexts

Errors can apply to both input and output contexts by specifying multiple lifecycle stages.

@error(Lifecycle.Create, Lifecycle.Read)
model GenericError {
  message: string;
}
No contexts

Just as is true for visibility, if no context is specified, the error model will be included in all of the default context modifiers applied by default by the visibility class.

Context follows visibility

There are a number of ways to modify the visibility of a model or operation. Context modifiers, as applied to errors, will follow the same rules as they do for visibility.

For example, use of the @parameterVisibility or @returnTypeVisibility decorators will modify the visibility of the error model in the same way as it does for parameters. That is, the properties of a model used as a parameter will apply their @raises errors based on the visibility of parameters. The properties of a model used as a return type will apply their @raises errors based on the visibility of the return type.

This also means that decorators which apply implicit visibility, such as @post or @put, will apply the implicit visibility of the operation to the error model.

Any other modification of visibility including visibility filters, custom context classes, et. al. should affect errors in the same way as they affect model properties.

Rejected alternative: Context modifiers on @raises and @handles

An alternative to adding context modifiers to the @error decorator is to add them to the @raises and @handles decorators.

This would allow developers to specify the context in which an error applies model property by model property, rather than applying to an error model everywhere it appears.

Such an alternative approach might look something like:

model User {
  @key id: string;

  @visibility(Lifecycle.Create, Lifecycle.Update, Lifecycle.Read)
  @raises([InvalidEmailError], [Lifecycle.Create, Lifecycle.Update])
  email: string;
}

While this approach does allow for finer granularity in specifying the context in which an error applies, it also adds complexity to the @raises and @handles decorators — and complexity for the developer to reason about the context in which an error applies. Applying context modifiers to the @error decorator abstracts the concerns of context away from any particular field or operation, so the developer does not always need to be considering it. It seems fairly intuitive for a developer to specify that an InvalidParametersError would only apply in input contexts, while a PermissionDeniedError would only apply in output contexts.

If context modifiers are specified on the @raises and @handles decorators, it is likely that the developer forgets to add all of the relevant lifecycle modifiers in some cases. This would result in operations insufficiently specifying errors, leading to clients receiving errors that they do not expect from the spec.

By contrast, adding context modifiers to the @error decorator is more likely to add errors in more contexts than are needed; while not ideal, specifying extra errors in the spec that will never be returned is less problematic than omitting errors that will be. Indeed, there's no guarantee that any error specified ever actually will be.


Identifying Unused Error Handlers

TypeSpec only knows, and can only reason about, errors that are specified in a @raises decorator. If an error is specified in a @handles decorator but not in any @raises decorator of all the model properties that are part of that property or operation, the TypeSpec compiler will not be able to determine whether the error is actually used.

To help developers make that determination, the TypeSpec compiler can issue a warning when this scenario occurs. If the developer determines that the error is thrown outside of the context of TypeSpec, they can use the standard # suppress directive to suppress the warning.

This warning helps to avoid misleading consumers about an error type that may not actually occur.

Example: Unused Error Handler

Consider the following example:

@error
model NotFoundError {
  message: string;
}

@error
model PermissionDeniedError {
  message: string;
}

model User {
  @raises(NotFoundError)
  profilePictureUrl: string;
}

@handles(PermissionDeniedError)
op getUser(id: string): User | NotFoundError;

In this example, the getUser operation specifies that it handles PermissionDeniedError using the @handles decorator. However, none of the properties or operations used in getUser (in this case, just the User.profilePictureUrl property) specify PermissionDeniedError in their @raises decorators.

As a result, the TypeSpec compiler will issue a warning.

steverice avatar Jun 09 '25 22:06 steverice