Category: Uncategorized

  • 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.

  • Prefer Duration Objects instead Numbers in Typescript


    While coding, developers frequently deal with time durations — whether it’s for timeouts, intervals, cache lifecycles, or animations. The common approach is to use plain numbers to represent these durations, often in milliseconds. While this works, it introduces ambiguity and potential bugs.

    The Problem with Plain Number

    Consider the default JavaScript setTimeout method:

    setTimeout(() => doSomething(), 5000); 

    Quite often plain number are used as a parameter. For correct usage you have to rely on documentation, comments, or debugging to understand the unit.

    Furthermore this could lead to variables with comments or constants having big magic numbers.

    const TIMEOUT = 1209600000; //two weeks in millis
    const TWO_WEEK_MILLIS = 1209600000;

     Those constants created multiple times whereever needed. Sometimes developer tries to unmagic those number with patterns containing out the convertion to the next unit .

    const TWO_WEEK_MILLIS = 14 * 24 * 60 * 60 * 1000;

    Real-World Example

    In this example you can see a echart configuration, with such a construct. Maybe you already recognized that maxValueSpan property is milliseconds while the minValueSpan is in seconds. Here you do not get any compile error nor this is obvious while reading.

    return {
          dataZoom: [
            {
              maxValueSpan: 3_600 * 24 * 1000 * 365 * 2, 
              minValueSpan: 3_600,
              disabled: true,
              moveOnMouseMove: 'shift',
              type: 'inside',
              zoomOnMouseWheel: 'ctrl',
              xAxisIndex: 0,
            },
          ],

    Having just these numbers without any semantic as parameters, leads to that we can paste any number into this parameter. 2 weeks or 2 hours, the method will never know.

    The Solution: Duration Objects

    Instead of primitive numbers we can use duration objects. Good old moment.js does have them (see example), date-fns have it and i am pretty sure there a lot of other libs providing such classes (or you can implement it by your own)

    export const weeks = (w: number) => moment.duration(w, 'weeks');
    export const hours = (h: number) => moment.duration(h, 'hours');
    export const seconds = (s: number) => moment.duration(s, 'seconds');
    ...

    Exporting this function multiple times for the different units makes it easy to create a duration everybody is knowing what he is dealing with (like weeks(2))

    Now, instead of passing raw numbers, you pass duration objects:

    const myTimeout = seconds(5);
    
    setTimeout(()=> {
      doSomething();
    }, myTimeout.asMilliseconds());


    This approach makes your code more readable and less error-prone. It also allows your functions to be agnostic of the time unit, converting it internally as needed.

    Even if a third-party library still consumes a number it becomes visible which target unit a developer wants to pass and you can convert it to the number as late as possible or create own wrapper consuming the duration object.

    import moment, { Duration } from 'moment';
    
    function setSafeTimeout(callback: () => void,timeout: Duration) {
      setTimeout(callback, timeout.asMilliseconds());
    }
    
    setSafeTimeout(seconds(5));
    
    
    
    
    

    Conclusion

    Using duration objects instead of raw numbers is a simple yet powerful way to make your TypeScript code more robust, readable, and maintainable. Whether you use a library like Moment.js or implement your own lightweight duration helpers, the benefits are clear.
    This approach can also be applied to other units, but durations are the most common inside code from my perspective.