This guide provides specific guidelines for contributing to the TypeScript AI Agent Toolkit.
-
Install Node.js 20.12.2 or later
-
Install a package manager (npm, pnpm 9.14.2+, or yarn)
-
Clone the repository
-
Install dependencies:
# Using npm npm install # Using pnpm pnpm install # Using yarn yarn
-
Make sure tests pass:
# Using npm npm test # Using pnpm pnpm test # Using yarn yarn test
We use async/await consistently and expose top-level async functions for creating classes:
// In class definition
export class MyClass {
private constructor(/* parameters */) {}
static async New(url: string, ...opts: Option[]): Promise<MyClass> {
// implementation
return new MyClass(/* parameters */);
}
}
// In main export
export async function NewMyClass(url: string, ...opts: Option[]): Promise<MyClass> {
return MyClass.New(url, ...opts);
}
We use the functional options pattern for optional configuration, but not for required parameters:
// Good use of options pattern - for optional configuration
export async function NewClient(url: string, ...opts: Option[]): Promise<Client> {
const options = defaultOptions();
for (const opt of opts) {
opt(options);
}
// Use options to configure the client
return new Client(url, options);
}
// Not using options for required parameters
export function NewContract(address: Address, abi: ABI): Contract {
return new Contract(address, abi);
}
When to use the options pattern:
- For truly optional configuration (logging, interceptors, timeouts)
- When extending functionality without changing method signatures
- When configurations may grow over time
When NOT to use the options pattern:
- For required parameters (addresses, ABIs, bytecode)
- When parameters are essential to the object's function
- When clarity of required inputs is important
Key benefits:
- Clear distinction between required and optional parameters
- Self-documenting parameter names with the
with*
prefix - Extensibility without breaking changes
- Graceful defaults for optional parameters
- Use strict TypeScript configuration
- Avoid
any
type - Use branded types when appropriate
- Leverage union types for better type safety
We use two distinct patterns for classes in our AI Agent Toolkit, depending on class complexity and lifecycle requirements:
For simple data structures that don't require async initialization or complex configuration:
// Simple value object with public constructor
export class Address {
private readonly data: Uint8Array;
constructor(data: Address | BytesLike | string) {
// Immediate initialization
if (data instanceof Address) {
this.data = data.bytes();
} else if (typeof data === 'string') {
this.data = eth.getBytes(data.startsWith('0x') ? data : `0x${data}`);
} else {
this.data = eth.getBytes(data);
}
}
// Methods
bytes(): Uint8Array { return this.data; }
}
When to use this pattern:
- Simple value objects with synchronous initialization
- Core data structures (Address, ABI, etc.)
- Classes with minimal or no dependencies on external systems
- Objects that don't require complex configuration
For classes that require async initialization or complex configuration:
// Service object with both public constructor and static factory method
export class Client {
private readonly ethClient: Provider;
// Public constructor for direct instantiation
constructor(provider: Provider, httpClient?: HttpClient) {
this.ethClient = provider;
this._httpClient = httpClient ?? globalThis.fetch;
}
// Static factory for configuration and async initialization
static async New(url: string, ...opts: ClientOption[]): Promise<Client> {
const options: ClientOptions = {};
for (const opt of opts) {
opt(options);
}
// Async initialization work (like connecting to network)
const provider = new eth.JsonRpcProvider(url);
await provider.getNetwork();
return new Client(provider, options.httpClient);
}
}
// In main export
export async function NewClient(url: string, ...opts: ClientOption[]): Promise<Client> {
return Client.New(url, ...opts);
}
When to use this pattern:
- Classes that require async initialization or validation
- Services with dependencies on external systems
- Objects that support the functional options pattern
- Classes with complex configuration requirements
Both patterns should:
- Use explicit interface implementation
- Mark properties as readonly when appropriate
- Hide implementation details with private modifiers
- Have corresponding top-level factory functions in the main export
- Use variadic arguments (...args) instead of arrays where possible
- When interfacing with underlying libraries that require arrays, convert variadic arguments internally
- Document when methods accept variable arguments
Example:
// ✅ Use variadic arguments for optional parameters
async someFunc(foo: string, bar: Number, ...args: unknown[]): Promise<void>
// ❌ Avoid using arrays for optional parameters
async someFunc(foo: string, bar: Number, args: unknown[] = []): Promise<void>
We use Vitest for testing:
describe('MyClass_someFunc', () => {
const tests = [
{
name: 'simple case',
foo: 'test',
bar: 42,
want: 'expected result',
},
{
name: 'error case',
foo: 'invalid',
wantErr: true,
},
];
tests.forEach(({ name, foo, bar, want, wantErr }) => {
test(name, async () => {
if (wantErr) {
await expect(MyClass.New(foo, bar)).rejects.toThrow();
} else {
const mc = await MyClass.New(foo, bar);
const result = await mc.someFunc();
expect(result).toBe(want);
}
});
});
});
- Use separate test files
- Handle cleanup properly
- Set appropriate timeouts
- Use environment variables for configuration
- Keep files focused and small
- Clear module exports
- Consistent import ordering
- Follow TypeScript project references
- Maintain our repository structure:
radius/
: Public API package that users importsrc/
: Implementation detailstest/
: Test utilities and integration tests
- Use PascalCase for classes and interfaces
- Use camelCase for methods and properties
- Use UPPER_CASE for constants
Before submitting:
-
Run tests:
pnpm test
-
Run linter:
pnpm lint
-
Format code:
pnpm format
For convenience, you can fix all lint and formatting errors by running the cleanup script:
- On Windows:
cleanup.bat
- On MacOS/Linux:
./cleanup.sh
We use tsup for building:
- Generates both ESM and CJS outputs
- Handles type definitions
- Manages source maps
- Optimizes production builds
- Keep dependencies minimal
- Use peer dependencies appropriately
- Lock versions in package.json
- Document why each dependency is needed
Follow these principles:
- Use typed errors
- Provide meaningful error messages
- Handle async errors properly
- Use error subclasses when appropriate
- Encapsulate error handling in abstraction layers
- Prevent leaking implementation details in error messages
Example of proper error handling pattern:
try {
const result = await this.provider.send(tx);
if (!result) {
throw new Error('Transaction failed: no result returned');
}
return result;
} catch (error) {
throw new Error(`Failed to send transaction: ${error instanceof Error ? error.message : String(error)}`);
}
Implement interfaces explicitly:
interface SomeInterface {
someFunc(): Promise<string>;
}
export class MyImplementation implements SomeInterface {
async someFunc(): Promise<string> {
return 'result';
}
}
- Update README.md
- Include TSDoc comments
- Maintain CHANGELOG.md
- Document breaking changes
- Include usage examples
- Export public types
- Use precise types when possible
- Document complex types
- Use utility types appropriately
We use Biome for linting and formatting:
- Follow configured rules
- Use provided formatter
- Address all linting errors
- Maintain configuration in biome.json
- The
dist
andnode_modules
directories are excluded from linting
When running the linter:
# Lint entire project
pnpm lint
# Lint specific directories
pnpm lint:fix -- ./src/providers/eth/
Our Biome configuration includes directory exclusions to avoid linting build artifacts:
{
"files": {
"ignore": ["dist/**/*", "node_modules/**/*"]
},
// Other Biome configuration...
}