{"id":21,"date":"2026-03-27T08:33:16","date_gmt":"2026-03-27T08:33:16","guid":{"rendered":"https:\/\/siewares.com\/?p=21"},"modified":"2026-03-27T08:33:16","modified_gmt":"2026-03-27T08:33:16","slug":"six-iterations-to-a-type-safe-api-layer-in-typescript","status":"publish","type":"post","link":"https:\/\/siewares.com\/?p=21","title":{"rendered":"Six Iterations to a Type-Safe API Layer in TypeScript"},"content":{"rendered":"\n<p><em>We thought <\/em><code><em>implements<\/em><\/code><em> was enough. It wasn&#8217;t\u200a\u2014\u200ahere&#8217;s the journey from naive to production-ready.<\/em><\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"572\" src=\"https:\/\/siewares.com\/wp-content\/uploads\/2026\/03\/Gemini_Generated_Image_qt64seqt64seqt64-1024x572.png\" alt=\"\" class=\"wp-image-22\" srcset=\"https:\/\/siewares.com\/wp-content\/uploads\/2026\/03\/Gemini_Generated_Image_qt64seqt64seqt64-1024x572.png 1024w, https:\/\/siewares.com\/wp-content\/uploads\/2026\/03\/Gemini_Generated_Image_qt64seqt64seqt64-300x167.png 300w, https:\/\/siewares.com\/wp-content\/uploads\/2026\/03\/Gemini_Generated_Image_qt64seqt64seqt64-768x429.png 768w, https:\/\/siewares.com\/wp-content\/uploads\/2026\/03\/Gemini_Generated_Image_qt64seqt64seqt64.png 1376w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p>When your frontend talks to a backend API, you typically create a service layer that mirrors the backend\u2019s interface. This layer acts as a typed contract between your frontend code and the actual HTTP \/ RPC whatever calls\u200a\u2014\u200aand when done right, it catches mismatches at compile time instead of at runtime in production.<\/p>\n\n\n\n<p>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\u2019s what we learned.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading is-style-text-subtitle is-style-text-subtitle--1\">The Problem<\/h3>\n\n\n\n<p>Say your backend exposes this API:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>interface MyInterface {\n  get(_query: { id: number }): Promise&lt;number&gt;;\n  delete(_query: { id: number }): void;\n}<\/code><\/pre>\n\n\n\n<p>On the frontend, you want service classes that implement <em>some<\/em> of these methods. The challenge: TypeScript should enforce the contract without forcing you to implement methods you don\u2019t need\u200a\u2014\u200awhile also preventing you from accidentally drifting away from the actual API shape.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading is-style-text-subtitle is-style-text-subtitle--2\">Iteration 1: Plain `implements` \u2014 All or Nothing<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>class MyImpl implements MyInterface {\n  get(_query: { id: number }): Promise&lt;number> {\n    throw new Error('Method not implemented.');\n  }\n  delete(_query: { id: number }): void {\n    throw new Error('Method not implemented.');\n  }\n}<\/code><\/pre>\n\n\n\n<p><strong>The good:<\/strong>&nbsp;TypeScript fully enforces the contract. If the backend adds a method, compilation fails until you add it too.<\/p>\n\n\n\n<p><strong>The bad:<\/strong>&nbsp;You must implement&nbsp;<em>everything<\/em>, even if your use case only needs&nbsp;<code>get<\/code>. In a large API with dozens of methods, this becomes noise. Stub methods that throw at runtime are misleading and inflate your codebase.<\/p>\n\n\n\n<p>=> The obvious fix: skip methods we don&#8217;t need. Enter\u00a0<code>Omit<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading is-style-text-subtitle is-style-text-subtitle--3\">Iteration 2:\u00a0<code>Omit<\/code>\u00a0\u2014 Explicitly Excluding Methods<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>class MyImplOmit implements Omit&lt;MyInterface, 'delete'> {\n  get(_query: { id: number }): Promise&lt;number> {\n    throw new Error('Method not implemented.');\n  }\n}<\/code><\/pre>\n\n\n\n<p><strong>The good:<\/strong>&nbsp;You only implement what you need. Clean and minimal.<\/p>\n\n\n\n<p><strong>The bad:<\/strong>&nbsp;You&#8217;re maintaining a&nbsp;<em>negative<\/em>&nbsp;list. Every time the backend adds a method, you must add it to the&nbsp;<code>Omit<\/code>&nbsp;list \u2014 or TypeScript will force you to implement it. The exclusion list grows with the API. You&#8217;re fighting against the interface instead of working with it.<\/p>\n\n\n\n<p>=> Let&#8217;s flip the approach: list what we\u00a0<em>want<\/em>\u00a0instead of what we don&#8217;t.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading is-style-text-subtitle is-style-text-subtitle--4\">Iteration 3:\u00a0<code>Pick<\/code>\u00a0\u2014 Explicitly Including Methods<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>class MyImplPick implements Pick&lt;MyInterface, 'get'> {\n  get(_query: { id: number }): Promise&lt;number> {\n    throw new Error('Method not implemented.');\n  }\n\n  \/\/ !! Wrong signature \u2014 TypeScript won't catch this\n  delete(_query: { name: string }): void {\n    throw new Error('Method not implemented.');\n  }\n}<\/code><\/pre>\n\n\n\n<p><strong>The good:<\/strong>&nbsp;You declare exactly which methods you&#8217;re implementing. New backend methods don&#8217;t break anything.<\/p>\n\n\n\n<p><strong>The bad:<\/strong>&nbsp;You maintain a string literal union (<code>'get'<\/code>) that mirrors the method names you implement \u2014 duplication that can drift. And critically: since&nbsp;<code>delete<\/code>&nbsp;is not part of the&nbsp;<code>Pick<\/code>, you can add it with a&nbsp;<em>completely wrong signature<\/em>&nbsp;(<code>{ name: string }<\/code>&nbsp;instead of&nbsp;<code>{ id: number }<\/code>) and TypeScript won&#8217;t complain. The contract is only enforced for picked methods; everything else is unguarded.<\/p>\n\n\n\n<p>=> Ideally, TypeScript would infer which methods we&#8217;re implementing \u2014 without us maintaining a list at all.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading is-style-text-subtitle is-style-text-subtitle--5\">Iteration 4:\u00a0<code>Partial<\/code>\u00a0\u2014 Opt-In Methods With No Duplication<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>class MyImplPartial implements Partial&lt;MyInterface> {\n  get(_query: { id: number }): Promise&lt;number> {\n    throw new Error('Method not implemented.');\n  }\n\n  \/\/ !! Compile error \u2014 wrong signature is caught!\n  \/\/ delete(_query: { name: string }): void { ... }\n\n  \/\/ !! Allowed \u2014 extra method not in the interface\n  newMethodOutsideBackendApi(): void {\n    throw new Error('Method not implemented.');\n  }\n}<\/code><\/pre>\n\n\n\n<p><strong>The good:<\/strong>&nbsp;No explicit list needed. You implement what you want. Unlike&nbsp;<code>Pick<\/code>, if you&nbsp;<em>do<\/em>&nbsp;implement&nbsp;<code>delete<\/code>, TypeScript enforces the correct signature \u2014 a wrong parameter type like&nbsp;<code>{ name: string }<\/code>&nbsp;instead of&nbsp;<code>{ id: number }<\/code>&nbsp;will fail compilation. New backend methods don&#8217;t break the build either.<\/p>\n\n\n\n<p><strong>The bad:<\/strong>&nbsp;You can add arbitrary extra methods that have nothing to do with the backend API. Nothing stops a developer from adding&nbsp;<code>newMethodOutsideBackendApi()<\/code>&nbsp;that doesn&#8217;t exist on the backend. Your service layer quietly drifts away from the actual contract.<\/p>\n\n\n\n<p>=> We need the flexibility of\u00a0<code>Partial<\/code>\u00a0with one addition: locking the class down to only methods from the interface.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading is-style-text-subtitle is-style-text-subtitle--6\">Iteration 5:\u00a0<code>Exact<\/code>\u00a0\u2014 Preventing Extra Methods<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>type Exact&lt;T extends U, U> = T &amp; Record&lt;Exclude&lt;keyof T, keyof U>, never>;\n\nclass MyImplExact implements Exact&lt;MyImplExact, Partial&lt;MyInterface>> {\n  get(_query: { id: number }): Promise&lt;number> {\n    throw new Error('Method not implemented.');\n  }\n\n  \/\/ !! Additional parameter \u2014 TypeScript doesn't catch this\n  delete(_query: { id: number; name: string }): void {\n    throw new Error('Method not implemented.');\n  }\n\n  \/\/ Extra methods are blocked \u2014 good!\n  \/\/ newMethodOutsideBackendApi() { ... } \/\/ \u2190 compile error\n}<\/code><\/pre>\n\n\n\n<p><strong>The good:<\/strong>&nbsp;Extra methods are now forbidden. The&nbsp;<code>Exact<\/code>&nbsp;type maps any key that doesn&#8217;t exist in&nbsp;<code>Partial&lt;MyInterface&gt;<\/code>&nbsp;to&nbsp;<code>never<\/code>, causing a type error if you try to add one.<\/p>\n\n\n\n<p><strong>The bad:<\/strong>&nbsp;The parameter check is too loose.&nbsp;<code>delete<\/code>&nbsp;accepts&nbsp;<code>{ id: number; name: string }<\/code>&nbsp;\u2014 a superset of the backend&#8217;s&nbsp;<code>{ id: number }<\/code>. Why doesn&#8217;t TypeScript complain? Because of how function parameter compatibility works: a function accepting&nbsp;<code>{ id, name }<\/code>&nbsp;can safely substitute for one accepting just&nbsp;<code>{ id }<\/code>&nbsp;(the extra property is ignored). Structurally sound \u2014 but in practice, your implementation silently expects data the backend never sends.<\/p>\n\n\n\n<p>=> One gap remains: enforcing exact parameter matching.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading is-style-text-subtitle is-style-text-subtitle--7\">Iteration 6:\u00a0<code>ApiSubset<\/code>\u00a0\u2014 Full Signature Enforcement<\/h2>\n\n\n\n<pre class=\"wp-block-code\"><code>type ApiSubset&lt;Impl, Base> = {\n  &#91;K in keyof Impl]: K extends keyof Base\n    ? Impl&#91;K] extends (...args: infer ImplArgs) => any\n      ? Base&#91;K] extends (...args: infer BaseArgs) => infer BaseReturn\n        ? ImplArgs extends BaseArgs\n          ? BaseArgs extends ImplArgs\n            ? (...args: BaseArgs) => BaseReturn\n            : never \/\/ Parameter mismatch\n          : never \/\/ Parameter mismatch\n        : Impl&#91;K]\n      : Impl&#91;K]\n    : never; \/\/ Extra methods not allowed\n};\n\nclass MyImplApiSubset implements ApiSubset&lt;MyImplApiSubset, MyInterface> {\n  get(_query: { id: number }): Promise&lt;number> {\n    throw new Error('Method not implemented.');\n  }\n\n  delete(_query: { id: number }): void {\n    throw new Error('Method not implemented.');\n  }\n\n  \/\/ newMethodOutsideBackendApi() { ... } \/\/ \u2190 compile error\n}<\/code><\/pre>\n\n\n\n<p><strong>The good:<\/strong>&nbsp;This is the most complete solution. Breaking it down:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>[K in keyof Impl]<\/code>\u00a0\u2014 iterates over every key the class defines<\/li>\n\n\n\n<li><code>K extends keyof Base<\/code>\u00a0\u2014 if the key exists in the backend interface, enforce the signature; otherwise map it to\u00a0<code>never<\/code>\u00a0(blocking extra methods)<\/li>\n\n\n\n<li><code>ImplArgs extends BaseArgs<\/code>\u00a0<strong>and<\/strong>\u00a0<code>BaseArgs extends ImplArgs<\/code>\u00a0\u2014 bidirectional check ensures parameters are\u00a0<em>exactly<\/em>\u00a0the same, not just compatible in one direction<\/li>\n\n\n\n<li><code>(...args: BaseArgs) => BaseReturn<\/code>\u00a0\u2014 the return type is inferred from the base interface, keeping the contract authoritative<\/li>\n<\/ul>\n\n\n\n<p><strong>Remaining trade-off:<\/strong>&nbsp;The type is self-referential (<code>ApiSubset&lt;MyImplApiSubset, MyInterface&gt;<\/code>), 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.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">The Trade-Off Matrix<\/h2>\n\n\n\n<figure class=\"wp-block-image size-full\"><img loading=\"lazy\" decoding=\"async\" width=\"662\" height=\"239\" src=\"https:\/\/siewares.com\/wp-content\/uploads\/2026\/03\/image.png\" alt=\"\" class=\"wp-image-23\" srcset=\"https:\/\/siewares.com\/wp-content\/uploads\/2026\/03\/image.png 662w, https:\/\/siewares.com\/wp-content\/uploads\/2026\/03\/image-300x108.png 300w\" sizes=\"auto, (max-width: 662px) 100vw, 662px\" \/><\/figure>\n\n\n\n<p>Only\u00a0<code>ApiSubset<\/code>\u00a0checks all four boxes.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading is-style-text-subtitle is-style-text-subtitle--8\">Practical Recommendation<\/h2>\n\n\n\n<p>Use&nbsp;<strong><code>ApiSubset<\/code><\/strong>. It&#8217;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&#8217;re set.<\/p>\n\n\n\n<p>The self-referential generic (<code>ApiSubset&lt;MyImpl, MyInterface&gt;<\/code>) is a small price for a compile-time guarantee that your frontend service layer is always an exact subset of the backend contract.<\/p>\n\n\n\n<p>If your team prefers sticking to built-in utility types,&nbsp;<strong><code>Partial&lt;MyInterface&gt;<\/code><\/strong>&nbsp;is a solid fallback \u2014 just be aware that it won&#8217;t stop developers from adding methods that don&#8217;t exist on the backend.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>We thought implements was enough. It wasn&#8217;t\u200a\u2014\u200ahere&#8217;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\u2019s interface. This layer acts as a typed contract between your frontend code and the actual HTTP \/ RPC whatever calls\u200a\u2014\u200aand when done right, it [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-21","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/siewares.com\/index.php?rest_route=\/wp\/v2\/posts\/21","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/siewares.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/siewares.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/siewares.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/siewares.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=21"}],"version-history":[{"count":1,"href":"https:\/\/siewares.com\/index.php?rest_route=\/wp\/v2\/posts\/21\/revisions"}],"predecessor-version":[{"id":24,"href":"https:\/\/siewares.com\/index.php?rest_route=\/wp\/v2\/posts\/21\/revisions\/24"}],"wp:attachment":[{"href":"https:\/\/siewares.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=21"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/siewares.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=21"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/siewares.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=21"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}