Six Iterations to a Type-Safe API Layer in TypeScript

We thought implements was enough. It wasn’t — here’s the journey from naive to production-ready.

When your frontend talks to a backend API, you typically create a service layer that mirrors the backend’s interface. This layer acts as a typed contract between your frontend code and the actual HTTP / RPC whatever calls — and when done right, it catches mismatches at compile time instead of at runtime in production.

Getting this abstraction right is harder than it looks. We went through six iterations, each fixing a real problem introduced by the previous approach. Here’s what we learned.


The Problem

Say your backend exposes this API:

interface MyInterface {
  get(_query: { id: number }): Promise<number>;
  delete(_query: { id: number }): void;
}

On the frontend, you want service classes that implement some of these methods. The challenge: TypeScript should enforce the contract without forcing you to implement methods you don’t need — while also preventing you from accidentally drifting away from the actual API shape.


Iteration 1: Plain `implements` — All or Nothing

class MyImpl implements MyInterface {
  get(_query: { id: number }): Promise<number> {
    throw new Error('Method not implemented.');
  }
  delete(_query: { id: number }): void {
    throw new Error('Method not implemented.');
  }
}

The good: TypeScript fully enforces the contract. If the backend adds a method, compilation fails until you add it too.

The bad: You must implement everything, even if your use case only needs get. In a large API with dozens of methods, this becomes noise. Stub methods that throw at runtime are misleading and inflate your codebase.

=> The obvious fix: skip methods we don’t need. Enter Omit.


Iteration 2: Omit — Explicitly Excluding Methods

class MyImplOmit implements Omit<MyInterface, 'delete'> {
  get(_query: { id: number }): Promise<number> {
    throw new Error('Method not implemented.');
  }
}

The good: You only implement what you need. Clean and minimal.

The bad: You’re maintaining a negative list. Every time the backend adds a method, you must add it to the Omit list — or TypeScript will force you to implement it. The exclusion list grows with the API. You’re fighting against the interface instead of working with it.

=> Let’s flip the approach: list what we want instead of what we don’t.


Iteration 3: Pick — Explicitly Including Methods

class MyImplPick implements Pick<MyInterface, 'get'> {
  get(_query: { id: number }): Promise<number> {
    throw new Error('Method not implemented.');
  }

  // !! Wrong signature — TypeScript won't catch this
  delete(_query: { name: string }): void {
    throw new Error('Method not implemented.');
  }
}

The good: You declare exactly which methods you’re implementing. New backend methods don’t break anything.

The bad: You maintain a string literal union ('get') that mirrors the method names you implement — duplication that can drift. And critically: since delete is not part of the Pick, you can add it with a completely wrong signature ({ name: string } instead of { id: number }) and TypeScript won’t complain. The contract is only enforced for picked methods; everything else is unguarded.

=> Ideally, TypeScript would infer which methods we’re implementing — without us maintaining a list at all.


Iteration 4: Partial — Opt-In Methods With No Duplication

class MyImplPartial implements Partial<MyInterface> {
  get(_query: { id: number }): Promise<number> {
    throw new Error('Method not implemented.');
  }

  // !! Compile error — wrong signature is caught!
  // delete(_query: { name: string }): void { ... }

  // !! Allowed — extra method not in the interface
  newMethodOutsideBackendApi(): void {
    throw new Error('Method not implemented.');
  }
}

The good: No explicit list needed. You implement what you want. Unlike Pick, if you do implement delete, TypeScript enforces the correct signature — a wrong parameter type like { name: string } instead of { id: number } will fail compilation. New backend methods don’t break the build either.

The bad: You can add arbitrary extra methods that have nothing to do with the backend API. Nothing stops a developer from adding newMethodOutsideBackendApi() that doesn’t exist on the backend. Your service layer quietly drifts away from the actual contract.

=> We need the flexibility of Partial with one addition: locking the class down to only methods from the interface.


Iteration 5: Exact — Preventing Extra Methods

type Exact<T extends U, U> = T & Record<Exclude<keyof T, keyof U>, never>;

class MyImplExact implements Exact<MyImplExact, Partial<MyInterface>> {
  get(_query: { id: number }): Promise<number> {
    throw new Error('Method not implemented.');
  }

  // !! Additional parameter — TypeScript doesn't catch this
  delete(_query: { id: number; name: string }): void {
    throw new Error('Method not implemented.');
  }

  // Extra methods are blocked — good!
  // newMethodOutsideBackendApi() { ... } // ← compile error
}

The good: Extra methods are now forbidden. The Exact type maps any key that doesn’t exist in Partial<MyInterface> to never, causing a type error if you try to add one.

The bad: The parameter check is too loose. delete accepts { id: number; name: string } — a superset of the backend’s { id: number }. Why doesn’t TypeScript complain? Because of how function parameter compatibility works: a function accepting { id, name } can safely substitute for one accepting just { id } (the extra property is ignored). Structurally sound — but in practice, your implementation silently expects data the backend never sends.

=> One gap remains: enforcing exact parameter matching.


Iteration 6: ApiSubset — Full Signature Enforcement

type ApiSubset<Impl, Base> = {
  [K in keyof Impl]: K extends keyof Base
    ? Impl[K] extends (...args: infer ImplArgs) => any
      ? Base[K] extends (...args: infer BaseArgs) => infer BaseReturn
        ? ImplArgs extends BaseArgs
          ? BaseArgs extends ImplArgs
            ? (...args: BaseArgs) => BaseReturn
            : never // Parameter mismatch
          : never // Parameter mismatch
        : Impl[K]
      : Impl[K]
    : never; // Extra methods not allowed
};

class MyImplApiSubset implements ApiSubset<MyImplApiSubset, MyInterface> {
  get(_query: { id: number }): Promise<number> {
    throw new Error('Method not implemented.');
  }

  delete(_query: { id: number }): void {
    throw new Error('Method not implemented.');
  }

  // newMethodOutsideBackendApi() { ... } // ← compile error
}

The good: This is the most complete solution. Breaking it down:

  • [K in keyof Impl] — iterates over every key the class defines
  • K extends keyof Base — if the key exists in the backend interface, enforce the signature; otherwise map it to never (blocking extra methods)
  • ImplArgs extends BaseArgs and BaseArgs extends ImplArgs — bidirectional check ensures parameters are exactly the same, not just compatible in one direction
  • (...args: BaseArgs) => BaseReturn — the return type is inferred from the base interface, keeping the contract authoritative

Remaining trade-off: The type is self-referential (ApiSubset<MyImplApiSubset, MyInterface>), which takes a moment to get used to. And because it relies on conditional type inference over function parameters, some edge cases around overloads or complex generics may still slip through.


The Trade-Off Matrix

Only ApiSubset checks all four boxes.


Practical Recommendation

Use ApiSubset. It’s the only approach that lets you implement only what you need, blocks extra methods, enforces exact parameter signatures, and survives new API methods without breaking the build. Define the type once in a shared utility file and you’re set.

The self-referential generic (ApiSubset<MyImpl, MyInterface>) is a small price for a compile-time guarantee that your frontend service layer is always an exact subset of the backend contract.

If your team prefers sticking to built-in utility types, Partial<MyInterface> is a solid fallback — just be aware that it won’t stop developers from adding methods that don’t exist on the backend.

Comments

Leave a Reply

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