1

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.

4
  • 1
    Your own solution at the end doesn't seem clunky to me at all. It's really clever and concise. (One refinement might be to optionally pass previous pieces into it and check for key overlap.) I think you'll struggle to find something better. I'm totally stealing that trick, I didn't realize TypeScript could infer K like that. :-) Commented Apr 25, 2022 at 6:35
  • @T.J.Crowder Thanks, I guess. :) Well, yeah, maybe it's not so bad, I just got a bit frustrated that there wasn't (or I couldn't find) a strictly type-based way to have a literal conform to a type (fulfills an extends clause) while not explicitly typing the literal, but having it inferred automatically. But perhaps I'll refine my solution a bit and be happy with it. Commented Apr 25, 2022 at 8:54
  • 1
    Yeah, there are a few edges in TypeScript where you have to run things through do-nothing functions in order to get the inference, as you have above. I'd think that would be worth addressing, but I mentioned it to a big TypeScript name (I forget which) and they said (paraphrasing) "Meh, we already have functions." :-) Commented Apr 25, 2022 at 9:18
  • 1
    @T.J.Crowder I decided to build a simple library from my own solution: npmjs.com/package/type-constraint If you liked the idea, feedback on the library is always nice. :) Commented Apr 25, 2022 at 9:32

2 Answers 2

1

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}; 
Sign up to request clarification or add additional context in comments.

2 Comments

Also a good approach. (Although the OP, specifically, may not be able to do it this way, because they said "In reality, the Api interface has been generated by a code generator" -- but others finding this later may be able to.)
Right. The complete interface comes as one big type so this is not ideal for me even though I could of course as a middle step define Api1 and Api2 using Pick, but it also doesn't infer the sub parts automatically.
1

After getting some feedback on my own solution in the question, I decided to extract a simple helper library from it. And perhaps it's not too bad after all: https://www.npmjs.com/package/type-constraint

import Constraint from 'type-constraint'; const api = Constraint.of<Api>(); const subImpl1 = api.pick({ greet: () => 'Hello, world!', cat: () => 'Meow!', }); const subImpl2 = api.pick({ ultimateQuestion: () => 42 }); const impl: Api = {...subImpl1, ...subImpl2}; 

I'm still curious about other approaches to this though. :)

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.