You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
postOrder() return type Promise<OrderResponse> doesn't include the error shape returned at runtime when throwOnError is not set #91
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
constclient=newClobClient({ host, chain, signer, creds })// throwOnError not setconstresp=awaitclient.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:
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.:
Or, provide two distinct call paths / overloads keyed off throwOnError so the return type is correctly inferred based on client configuration.
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.
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:
typeClobError={error: string;status: number};functionisClobError(r: unknown): r is ClobError{returntypeofr==="object"&&r!==null&&"error"inr;}// Usage:constresult=awaitclient.postOrder(order,{throwOnError: false});if(isClobError(result)){// TypeScript now narrows correctlyconsole.error(`Order failed [${result.status}]: ${result.error}`);if(result.error.includes("not enough balance")){awaitclient.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.
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`.
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 orderIDand 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.
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 neededfunctionisCleanKill(r: PostOrderResult): r is OrderCleanKill{return"error"inr&&"orderID"inr;// 400 + orderID = FOK/FAK clean kill}functionisOrderError(r: PostOrderResult): r is OrderError{return"error"inr&&!("orderID"inr);// any other non-2xx = genuine rejection}functionisOrderSuccess(r: PostOrderResult): r is OrderSuccess{return!("error"inr);}
Concrete type union for all three observed shapes:
// 200/201 — order accepted (resting GTC, or immediate fill)exporttypeOrderSuccess={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 eithermakingAmount?: 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)exporttypeOrderCleanKill={orderID: string;error: string;status: 400;};// non-2xx, no orderID — genuine rejection (bad sig, insufficient allowance, geoblock, etc.)exporttypeOrderError={error: string;status: number;};exporttypePostOrderResult=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:
Activity
osr21 commentedon Jul 23, 2026
Practical workaround until the types are fixed
Building PolyWatch I hit this exact issue —
postOrder()typed asPromise<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:
The correct fix in the SDK would be to change the return type to:
Happy to submit a PR for this if the team is open to it.
osr21 commentedon Jul 23, 2026
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
.orderIDon an error object.fix: broaden postOrder/postOrders return type to include ClobErrorRes…
CrisisAlpha commentedon Jul 25, 2026
Observed wire shapes from production that the type (and any
throwOnErrorhandling) would need to model — some are surprising:orderIDand 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.transactionsHashes: string[](first entry = the settling batch tx) and fill amounts that arrive in both camelCase and snake_case depending on path (makingAmountvsmaking_amount) — a strict decoder that only accepts one form breaks on the other.size_matched(orGET /data/order/<id>), not the requested size — fixed-USD market buys fill a variable number of shares.osr21 commentedon Jul 25, 2026
Good data — the wire shapes @CrisisAlpha documented are the critical input for getting the union type right. A few additions:
Use
"orderID" in respas the clean-kill discriminant, not a regex:The structural presence of
orderIDin 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:Concrete type union for all three observed shapes:
throwOnError: trueis the wrong mode for FOK/FAK orders:When
throwOnError: trueis set and a FOK/FAK clean kill comes back as HTTP 400, the SDK throws anApiError— but the thrown error does not include theorderIDfrom 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 usethrowOnError: falseand narrow withisCleanKill()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 — nottransactionHashes. Any type definition or decoder that uses the grammatically correct spelling will silently drop the field. This applies to bothOrderSuccessand the fill events emitted over the WebSocket.camelCase/snake_case duality scope:
The duality extends to at least
takingAmount/taking_amountsymmetrically withmakingAmount/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: