Home > OS >  Implementing a typescript interface in parts while keeping full type safety against the interface
Implementing a typescript interface in parts while keeping full type safety against the interface

Time:04-22

Let's say I have an API interface consisting of a number of functions, like this:

type Handler<T> = (params: any) => T;

interface Api {
  greet: Handler<string>;
  cat: Handler<string>
  ultimateQuestion: Handler<number>;
}

(In reality, the Api interface has been generated by a code generator, so ignore the silly example. But Api will be something that extends from Record<string, Handler<any>>.)

Now, to implement this, one could define an object literal like this:

const implementation: Api = {
  greet: () => 'Hello, world!',
  cat: () => 'Meow!',
  ultimateQuestion: () => 42
}

This is nice, because the compiler will complain if a method is missing, if a method's return value is incorrect, or if a method that doesn't exist in the API is present.

However, for a large API, it might be beneficial to break the implementation down into smaller pieces, each sub-object implementing a part of the API, while still enjoying type safety of the implementation.

So, just to illustrate, this compiles:

const subImpl1 = {
  greet: () => 'Hello, world!',
  cat: () => 'Meow!',
}

const subImpl2 = {
  ultimateQuestion: () => 42
}

const impl: Api = {...subImpl1, ...subImpl2};

And if a method is missing or mismatching etc, I will still get a compiler error on the last line, since impl must match interface Api.

However, I want each sub-part to also benefit from type-checking against the Api interface, and that's not the case since each sub-impl is an object literal not bound by any type contract. :(

At first I try simply typing each sub-part using Partial:

const subImpl1: Partial<Api> = {
  greet: () => 'Hello, world!',
  cat: () => 'Meow!',
}

const subImpl2: Partial<Api> = {
  ultimateQuestion: () => 42
}

const impl: Api = {...subImpl1, ...subImpl2};

This works quite well for subImpl1 and subImpl2 which is checked against the API, but the last line will not compile since the combined subImpl1 and subImpl2 does not form Api due to each part being explicitly typed as Partial, and which methods are part of each sub part is no longer inferred.

Is there a nice way to do this so that each sub implementation can be typed to be a part of the API, and will have errors reported on that part of the API only, and the complete API combines all sub-parts to check against the full Api type?

The only way I've come up with is to wrap the declarations of the literals with a function, something like this:

const defineImpl = <K extends keyof Api> (impl: Pick<Api, K>) => impl;

Which allows me to do:

const subImpl1 = defineImpl({
  greet: () => 'Hello, world!',
  cat: () => 'Meow!',
});

const subImpl2 = defineImpl({
  ultimateQuestion: () => 42
});

const impl: Api = {...subImpl1, ...subImpl2};

This way, each sub implementation is still type-checked against Api so that only existing methods may be defined, and the return type of each method is also checked. Also, the compiler will complain on the last line if the combination of all sub implementations does not form the complete Api interface.

So this fulfills my requirements, but still feels a bit clunky. Is there a way to do this only with types? Since it relies on input to conform to a part of a given interface but still must infer the exact part which was defined, I couldn't come up with any cleaner way myself.

CodePudding user response:

Maybe go the opposite route: instead of making small interfaces as parts of one big interface, create the big one from several specialized ones?

type Handler<T> = (params: any) => T;

interface Api1 {
  greet: Handler<string>;
  cat: Handler<string>
}

interface Api2 {
  ultimateQuestion: Handler<number>;
}

interface Api extends Api1, Api2 {};

const subImpl1: Api1 = {
  greet: () => 'Hello, world!',
  cat: () => 'Meow!',
}

const subImpl2: Api2 = {
  ultimateQuestion: () => 42
}

const impl: Api = {...subImpl1, ...subImpl2};

CodePudding user response:

You can use Pick for that:

type Handler<T> = (params: any) => T;

interface Api {
    greet: Handler<string>;
    cat: Handler<string>
    ultimateQuestion: Handler<number>;
}

const subImpl1: Pick<Api, "greet" | "cat"> = {
    greet: () => 'Hello, world!',
    cat: () => 'Meow!',
};

const subImpl2: Pick<Api, "ultimateQuestion"> = {
    ultimateQuestion: () => 42
};

const impl: Api = {...subImpl1, ...subImpl2};

// This gives an error as desired:
const subImpl1B: Pick<Api, "greet" | "cat"> = {
    greet: () => 'Hello, world!',
};

The Pick grabs the parts of Api that you're going to implement in that object, and typechecks the object against that subset of the Api interface.

Playground link

  • Related