Part 1 of the Journey: The Chaos Era - The Problem & The Pain Previous: The 5,847-Line Nightmare | Next: The Git Submodule Disaster
The TypeScript Migration: From Chaotic JS to Type-Safe Production
How we converted 67,000 lines of JavaScript to TypeScript without breaking everything
Historical Context (November 2025): Built August 2025, during early MCP ecosystem development. We were establishing patterns for production TypeScript implementations before community standards existed. This migration taught us lessons that informed the later MCP Factory automation.
Date: August 15, 2025 Author: Myron Koch & Claude Code Category: Technical Transformation
The Starting Point
// ethereum-mcp-server/index.js - Line 1,234
function getBalance(address) {
return web3.eth.getBalance(address);
}
// Called from somewhere with:
getBalance('0x123...'); // Works
getBalance({ address: '0x123' }); // Runtime error
getBalance(); // Runtime error
getBalance(null); // Runtime error
getBalance(123); // Runtime error
All possible. None prevented. Everything discovered at runtime.
The Decision
Team: "Should we migrate to TypeScript?"
Arguments FOR:
- Catch errors before runtime
- Better IDE support
- Self-documenting code
- Industry standard
Arguments AGAINST:
- 67,000 lines to convert
- Learning curve
- Build complexity
- "JavaScript works fine!"
Us: "Let's do it. We'll regret it, but we'll do it anyway."
The Naive First Attempt
# Step 1: Rename all .js to .ts
find . -name "*.js" -exec bash -c 'mv "$1" "${1%.js}.ts"' - {} \;
# Step 2: Run TypeScript compiler
npx tsc
# Result:
Found 2,847 errors
# us: *stares in horror*
The Errors We Saw
Error 1: Implicit Any Everywhere
// What we had:
function formatBalance(balance) {
return (balance / 1e18).toFixed(4);
}
// TypeScript:
Parameter 'balance' implicitly has an 'any' type. ts(7006)
Fix required: Add types to EVERYTHING.
Error 2: Missing Return Types
// What we had:
async function getTransaction(hash) {
const tx = await web3.eth.getTransaction(hash);
return tx;
}
// TypeScript:
Function lacks return type annotation. ts(7010)
Fix required: Declare what every function returns.
Error 3: Undefined Object Properties
// What we had:
const config = {};
config.network = 'mainnet'; // Property 'network' does not exist on type {}
// TypeScript:
This works in JavaScript, fails in TypeScript
Fix required: Define object shapes upfront.
Error 4: BigInt Incompatibility
// What we had:
const balance = BigInt('1000000000000000000');
const formatted = JSON.stringify({ balance });
// TypeScript:
Type 'bigint' is not assignable to type 'string | number | boolean | null'. ts(2322)
Fix required: Custom serialization for BigInt.
The Incremental Migration Strategy
Phase 1: Configuration Files First
# Start with simple config files (no complex logic)
mv config.js config.ts
# Add type definitions
cat > config.ts << 'EOF'
export interface NetworkConfig {
rpcUrl: string;
chainId: number;
explorer: string;
}
export const NETWORKS: Record<string, NetworkConfig> = {
mainnet: {
rpcUrl: 'https://mainnet.infura.io',
chainId: 1,
explorer: 'https://etherscan.io'
}
};
EOF
# Compile just this file
npx tsc config.ts --noEmit
# Fix errors in isolation
# Repeat until clean
Phase 2: Type Definitions Next
// Create types/index.ts BEFORE converting logic
export interface EthereumAddress {
value: string;
isValid: boolean;
}
export interface Balance {
wei: bigint;
ether: string;
formatted: string;
}
export interface Transaction {
hash: string;
from: string;
to: string;
value: bigint;
gasPrice: bigint;
gasLimit: bigint;
nonce: number;
data: string;
}
export interface Block {
number: number;
hash: string;
timestamp: number;
transactions: Transaction[];
miner: string;
}
// Export all types used across the server
export type * from './client.types';
export type * from './tool.types';
Phase 3: Utilities Then Core Logic
// Convert utility functions (pure logic, minimal dependencies)
// utils/validation.ts
export function isValidAddress(address: string): boolean {
return /^0x[a-fA-F0-9]{40}$/.test(address);
}
export function validateAddress(address: string): void {
if (!isValidAddress(address)) {
throw new Error(`Invalid Ethereum address: ${address}`);
}
}
// utils/formatting.ts
export function formatBalance(wei: bigint): string {
return `${Number(wei) / 1e18} ETH`;
}
export function formatTimestamp(timestamp: number): string {
return new Date(timestamp * 1000).toISOString();
}
Phase 4: Client Classes
// client.ts - The blockchain interface
import Web3 from 'web3';
import type { Transaction, Block } from './types/index.js';
export class EthereumClient {
private web3: Web3;
private network: string;
constructor(config: { network: string; rpcUrl?: string }) {
this.network = config.network;
this.web3 = new Web3(
config.rpcUrl || NETWORKS[config.network].rpcUrl
);
}
async getBalance(address: string): Promise<bigint> {
validateAddress(address);
const balance = await this.web3.eth.getBalance(address);
return BigInt(balance);
}
async getTransaction(hash: string): Promise<Transaction> {
const tx = await this.web3.eth.getTransaction(hash);
if (!tx) {
throw new Error(`Transaction not found: ${hash}`);
}
return {
hash: tx.hash,
from: tx.from,
to: tx.to || '',
value: BigInt(tx.value),
gasPrice: BigInt(tx.gasPrice),
gasLimit: BigInt(tx.gas),
nonce: tx.nonce,
data: tx.input
};
}
}
Phase 5: Tool Handlers
// tools/core/eth-get-balance.ts
import type { EthereumClient } from '../../client.js';
interface GetBalanceArgs {
address: string;
}
interface ToolResponse {
content: Array<{
type: 'text';
text: string;
}>;
}
export async function handleEthGetBalance(
args: GetBalanceArgs,
client: EthereumClient
): Promise<ToolResponse> {
if (!args.address) {
throw new Error('Address parameter is required');
}
const balance = await client.getBalance(args.address);
return {
content: [{
type: 'text',
text: JSON.stringify({
address: args.address,
balance: balance.toString(),
formatted: formatBalance(balance)
}, null, 2)
}]
};
}
The TypeScript Configuration Evolution
Version 1: Too Strict (Didn’t Compile)
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
// Result: 2,847 errors
Version 2: Too Loose (Defeated the Purpose)
{
"compilerOptions": {
"strict": false,
"noImplicitAny": false,
"skipLibCheck": true
}
}
// Result: Compiles, but no type safety
Version 3: The Goldilocks Config
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
// Module resolution
"moduleResolution": "node",
"resolveJsonModule": true,
"esModuleInterop": true,
// Type checking (gradually strictening)
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
// Allow JavaScript during migration
"allowJs": true,
"checkJs": false,
// Output
"declaration": true,
"declarationMap": true,
"sourceMap": true,
// Skip checking node_modules
"skipLibCheck": true,
// Allow importing .js files with .js extension
"allowImportingTsExtensions": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
The Build Pipeline
// package.json
{
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"type-check": "tsc --noEmit",
"lint": "eslint src/**/*.ts"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.3.0",
"tsx": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0"
}
}
The Type Definition Hunt
Problem: Missing Types for Blockchain Libraries
// web3.js has types... sort of
import Web3 from 'web3'; // ✅ Has @types/web3
// But some blockchain libs don't
import { SomeBlockchainSDK } from 'some-blockchain-sdk';
// Could not find a declaration file for module 'some-blockchain-sdk'. ts(7016)
Solution: Custom Type Declarations
// src/types/some-blockchain-sdk.d.ts
declare module 'some-blockchain-sdk' {
export class Client {
constructor(config: { network: string });
getBalance(address: string): Promise<string>;
sendTransaction(tx: any): Promise<string>;
}
export interface Transaction {
hash: string;
from: string;
to: string;
value: string;
}
}
The BigInt Type Saga
// BigInt is supported in TypeScript... kind of
const value: bigint = BigInt('1000'); // ✅ Type safe
// But JSON.stringify doesn't work
JSON.stringify({ value });
// TypeError: Do not know how to serialize a BigInt
// Solution: Custom serializer
function serializeBigInt(key: string, value: any): any {
if (typeof value === 'bigint') {
return value.toString();
}
return value;
}
JSON.stringify({ value }, serializeBigInt); // ✅ Works
The BigInt Type Helper
// utils/bigint.ts
export type SerializableBigInt = string;
export function toBigInt(value: string | number | bigint): bigint {
if (typeof value === 'bigint') return value;
return BigInt(value);
}
export function fromBigInt(value: bigint): SerializableBigInt {
return value.toString();
}
// Usage in types
export interface Balance {
wei: SerializableBigInt; // String in JSON, BigInt at runtime
ether: string;
}
The Migration Patterns
Pattern 1: Type Guards
// Instead of runtime type checking
function processValue(value: any) {
if (typeof value === 'string') {
return value.toUpperCase();
}
return String(value);
}
// Use type guards with proper types
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function processValue(value: string | number): string {
if (isString(value)) {
return value.toUpperCase();
}
return String(value);
}
Pattern 2: Generics for Reusable Functions
// Before: Any type
function wrapResponse(data: any) {
return {
content: [{
type: 'text',
text: JSON.stringify(data)
}]
};
}
// After: Generic type
function wrapResponse<T>(data: T): ToolResponse {
return {
content: [{
type: 'text',
text: JSON.stringify(data, serializeBigInt, 2)
}]
};
}
// Type-safe usage
const response = wrapResponse<Balance>({ wei: '1000', ether: '0.001' });
Pattern 3: Interface Extension
// Base tool response
interface BaseToolResponse {
content: Array<{
type: 'text';
text: string;
}>;
}
// Error response extends base
interface ErrorToolResponse extends BaseToolResponse {
isError: true;
code: string;
}
// Success response extends base
interface SuccessToolResponse extends BaseToolResponse {
isError?: false;
}
// Union type for all responses
type ToolResponse = SuccessToolResponse | ErrorToolResponse;
The Testing with TypeScript
// tests/core.test.ts
import { handleEthGetBalance } from '../src/tools/core/eth-get-balance';
import type { EthereumClient } from '../src/client';
describe('eth_get_balance', () => {
let mockClient: jest.Mocked<EthereumClient>;
beforeEach(() => {
mockClient = {
getBalance: jest.fn(),
getTransaction: jest.fn(),
// Type system ensures all methods are mocked!
} as jest.Mocked<EthereumClient>;
});
it('returns balance for valid address', async () => {
const mockBalance = BigInt('1000000000000000000');
mockClient.getBalance.mockResolvedValue(mockBalance);
const result = await handleEthGetBalance(
{ address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb3' },
mockClient
);
expect(result.content[0].text).toContain('1000000000000000000');
});
it('throws for missing address', async () => {
await expect(
handleEthGetBalance({} as any, mockClient)
).rejects.toThrow('Address parameter is required');
});
});
The Gradual Strictness Approach
# Week 1: Basic compilation
"strict": false
# Week 2: Implicit any
"noImplicitAny": true
# Week 3: Null checks
"strictNullChecks": true
# Week 4: Function types
"strictFunctionTypes": true
# Week 5: All strict options
"strict": true
The Errors That Saved Us
Caught Before Production #1:
// JavaScript version (runtime error)
function getBalance(address) {
return web3.eth.getBalance(address);
}
getBalance(); // undefined passed to web3
// Runtime: Cannot read property 'getBalance' of undefined
// TypeScript version (compile error)
function getBalance(address: string): Promise<bigint> {
return web3.eth.getBalance(address);
}
getBalance();
// Compile: Expected 1 arguments, but got 0. ts(2554)
Caught Before Production #2:
// JavaScript version (silent data loss)
const balance = await getBalance(address);
return { balance }; // BigInt in JSON
// Runtime: JSON.stringify fails silently
// TypeScript version (compile error)
const balance: bigint = await getBalance(address);
return { balance };
// Compile: Type 'bigint' is not assignable to type 'string'. ts(2322)
Caught Before Production #3:
// JavaScript version (wrong return type)
async function getTransaction(hash) {
const tx = await web3.eth.getTransaction(hash);
return tx.value; // Should return whole transaction!
}
// TypeScript version (compile error)
async function getTransaction(hash: string): Promise<Transaction> {
const tx = await web3.eth.getTransaction(hash);
return tx.value;
// Compile: Type 'string' is not assignable to type 'Transaction'. ts(2322)
}
The Tooling Wins
IDE Autocomplete
// Before TypeScript: Guess the API
client. // ??? No suggestions
// After TypeScript: Know the API
client.getBalance() // ✅ Suggested with signature
client.getTransaction() // ✅ Suggested with signature
client.getBlock() // ✅ Suggested with signature
Refactoring Confidence
// Rename function across 67,000 lines
// JavaScript: Find/replace, hope nothing breaks
// TypeScript: Rename symbol, compiler finds all usages
// Change function signature
// JavaScript: Search codebase, update manually
// TypeScript: Compiler shows all call sites to fix
Inline Documentation
/**
* Get ETH balance for an address
* @param address - Ethereum address (0x...)
* @returns Balance in wei
* @throws {Error} If address is invalid
*/
async function getBalance(address: string): Promise<bigint> {
// ...
}
// Hover over function in IDE: See full documentation
The Migration Metrics
Timeline: 6 weeks for 17 servers
Lines converted: 67,234
Errors found during migration: 2,847
- Type errors: 1,923
- Missing return types: 534
- Undefined properties: 245
- Other: 145
Bugs caught: 37 real bugs that would have hit production
Developer velocity:
- Week 1-2: Slower (learning curve)
- Week 3-4: Same as before
- Week 5+: 30% faster (autocomplete, refactoring tools)
The Checklist for Migration
- Install TypeScript and type definitions
- Create tsconfig.json (start loose)
- Convert config files first
- Create comprehensive type definitions
- Convert utilities (pure functions)
- Convert client classes
- Convert tool handlers
- Update tests
- Enable strict mode gradually
- Update CI/CD for TypeScript
- Train team on TypeScript
- Delete .js files
The Final Verdict
Worth it? Absolutely.
Regrets? Should have done it sooner.
Would we go back to JavaScript? Never.
References
- TypeScript config:
/tsconfig.json - Type definitions:
/src/types/ - Migration script:
/scripts/migrate-to-typescript.sh - Custom type declarations:
/src/types/*.d.ts
This is part of our ongoing series documenting architectural patterns and insights from building the Blockchain MCP Server Ecosystem. Type safety is not optional for production systems.
Related Reading
Prerequisites
- The 5,847-Line Nightmare: From Monolithic to Modular - Understand the chaotic codebase we were migrating.
Next Steps
- The Git Submodule Disaster - After migrating to TS, we tried to share code. It didn’t go well.
Deep Dives
- BigInt Testing Hell: Mocking Blockchain Numbers in Jest - A deep dive into one of the biggest pain points of this migration.
- ESM/CommonJS Migration Hell - Another major challenge in the JS/TS ecosystem we had to solve.