TypeScript interfaces: Stop writing basic user objects

Well, I was staring at my VS Code editor at 2 AM last Tuesday, watching the TypeScript language server completely choke. The memory usage was sitting at 1.4GB. Autocomplete took three seconds to pop up. It was crawling.

Actually, I should clarify — I was trying to build a double-buffered rendering engine for a terminal UI project. Because I thought I was being clever, I had built the entire state tree using deeply nested type aliases and complex intersections. That was a mistake. A big one.

And everyone teaches TypeScript interfaces using the same tired interface Dog extends Animal examples. That stuff is useless in the real world. But let me back up — let’s talk about what happens when you actually need to build something heavy, and why interfaces are the only thing that will save your compiler from melting down.

The compiler caching gotcha

Here’s a weird quirk about the TypeScript compiler that nobody warns you about. I had to learn this the hard way.

Interfaces are cached by name. Type aliases—especially when you start intersecting them with &—are evaluated structurally. Every. Single. Time.

TypeScript programming - TypeScript Programming Example - GeeksforGeeks
TypeScript programming – TypeScript Programming Example – GeeksforGeeks

And when I finally ripped out my massive type intersections and replaced them with standard interface declarations on TS 5.8, my local build time dropped from 42 seconds to just under 14 seconds. The language server stopped freezing. If you are building complex objects, probably you should stop using type. Just use an interface.

Typing the DOM (and custom buffers)

Let’s look at how this actually applies to UI rendering. If you’re manipulating the DOM directly or building a custom paint buffer, you need strict contracts.

I needed a way to define a rendering surface. Sometimes it’s a literal HTML canvas, sometimes it’s just a string buffer in memory.

interface RenderSurface {
    width: number;
    height: number;
    clear(): void;
    draw(x: number, y: number, char: string): boolean;
}

// Extending it for the actual browser DOM
interface DOMRenderer extends RenderSurface {
    container: HTMLElement;
    attach(elementId: string): void;
    forceRepaint(): void;
}

Notice how clean that inheritance is. When you extend an interface, TypeScript statically knows exactly what the new shape looks like. It doesn’t have to compute a complex Venn diagram of properties like it does with type intersections.

Implementing this in the browser usually looks something like this:

class WebTerminal implements DOMRenderer {
    width = 80;
    height = 24;
    container: HTMLElement;

    constructor() {
        // The dreaded 'Cannot read property of null' happens if we aren't careful here
        this.container = document.createElement('div');
    }

    attach(elementId: string) {
        const el = document.getElementById(elementId);
        if (!el) throw new Error(Missing DOM element: ${elementId});
        el.appendChild(this.container);
    }

    clear() {
        this.container.innerHTML = '';
    }

    draw(x: number, y: number, char: string) {
        // Implementation details omitted because DOM math is boring
        return true;
    }

    forceRepaint() {
        // Force a browser reflow
        void this.container.offsetHeight; 
    }
}

Taming chaotic external APIs

TypeScript programming - Programming language TypeScript: advantages, and disadvantages
TypeScript programming – Programming language TypeScript: advantages, and disadvantages

You can’t just render static text forever. Eventually, your UI has to talk to the outside world. This is where interfaces actually pay the bills.

But last month, I was pulling live server metrics into my UI. The backend team changes their JSON structure constantly. If you don’t interface your API responses, you are asking for runtime explosions.

interface ServerMetrics {
    cpuLoad: number;
    activeConnections: number;
    // The API returns an ISO string, but we want a Date object in our app
    lastPing: string; 
}

// We can define the async function signature itself as an interface
interface MetricsFetcher {
    (endpoint: string, timeoutMs?: number): Promise<ServerMetrics>;
}

Yes, you can define functions as interfaces. People forget this syntax exists. It’s incredibly handy when you want to pass around different fetching strategies (like a mock fetcher for testing vs the real one).

Here is how I actually implement that async contract:

const fetchLiveMetrics: MetricsFetcher = async (endpoint, timeoutMs = 5000) => {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

    try {
        const response = await fetch(endpoint, { signal: controller.signal });
        
        if (!response.ok) {
            throw new Error(HTTP error! status: ${response.status});
        }
        
        // We cast here, but ideally you'd use Zod to validate at runtime
        const data = await response.json() as ServerMetrics;
        return data;
        
    } catch (error) {
        if (error instanceof Error && error.name === 'AbortError') {
            console.warn('Metrics fetch timed out');
        }
        throw error;
    } finally {
        clearTimeout(timeoutId);
    }
};

I kept getting ECONNREFUSED on port 8080 during local testing because my mock server was down. The interface didn’t fix my broken server, obviously. But because I strictly typed the Promise<ServerMetrics> return value, my rendering loop knew exactly how to handle the fallback state when the catch block threw.

Functions with properties

I’ll leave you with one weird edge case that interfaces handle beautifully.

Sometimes you have a function that also acts as an object with properties. Think of things like React components (which have propTypes attached) or event emitters. You literally cannot type this cleanly without an interface.

interface PriorityLogger {
    // The callable signature
    (message: string): void;
    
    // The attached properties
    level: 'info' | 'warn' | 'error';
    history: string[];
}

const log: PriorityLogger = (message: string) => {
    const formatted = [${log.level.toUpperCase()}] ${message};
    console.log(formatted);
    log.history.push(formatted);
};

// Initialize the properties
log.level = 'info';
log.history = [];

log("UI initialized");
log("Buffer attached");
console.log(Logged ${log.history.length} events);

Stop overcomplicating your TypeScript architecture. If it’s an object, a class contract, or a complex function, default to interface. Save type for unions and mapped types. Your compiler—and your laptop’s cooling fans—will thank you.

typescriptworld_com

Learn More →

Leave a Reply

Your email address will not be published. Required fields are marked *