Prompt: TypeScript Migration
April 6, 2026 · View on GitHub
Why This Matters
TypeScript isn't optional for a professional open-source project in 2026. It's the difference between "I'll try this" and "I trust this." Developers won't adopt a library without types — no autocomplete, no confidence.
Current State
All JavaScript, no types, no JSDoc. ~15 source files.
Migration Strategy
Incremental migration — don't rewrite everything at once. Use allowJs: true so JS and TS coexist during migration.
Step 1: Project Setup
npm install -D typescript @types/node @types/express tsx
npx tsc --init
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"allowJs": true,
"checkJs": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "examples"]
}
Step 2: Define Core Types First
Create src/types.ts with all the interfaces before migrating any code. This forces you to think about the public API:
// === Agent Configuration ===
export interface AgentConfig {
auth: AuthConfig
ai: AIConfig
voice?: VoiceConfig
browser?: BrowserConfig
behavior?: BehaviorConfig
}
export interface AuthConfig {
/** X auth_token cookie value */
token?: string
/** X ct0 CSRF token */
ct0?: string
/** Username for form-based login */
username?: string
/** Password for form-based login */
password?: string
/** Email for verification step */
email?: string
/** Path to persistent cookie file */
cookiePath?: string
}
export interface AIConfig {
provider: 'openai' | 'openai-realtime' | 'claude' | 'groq' | 'custom'
model?: string
apiKey?: string
systemPrompt: string
maxTokens?: number
temperature?: number
maxHistory?: number
custom?: CustomProvider
}
export interface VoiceConfig {
provider: 'elevenlabs' | 'openai' | 'browser'
apiKey?: string
voiceId?: string
speed?: number
stability?: number
}
export interface BrowserConfig {
headless?: boolean
executablePath?: string
userDataDir?: string
proxy?: string
args?: string[]
}
export interface BehaviorConfig {
autoRespond?: boolean
silenceThreshold?: number
minSpeechDuration?: number
maxResponseLength?: number
turnDelay?: number
}
// === Agent Status ===
export type AgentStatus =
| 'disconnected'
| 'launching'
| 'logged-in'
| 'joining-space'
| 'listening'
| 'speaking'
| 'error'
// === Messages ===
export interface Message {
role: 'system' | 'user' | 'assistant'
content: string
timestamp?: Date
speaker?: string
agentId?: number
}
// === Events ===
export interface AgentEvents {
transcription: { speaker: string; text: string; timestamp: Date }
response: { text: string; audio?: Buffer; agentId: number }
status: AgentStatus
error: Error
'speaker-joined': { username: string }
'speaker-left': { username: string }
'space-ended': void
'audio-chunk': Float32Array
'turn-granted': { agentId: number }
'turn-released': { agentId: number }
}
// === Provider Interface ===
export interface LLMProvider {
readonly type: 'socket' | 'webrtc'
readonly name: string
generateResponse(params: {
messages: Message[]
systemPrompt: string
maxTokens?: number
temperature?: number
}): Promise<string>
generateResponseStream?(params: {
messages: Message[]
systemPrompt: string
}): AsyncIterable<string>
}
export interface STTProvider {
transcribe(audio: Buffer, format?: string): Promise<string>
}
export interface TTSProvider {
synthesize(text: string, voiceId?: string): Promise<Buffer>
}
// === Custom Provider (user-facing) ===
export interface CustomProvider {
type: 'socket'
generateResponse(params: {
messages: Message[]
systemPrompt: string
}): Promise<string>
generateResponseStream?(params: {
messages: Message[]
systemPrompt: string
}): AsyncIterable<string>
}
// === Middleware ===
export type MiddlewareStage =
| 'before:stt'
| 'after:stt'
| 'before:llm'
| 'after:llm'
| 'before:tts'
| 'after:tts'
export type MiddlewareFn<T = unknown> = (input: T) => T | null | Promise<T | null>
// === Multi-Agent ===
export interface AgentTeamConfig {
auth: AuthConfig
agents: Array<{
name: string
ai: AIConfig
voice?: VoiceConfig
behavior?: BehaviorConfig
}>
turnManagement?: {
strategy: 'queue' | 'round-robin' | 'director'
turnDelay?: number
}
}
Step 3: Migration Order
Migrate bottom-up — start with modules that have no internal dependencies:
Wave 1: Leaf modules (no internal imports)
x-spaces/selectors.js→src/browser/selectors.tsproviders/stt.js→src/pipeline/stt.tsproviders/tts.js→src/pipeline/tts.ts
Wave 2: Provider implementations
providers/openai-chat.js→src/providers/openai.tsproviders/claude.js→src/providers/claude.tsproviders/groq.js→src/providers/groq.tsproviders/openai-realtime.js→src/providers/openai-realtime.tsproviders/index.js→src/providers/index.ts
Wave 3: Browser automation
x-spaces/browser.js→src/browser/launcher.tsx-spaces/auth.js→src/browser/auth.tsx-spaces/space-ui.js→src/browser/space-ui.tsx-spaces/audio-bridge.js→src/audio/bridge.ts
Wave 4: Core orchestration
x-spaces/index.js→src/browser/orchestrator.ts- Server state/pipeline →
src/agent.ts(the mainXSpaceAgentclass)
Wave 5: Server (last — it's the consumer)
server.js→src/server/index.ts
Step 4: Build & Development Setup
package.json scripts
{
"scripts": {
"build": "tsc",
"dev": "tsx watch src/server/index.ts",
"start": "node dist/server/index.js",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"prepublishOnly": "npm run build"
}
}
Development with tsx
Use tsx for development (runs TypeScript directly, no build step needed):
npx tsx src/server/index.ts
Step 5: Type-Safe Event Emitter
Replace Node's generic EventEmitter with a typed version:
import { EventEmitter } from 'events'
// Typed event emitter wrapper
export class TypedEmitter<Events extends Record<string, unknown>> {
private emitter = new EventEmitter()
on<K extends keyof Events>(event: K, listener: (data: Events[K]) => void): this {
this.emitter.on(event as string, listener)
return this
}
emit<K extends keyof Events>(event: K, data: Events[K]): boolean {
return this.emitter.emit(event as string, data)
}
off<K extends keyof Events>(event: K, listener: (data: Events[K]) => void): this {
this.emitter.off(event as string, listener)
return this
}
}
Step 6: Strict Null Checks
After migrating, enable strictNullChecks. Common patterns to fix:
// Before: crashes at runtime if env var missing
const apiKey = process.env.OPENAI_API_KEY
// After: explicit validation
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('OPENAI_API_KEY is required')
New Dependencies
{
"devDependencies": {
"typescript": "^5.4.0",
"@types/node": "^20.0.0",
"@types/express": "^4.17.0",
"tsx": "^4.7.0",
"eslint": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0"
}
}
Validation
-
npm run buildcompiles with zero errors -
npm run typecheckpasses - Generated
.d.tsfiles provide full autocomplete - All provider implementations satisfy
LLMProviderinterface - No
anytypes in public API (internalanyis OK during migration) - Import autocomplete works when consuming the package
-
tsxdev mode works for development