Skip to content

contract.Client.from()/fromWasm/fromWasmHash return an untyped Client: dynamic contract methods are @ts-ignore'd, so client.method() is a TS error (consumers must cast as any) #1439

@oceans404

Description

@oceans404

Summary

On the modernization branch (and on published releases), a contract.Client
built at runtime from a contract's on-chain spec has no typed contract
methods
. Client.from(), Client.fromWasm(), and Client.fromWasmHash()
all resolve to a plain Client, but the contract's methods (e.g. increment,
hello) are attached to the instance dynamically in the constructor and the
assignment is silenced with // @ts-ignore. So in TypeScript:

const client = await contract.Client.from(opts);
client.increment();   // TS2339: Property 'increment' does not exist on type 'Client'.

Consumers must fall back to (client as any).increment(), losing autocomplete,
argument checking, and return-type inference for every contract call.

This appears to be by design — the typed path is the generated bindings
(stellar contract bindings typescript), which emit a per-contract subclass
with declared method signatures. So this is an enhancement request, not a
bug, and it is distinct from #1432 (which strips already-public symbols out
of the .d.ts; here the methods never had a static type to strip).

How this was found

While writing the new task-oriented developer guides (P27 docs effort), the
invoke-a-contract guide (guide 06) demonstrates calling a deployed contract via
contract.Client.from() rather than generated bindings. The guide snippets are
type-checked as a real consumer (importing @stellar/stellar-sdk). The runtime
call worked, but client.<method>() failed the consumer type-check, forcing an
as any cast in the snippet. Tracing that contradiction (JS works, types don't)
led to the dynamic method attachment below.

Root cause

Client is declared with only its static factories, the constructor, spec,
options, and txFromJSON — it has no index signature and no per-contract
method declarations
(src/contract/client.ts:37-199).

The contract's methods are attached at runtime in the constructor by
iterating the spec's functions and assigning a closure onto this under the
method's name. That assignment is untypeable against the class shape, so it is
silenced:

// src/contract/client.ts:130-134
// @ts-ignore error TS7053: Element implicitly has an 'any' type
this[sanitizeIdentifier(method)] =
  spec.getFunc(method).inputs().length === 0
    ? (opts?: MethodOptions) => assembleTransaction(undefined, opts)
    : assembleTransaction;

Each attached method returns a Promise<AssembledTransaction<T>>
(AssembledTransaction.build, src/contract/assembled_transaction.ts:572-574),
but because the property is added dynamically, none of this is visible to the
type system.

The factories that hand back these instances are all typed Promise<Client>,
so the dynamic methods are invisible regardless of entry point:

  • Client.from(options)Promise<Client> (src/contract/client.ts:188-199)
  • Client.fromWasm(wasm, options)Promise<Client> (src/contract/client.ts:176-179)
  • Client.fromWasmHash(wasmHash, options, format)Promise<Client> (src/contract/client.ts:148-166)

Impact / blast radius

Reproduction

import { contract } from "@stellar/stellar-sdk";

const client = await contract.Client.from({
  contractId: "C...",
  networkPassphrase: "Test SDF Network ; September 2015",
  rpcUrl: "https://soroban-testnet.stellar.org",
});

// Runtime: works. Type-check: fails.
const tx = await client.increment();
TS2339: Property 'increment' does not exist on type 'Client'.

The only way to make it compile today is to cast away the type:

const tx = await (client as any).increment(); // no autocomplete, no arg/return checking

Suggested fix

Keep generated bindings as the recommended typed path (they remain the most
precise: real param/return types derived from the contract spec). For the
runtime from* path, offer opt-in typing. Options, in rough order of
preference:

  1. Generic from* factories parameterized by a method interface. Let
    consumers pass a contract-method interface as a type argument so the returned
    client is typed, e.g.:

    interface Counter {
      increment(): Promise<AssembledTransaction<number>>;
    }
    const client = await contract.Client.from<Counter>(opts); // client.increment() type-checks

    This is non-breaking (defaults to today's Client), requires no codegen, and
    lets bindings-free consumers bring their own types. Implement by making
    from/fromWasm/fromWasmHash generic and returning Promise<Client & T>.

  2. Add an index signature for the dynamic methods on Client (e.g.
    [method: string]: (...args: any[]) => Promise<AssembledTransaction<any>>).
    This removes the as any requirement and the @ts-ignore, and restores
    autocomplete-of-last-resort, but gives no per-method arg/return precision and
    weakens checking on the real static members. Lower value than (1).

  3. Document the cast. If neither is desired, explicitly document that
    from*-built clients are untyped and that bindings are required for typed
    calls. This is the status quo plus docs; least preferred.

Recommendation: (1), with bindings kept as the documented "fully typed"
path. (1) and bindings compose — a consumer can pass the bindings' generated
method interface as the type argument.

Notes

Environment

Suggested labels: enhancement, typescript, contract-client, dx

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Backlog (Not Ready)

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions