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
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
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:
constclient=awaitcontract.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' typethis[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:
Who hits it: any TypeScript consumer that builds a client at runtime from
a contract's on-chain spec instead of using generated bindings — i.e. anyone
who does not have (or does not want to pre-generate) a typed binding package
for the target contract. Common in scripts, explorers, dynamic/multi-contract
tools, and quick-start docs.
Every from-built client is affected.from, fromWasm, and fromWasmHash all return a bare Client (src/contract/client.ts:148-199),
and deploy()'s parseResultXdr also constructs a plain Client
(src/contract/client.ts:84-88). No runtime-built client exposes typed
methods.
Workaround is as any, which discards all type safety for the call:
no method-name checking, no argument validation, no return-type inference on
the resulting AssembledTransaction<T>.
TS2339: Property 'increment' does not exist on type 'Client'.
The only way to make it compile today is to cast away the type:
consttx=await(clientasany).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:
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.:
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>.
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).
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
This is an enhancement, not a bug: the runtime behavior is correct and
intentional, and typed clients are expected to come from generated bindings.
It is not a regression — from* has returned a bare Client and attached
methods dynamically on published releases as well; the @ts-ignore is
long-standing source.
Summary
On the
modernizationbranch (and on published releases), acontract.Clientbuilt at runtime from a contract's on-chain spec has no typed contract
methods.
Client.from(),Client.fromWasm(), andClient.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 theassignment is silenced with
// @ts-ignore. So in TypeScript: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 subclasswith 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 aretype-checked as a real consumer (importing
@stellar/stellar-sdk). The runtimecall worked, but
client.<method>()failed the consumer type-check, forcing anas anycast in the snippet. Tracing that contradiction (JS works, types don't)led to the dynamic method attachment below.
Root cause
Clientis declared with only its static factories, theconstructor,spec,options, andtxFromJSON— it has no index signature and no per-contractmethod 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
thisunder themethod's name. That assignment is untypeable against the class shape, so it is
silenced:
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
a contract's on-chain spec instead of using generated bindings — i.e. anyone
who does not have (or does not want to pre-generate) a typed binding package
for the target contract. Common in scripts, explorers, dynamic/multi-contract
tools, and quick-start docs.
from-built client is affected.from,fromWasm, andfromWasmHashall return a bareClient(src/contract/client.ts:148-199),and
deploy()'sparseResultXdralso constructs a plainClient(
src/contract/client.ts:84-88). No runtime-built client exposes typedmethods.
as any, which discards all type safety for the call:no method-name checking, no argument validation, no return-type inference on
the resulting
AssembledTransaction<T>.stripInternal+ over-broad@internaltags strip public API from emitted.d.ts(Transaction.sign/toXDR, all CallBuilders, AccountResponse, friendbot, …) #1432.stripInternal+ over-broad@internaltags strip public API from emitted.d.ts(Transaction.sign/toXDR, all CallBuilders, AccountResponse, friendbot, …) #1432 is a regression where genuinely-public symbolsare stripped from the emitted
.d.tsbystripInternal. Here the contractmethods are not static members at all — they are attached dynamically and
deliberately
@ts-ignore'd — so there is nothing for declaration emit tostrip. Fixing
stripInternal+ over-broad@internaltags strip public API from emitted.d.ts(Transaction.sign/toXDR, all CallBuilders, AccountResponse, friendbot, …) #1432 does not makeclient.increment()type-check.Reproduction
The only way to make it compile today is to cast away the type:
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 ofpreference:
Generic
from*factories parameterized by a method interface. Letconsumers pass a contract-method interface as a type argument so the returned
client is typed, e.g.:
This is non-breaking (defaults to today's
Client), requires no codegen, andlets bindings-free consumers bring their own types. Implement by making
from/fromWasm/fromWasmHashgeneric and returningPromise<Client & T>.Add an index signature for the dynamic methods on
Client(e.g.[method: string]: (...args: any[]) => Promise<AssembledTransaction<any>>).This removes the
as anyrequirement and the@ts-ignore, and restoresautocomplete-of-last-resort, but gives no per-method arg/return precision and
weakens checking on the real static members. Lower value than (1).
Document the cast. If neither is desired, explicitly document that
from*-built clients are untyped and that bindings are required for typedcalls. 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
intentional, and typed clients are expected to come from generated bindings.
from*has returned a bareClientand attachedmethods dynamically on published releases as well; the
@ts-ignoreislong-standing source.
stripInternal+ over-broad@internaltags strip public API from emitted.d.ts(Transaction.sign/toXDR, all CallBuilders, AccountResponse, friendbot, …) #1432: distinct root cause.stripInternal+ over-broad@internaltags strip public API from emitted.d.ts(Transaction.sign/toXDR, all CallBuilders, AccountResponse, friendbot, …) #1432 strips already-public typesfrom
.d.ts; this is about methods that were never statically typed. Both canaffect a TypeScript consumer of the contract client, but neither fix resolves
the other.
currently must
as anythe contract-method calls to type-check.Environment
modernization(also reproduces on published@stellar/stellar-sdk).src/contract/client.ts(method attachment +@ts-ignoreat lines130-134;
from/fromWasm/fromWasmHashat 148-199), returningPromise<AssembledTransaction<T>>per method(
src/contract/assembled_transaction.ts:572-574).5.9.3.stripInternal+ over-broad@internaltags strip public API from emitted.d.ts(Transaction.sign/toXDR, all CallBuilders, AccountResponse, friendbot, …) #1432 (distinct root cause), AccountResponse from loadAccount() is not assignable to TransactionBuilder under generated types (Account private-field nominal brand) #1435 (distinct;Accountnominal brand).Suggested labels: enhancement, typescript, contract-client, dx