Skip to content

Effect API

Midday SDK is built on Effect, a powerful TypeScript library for building robust, composable applications. The SDK follows the Hybrid API Pattern where Effect is the source of truth.

Why Effect?

Effect provides:

  • Type-safe errors - Errors are part of the type signature
  • Composability - Build complex workflows from simple operations
  • Dependency injection - Swap implementations for testing
  • Resource management - Automatic cleanup of resources
  • Concurrency - Built-in support for parallel and sequential operations

API Styles Comparison

Simplest approach, ideal for quick prototypes. Uses method chaining:

const client = await Midday.Client.create(config);
const loaded = await client.loadContract({ module, zkConfig, privateStateId: 'my-id' });
const deployed = await loaded.deploy();
await deployed.actions.increment();
const state = await deployed.ledgerState();

The Hybrid Pattern

The SDK follows these principles:

  1. Effect is source of truth - All logic is implemented as Effect functions
  2. Client is the hub - Everything flows from the client
  3. Two interfaces - .effect.method() for Effect users, .method() for Promise users
  4. Two-handle pattern - LoadedContract and DeployedContract are distinct types
// LoadedContract — returned by loadContract()
interface LoadedContract<TLedger, TCircuits, TActions> {
readonly module: LoadedContractModule;
readonly providers: ContractProviders;
deploy(options?: DeployOptions): Promise<DeployedContract<TLedger, TCircuits, TActions>>;
join(address: string, options?: JoinOptions): Promise<DeployedContract<TLedger, TCircuits, TActions>>;
readonly effect: {
deploy(options?: DeployOptions): Effect.Effect<DeployedContract, ContractError | TxTimeoutError>;
join(address: string, options?: JoinOptions): Effect.Effect<DeployedContract, ContractError | TxTimeoutError>;
};
}
// DeployedContract — returned by deploy() or join()
interface DeployedContract<TLedger, TCircuits, TActions> {
readonly address: string;
readonly actions: TActions; // typed action methods
call(action: TCircuits, ...args: unknown[]): Promise<CallResult>;
ledgerState(): Promise<TLedger>;
ledgerStateAt(blockHeight: number): Promise<TLedger>;
getState(): Promise<unknown>;
getStateAt(blockHeight: number): Promise<unknown>;
onStateChange(callback: (state: TLedger) => void): Unsubscribe;
onRawStateChange(callback: (state: unknown) => void): Unsubscribe;
watchState(): AsyncIterableIterator<TLedger>;
watchRawState(): AsyncIterableIterator<unknown>;
readonly effect: {
readonly actions: ToEffectActions<TActions>;
call(action: TCircuits, ...args: unknown[]): Effect.Effect<CallResult, ContractError>;
ledgerState(): Effect.Effect<TLedger, ContractError>;
ledgerStateAt(blockHeight: number): Effect.Effect<TLedger, ContractError>;
getState(): Effect.Effect<unknown, ContractError>;
getStateAt(blockHeight: number): Effect.Effect<unknown, ContractError>;
watchState(): Stream.Stream<TLedger, ContractError>;
watchRawState(): Stream.Stream<unknown, ContractError>;
};
}

Running Effects

With runEffectPromise

The simplest way to run an Effect:

const result = await Midday.Runtime.runEffectPromise(program);

With Effect.runPromise

For more control:

const result = await Effect.runPromise(
program.pipe(Effect.provide(ClientLayer))
);

Available Services

ServiceLayer FactoryDescription
Client.MiddayClientServiceClient.layer(config)Pre-initialized client (scoped — auto-closes)
Client.ClientServiceClient.ClientLiveClient factory service
ClusterServiceCluster.managedLayer(config?)Managed devnet cluster (auto-starts/removes)

Using Layers

For most applications, use Client.layer() to create a scoped client layer. The client is automatically closed when the scope ends:

import * as Midday from '@no-witness-labs/midday-sdk';
import { Effect } from 'effect';
// Create scoped client layer — auto-closes when done
const ClientLayer = Midday.Client.layer({
seed: 'your-64-char-hex-seed',
networkConfig: Midday.Config.NETWORKS.local,
privateStateProvider: Midday.PrivateState.inMemoryPrivateStateProvider(),
});
const program = Effect.gen(function* () {
const client = yield* Midday.Client.MiddayClientService;
const loaded = yield* client.effect.loadContract({
module: CounterContract,
zkConfig: Midday.ZkConfig.fromPath('./contracts/counter'),
privateStateId: 'my-counter',
});
const deployed = yield* loaded.effect.deploy();
return deployed;
});
await Effect.runPromise(program.pipe(Effect.provide(ClientLayer)));

Layer Composition with Devnet

For applications that also manage a local devnet cluster, compose layers so cleanup happens automatically in LIFO order:

import { Cluster, ClusterService } from '@no-witness-labs/midday-sdk/devnet';
import { Layer } from 'effect';
// Managed cluster — auto-starts on creation, auto-removes on release
const ClusterLayer = Cluster.managedLayer({ clusterName: 'my-app' });
// Derives a client from the cluster service
const ClientFromCluster = Layer.scoped(
Midday.Client.MiddayClientService,
Effect.gen(function* () {
const cluster = yield* ClusterService;
return yield* Midday.Client.effect.createScoped({
seed: Midday.Config.DEV_WALLET_SEED,
networkConfig: cluster.networkConfig,
privateStateProvider: Midday.PrivateState.inMemoryPrivateStateProvider(),
});
}),
);
// Compose: Cluster feeds into Client
const AppLayer = ClientFromCluster.pipe(Layer.provide(ClusterLayer));
// Run — zero lifecycle code
await Effect.runPromise(program.pipe(Effect.provide(AppLayer)));

Client Factory Service

For creating multiple clients with different configs:

const program = Effect.gen(function* () {
const clientService = yield* Midday.Client.ClientService;
// Create different clients
const localClient = yield* clientService.create(localConfig);
const testnetClient = yield* clientService.create(testnetConfig);
return { localClient, testnetClient };
});
await Effect.runPromise(program.pipe(Effect.provide(Midday.Client.ClientLive)));

Error Handling

Effect makes errors explicit in the type system:

const program = Effect.gen(function* () {
const client = yield* Midday.Client.effect.create(config);
const loaded = yield* client.effect.loadContract({ module, zkConfig, privateStateId: 'my-id' });
const deployed = yield* loaded.effect.deploy();
const result = yield* deployed.effect.call('increment');
return result;
}).pipe(
Effect.catchTag('ContractError', (error) => {
console.error('Contract operation failed:', error.message);
return Effect.succeed({ status: 'failed' });
}),
Effect.catchTag('TxTimeoutError', (error) => {
console.error('Timeout:', error.message);
return Effect.succeed({ status: 'timeout' });
}),
Effect.catchAll((error) => {
console.error('Unexpected error:', error);
return Effect.fail(error);
})
);

State Watching with Effect Streams

Effect users get Stream variants for state watching:

import { Stream } from 'effect';
const program = Effect.gen(function* () {
const deployed = /* ... */;
// Watch as Effect Stream
const stream = deployed.effect.watchState();
// Take first 5 state changes
yield* stream.pipe(
Stream.take(5),
Stream.runForEach((state) =>
Effect.sync(() => console.log('Counter:', state.counter))
),
);
});

Testing with Effect

Replace services with test implementations:

import { Layer, Effect } from 'effect';
// Create mock client matching the MiddayClient interface
const mockClient: Midday.Client.MiddayClient = {
networkConfig: Midday.Config.NETWORKS.local,
providers: mockProviders,
wallet: null,
relayerWallet: null,
loadContract: () => Promise.resolve(mockLoadedContract),
waitForTx: () => Promise.resolve({ txHash: '0x123', blockHeight: 1, blockHash: '0xabc' }),
close: () => Promise.resolve(),
[Symbol.asyncDispose]: async () => {},
effect: {
loadContract: () => Effect.succeed(mockLoadedContract),
waitForTx: () => Effect.succeed({ txHash: '0x123', blockHeight: 1, blockHash: '0xabc' }),
close: () => Effect.void,
},
};
const TestClientLayer = Layer.succeed(Midday.Client.MiddayClientService, mockClient);
// Run tests with mock client
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestClientLayer))
);

Parallel Operations

Effect makes concurrent operations easy:

const program = Effect.gen(function* () {
// Run multiple contract calls in parallel
const [result1, result2] = yield* Effect.all([
contract1.effect.call('action1'),
contract2.effect.call('action2'),
], { concurrency: 'unbounded' });
return { result1, result2 };
});

Resource Management

Effect handles cleanup automatically with scoped resources:

When using Client.layer() or Cluster.managedLayer(), resources are cleaned up automatically when the Effect scope ends — no manual close() needed:

const ClientLayer = Midday.Client.layer(config);
// Client is auto-closed when runPromise completes
await Effect.runPromise(program.pipe(Effect.provide(ClientLayer)));

Scoped Resources

For fine-grained control, use createScoped() and makeScoped() directly:

const program = Effect.scoped(
Effect.gen(function* () {
const client = yield* Midday.Client.effect.createScoped(config);
// client is auto-closed when this scope exits
const loaded = yield* client.effect.loadContract({ module, zkConfig, privateStateId: 'my-id' });
const deployed = yield* loaded.effect.deploy();
yield* deployed.effect.actions.increment();
}),
);

Manual Close (Promise API)

When using the Promise API, always close the client explicitly:

const client = await Midday.Client.create(config);
try {
// ...
} finally {
await client.close();
}