Skip to content

Legacy union constructors build a broken object instead of throwing #1640

Description

@quietbits

Legacy union constructors build a broken object instead of throwing, on all 115 generated union types

Severity: Low. Code-only — the documentation is already correct and complete. Tested on 17.0.0-rc.1 from npm; Node 22.22.0, Deno 2.8.3, Bun 1.3.14 identical.

v17 replaced new xdr.Union(discriminant, value) with arm factories (xdr.TransactionMeta.v3(value)). The old form does not throw — it returns an instance of the abstract base, silently discarding both arguments:

const m = new xdr.TransactionMeta(3, someValue);
m.constructor.name;   // "TransactionMetaBase"
Object.keys(m);       // []        <-- discriminant and value are gone
m.type;               // undefined
m.toXdr();            // TypeError: this.toXdrObject is not a function

toJson() and toXdr("base64") throw the identical TypeError, all three from lib/esm/xdr/values/xdr-value.js:11 — inside the SDK's own serialization, naming neither the union nor the call that created it. Because the broken object is constructed successfully and only fails when serialized, the throw can surface arbitrarily far from the responsible new xdr.TransactionMeta(...) — including as a whole module failing at import, when the call sits in a module-level constant.

Every generated union is affected. A union is exported under a name whose class is the abstract <Name>Base; sweeping those and calling the legacy form gives 115 of 115:

import { xdr } from "@stellar/stellar-sdk";
const unions = Object.entries(xdr).filter(([k, v]) => typeof v === "function" && v.name === k + "Base");
let broken = 0;
for (const [k, U] of unions) {
  const o = new U(0, null);
  if (o.constructor.name === k + "Base" && Object.keys(o).length === 0 && o.type === undefined) broken++;
}
console.log(broken, "of", unions.length); // 115 of 115 — none throw, none retain the arguments

grep -c "declare abstract class .*Base" lib/esm/xdr/generated/*.d.ts totals the same 115. ScVal, Asset, Memo, AccountEntryExt, OperationBody, PublicKey and LedgerKey are all included. (The 580 concrete arm classes — AccountEntryExtV0 and the like — are a separate surface and are not what this issue is about.)

The SDK already guards the equivalent mistake. xdr.Int64 / Uint64 / Int32 throw at the call site naming the fix, and enums fail immediately too — xdr.AssetType.assetTypeNative is the enum singleton rather than a factory, so the legacy call raises TypeError: xdr.AssetType.assetTypeNative is not a function:

TypeError: new xdr.Int64(...) is not supported: XDR int64 values are native bigints.
Call xdr.Int64(value) (no `new`) or pass a bigint literal instead.

§ 4 states the rationale — "a boxed bigint would fail deep inside serialization instead of at the call site" — which applies verbatim to unions.

Exposure: plain JavaScript, and TypeScript run without a type-check pass. The declarations already say these bases are abstract (declare abstract class TransactionMetaBase extends XdrValue in transaction-meta.d.ts:42), so new xdr.TransactionMeta(3, v) is TS2511: Cannot create an instance of an abstract class — the runtime simply does not enforce what the .d.ts promises. The documented narrowing helpers also behave correctly (isUnionVariantfalse, expectUnionVariantExpected XDR variant 'v3', got 'undefined' (TransactionMetaBase)). The difficulty is localization, not comprehension — § 2 and § 10 already give the right replacement. TransactionMeta.is(m) returns true, since it tests instanceof TransactionMetaBase and the broken object genuinely is one.

Reproduce

import { xdr } from "@stellar/stellar-sdk";
const m = new xdr.TransactionMeta(3, null);
console.log(m.constructor.name, Object.keys(m), m.type); // TransactionMetaBase [] undefined
m.toXdr(); // TypeError: this.toXdrObject is not a function

Suggested fix

Give the generated union base classes a constructor that throws in the same shape as xdr.Int64, naming the arm factories — e.g. new xdr.TransactionMeta(...) is not supported: call xdr.TransactionMeta.v3(value) instead. Applying an established pattern to the remaining 115; every legacy call site then fails at the line that needs changing.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Status
Done

Relationships

None yet

Development

No branches or pull requests

Issue actions