Skip to content

postOrder() return type Promise<OrderResponse> doesn't include the error shape returned at runtime when throwOnError is not set #91

Open

Description

@bobanm

Summary

Release v1.1.0 changed the return type of ClobClient.postOrder() and ClobClient.postOrders() to OrderResponse instead of any.

But when throwOnError is not set [the default], a failed order submission resolves with a ClobErrorResponseBody shaped object { error: string; status: number } instead of an OrderResponse. TypeScript consumers get no compile-time warning that this can happen, and have to know to duck-type the response at runtime.

Steps to reproduce

const client = new ClobClient({ host, chain, signer, creds }) // throwOnError not set
const resp = await client.postOrder(signedOrder)
console.log(resp)

Expected

Given the declared signature Promise<OrderResponse>, resp should always be assignable to OrderResponse, or TypeScript should surface the alternative shape.

Actual

When the order is rejected by the backend [e.g. order size lower than the minimum], resp is:

{
  "error": "order 0x1111111111111111111111111111111111111111111111111111111111111111 is invalid. Size (1) lower than the minimum: 5",
  "status": 400
}

This matches ClobErrorResponseBody [already defined/imported in client.ts], not OrderResponse. Since the method's declared return type doesn't include this shape, code like resp.orderID type-checks fine but is undefined at runtime, silently.

Root cause

As far as I can trace, postOrder() delegates to the shared post() helper in src/http-helpers/index.ts. That helper branches on this.throwOnError:

  • throwOnError: true => non-2xx responses are thrown as ApiError.
  • throwOnError unset/false => non-2xx responses resolve with the parsed error body ClobErrorResponseBody instead of throwing.

Every public method that goes through this.get/post/del [not just postOrder] has this same mismatch between its declared return type and its actual runtime behavior in the default [non-throwing] mode.

Suggested fix

One of:

  1. Type these methods as Promise<OrderResponse | ClobErrorResponseBody> [or a discriminated union] when throwOnError is not guaranteed true, so consumers are forced to narrow the type before use, e.g.:
    public async postOrder(...): Promise<OrderResponse | ClobErrorResponseBody>
  2. Or, provide two distinct call paths / overloads keyed off throwOnError so the return type is correctly inferred based on client configuration.
  3. At minimum, document this explicitly on postOrder()'s JSDoc. The README mentions it generally, but it's easy to miss and isn't reflected in the types. Add a type guard helper [e.g. isClobError(resp): resp is ClobErrorResponseBody] exported from the package for consumers to use.

Activity

  1. osr21 commented on Jul 23, 2026

    @osr21

    Practical workaround until the types are fixed

    Building PolyWatch I hit this exact issue — postOrder() typed as Promise<OrderResponse> but resolves with { error: string; status: number } on failure.

    The cleanest workaround until the union return type is added is a type-guard helper:

    type ClobError = { error: string; status: number };
    
    function isClobError(r: unknown): r is ClobError {
      return typeof r === "object" && r !== null && "error" in r;
    }
    
    // Usage:
    const result = await client.postOrder(order, { throwOnError: false });
    
    if (isClobError(result)) {
      // TypeScript now narrows correctly
      console.error(`Order failed [${result.status}]: ${result.error}`);
      if (result.error.includes("not enough balance")) {
        await client.updateBalanceAllowance({ asset_type: AssetType.COLLATERAL });
      }
    } else {
      console.log("Order placed:", result.orderId);
    }

    The correct fix in the SDK would be to change the return type to:

    postOrder(order: SignedOrder, options?: PostOrderOptions): Promise<OrderResponse | ClobErrorResponseBody>;
    // or with throwOnError discriminant:
    postOrder(order: SignedOrder, options: { throwOnError: true }): Promise<OrderResponse>;
    postOrder(order: SignedOrder, options?: { throwOnError?: false }): Promise<OrderResponse | ClobErrorResponseBody>;

    Happy to submit a PR for this if the team is open to it.

  2. osr21 commented on Jul 23, 2026

    @osr21

    This is a real footgun. The runtime behavior and the declared type diverge silently, and the only way to discover it is a runtime exception when accessing .orderID on an error object.

    **Minimal type-safe fix** — change the return type to a discriminated union:
    
    ```ts
    // types.ts
    export type ClobErrorResponseBody = {
    error: string;
    status: number;
    };
    
    export type PostOrderResult = OrderResponse | ClobErrorResponseBody;
    
    // Type guard
    export function isOrderError(r: PostOrderResult): r is ClobErrorResponseBody {
    return "error" in r;
    }
    ```
    
    ```ts
    // ClobClient
    async postOrder(order: SignedOrder, options?: PostOrderOptions): Promise<PostOrderResult>
    ```
    
    Consumer code then becomes:
    
    ```ts
    const result = await client.postOrder(signedOrder);
    if (isOrderError(result)) {
    console.error("Order rejected:", result.error, result.status);
    return;
    }
    console.log("Order placed:", result.orderID);
    ```
    
    **Alternative if a breaking change is undesirable:** keep the current signature but throw by default and make `throwOnError: false` the opt-in, not opt-out. That way the default path never surprises TypeScript consumers with a mismatched runtime shape.
    
    Either way, the current behavior — where `throwOnError` defaults to `false` but the type says the happy path is the only path — should be documented prominently in the README and JSDoc on `postOrder`.
    
  3. CrisisAlpha commented on Jul 25, 2026

    @CrisisAlpha

    Observed wire shapes from production that the type (and any throwOnError handling) would need to model — some are surprising:

    • Non-2xx does not always mean failure. A FOK/FAK with no crossing liquidity returns HTTP 400 with a body that contains a real orderID and an error message matching /no orders?.{0,24}match/i — that's a clean kill (order never rested), not a rejection. We special-case exactly that pattern; every other non-2xx (bad signature, insufficient allowance, geoblock, malformed order) is a genuine error.
    • Successful matches carry transactionsHashes: string[] (first entry = the settling batch tx) and fill amounts that arrive in both camelCase and snake_case depending on path (makingAmount vs making_amount) — a strict decoder that only accepts one form breaks on the other.
    • For market BUYs, the authoritative fill size is size_matched (or GET /data/order/<id>), not the requested size — fixed-USD market buys fill a variable number of shares.
  4. osr21 commented on Jul 25, 2026

    @osr21

    Good data — the wire shapes @CrisisAlpha documented are the critical input for getting the union type right. A few additions:

    Use "orderID" in resp as the clean-kill discriminant, not a regex:

    The structural presence of orderID in a 400 body is a more reliable discriminant than matching the error string. Polymarket can change error message wording at any time; the field presence is part of the response contract:

    // Structural discriminants — no string matching needed
    function isCleanKill(r: PostOrderResult): r is OrderCleanKill {
      return "error" in r && "orderID" in r;          // 400 + orderID = FOK/FAK clean kill
    }
    function isOrderError(r: PostOrderResult): r is OrderError {
      return "error" in r && !("orderID" in r);        // any other non-2xx = genuine rejection
    }
    function isOrderSuccess(r: PostOrderResult): r is OrderSuccess {
      return !("error" in r);
    }

    Concrete type union for all three observed shapes:

    // 200/201 — order accepted (resting GTC, or immediate fill)
    export type OrderSuccess = {
      orderID: string;
      success: boolean;
      transactionsHashes: string[];          // note: "transactions" plural — API typo, baked in since V1
      // fill amounts arrive in BOTH forms depending on code path — accept either
      makingAmount?: string; making_amount?: string;
      takingAmount?: string; taking_amount?: string;
      size_matched?: string;
    };
    
    // 400 + orderID — FOK/FAK with no crossing liquidity (clean kill, not an error)
    export type OrderCleanKill = {
      orderID: string;
      error: string;
      status: 400;
    };
    
    // non-2xx, no orderID — genuine rejection (bad sig, insufficient allowance, geoblock, etc.)
    export type OrderError = {
      error: string;
      status: number;
    };
    
    export type PostOrderResult = OrderSuccess | OrderCleanKill | OrderError;

    throwOnError: true is the wrong mode for FOK/FAK orders:

    When throwOnError: true is set and a FOK/FAK clean kill comes back as HTTP 400, the SDK throws an ApiError — but the thrown error does not include the orderID from the response body. You lose the order reference entirely. FOK/FAK callers that need to track the order (even a killed one, for reconciliation) must use throwOnError: false and narrow with isCleanKill() rather than catching the thrown error.

    transactionsHashes — the typo is load-bearing:

    The field is transactionsHashes ("transactions" plural) in both V1 and V2 API responses — not transactionHashes. Any type definition or decoder that uses the grammatically correct spelling will silently drop the field. This applies to both OrderSuccess and the fill events emitted over the WebSocket.

    camelCase/snake_case duality scope:

    The duality extends to at least takingAmount/taking_amount symmetrically with makingAmount/making_amount. The pattern correlates loosely with code path (GTC fill → camelCase; market/FOK fill → snake_case) but is not guaranteed — the safe approach is to read both with ?? rather than branching on order type:

    const filled = result.makingAmount ?? result.making_amount ?? "0";
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions