The TypeScript inheritance trap
Inheriting types from a library's own type hierarchy quietly couples your code to its internals — not just its runtime behavior. A smaller, hand-written type is usually the better trade.
TypeScript developers reach for a familiar move: inherit types from whatever library you’re using, instead of writing your own. It reads as free correctness. Most of the time it’s a trap.
The libraries most worth using — cloud SDKs especially — tend to have deep, layered type hierarchies. Take the AWS CDK:
export interface ResourceOptions
export interface RestApiBaseProps
export interface RestApiOptions extends RestApiBaseProps, ResourceOptions
export interface RestApiProps extends RestApiOptions
export class LambdaRestApi extends RestApi
Follow that chain far enough and you’re several extends deep into generics that were generated, not authored — hard to read, harder to debug when something doesn’t line up. A colleague once put it well: using types this way doesn’t just couple you to the library. It couples you to the library’s type system, which is a much bigger surface to depend on than the handful of fields you actually touch.
Types are supposed to buy you clarity. Used this way, they cost more than they return. It’s worth sticking to a simpler rule: keep interfaces small, and only require what you actually use.
Because TypeScript does structural typing, this is easy to act on. Define your own narrow type with just the fields your code needs, and pass values of that type into the library’s functions — no Picking from the library’s type required, since structural matching handles it for you:
import { Construct } from 'constructs';
import { LambdaRestApi } from 'aws-cdk-lib/aws-apigateway';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
interface ApiOptions {
readonly entry: string; // handler source
readonly stageName: string; // where it deploys
}
function createApi(scope: Construct, id: string, props: ApiOptions) {
const handler = new NodejsFunction(scope, 'Handler', { entry: props.entry });
// RestApiProps is five `extends` deep; this line passes just the part we use:
return new LambdaRestApi(scope, id, {
handler,
deployOptions: { stageName: props.stageName },
});
}
Callers see two fields, not a five-layer hierarchy — and the library’s type appears in exactly one line, at the call site, which is where it belongs.
Two things fall out of this. First, your own code stays decoupled from a library you don’t control — you’re reading and reviewing your own three-field interface, not someone else’s five-layer hierarchy. Second, and more usefully: if the library changes shape in a way that’s actually incompatible with how you’re using it, your build fails at the boundary where you constructed the call — not somewhere three abstractions downstream.