Applying extends, keyof, and defaults in practice
An unconstrained generic type parameter accepts almost anything and thereby blocks any meaningful access to properties or methods. With the extends keyword, keyof, and well-designed default values, type parameters can be deliberately narrowed so the compiler catches real errors and developers get reliable autocomplete while writing code. This article uses a typed pick-fields function to show how these concepts work together.
Table of Contents
- 1. Why unconstrained generics are too permissive
- 2. The extends keyword: syntax and effect
- 3. Constraining to object shapes: object and Record
- 4. Constraining with keyof: safe property access
- 5. Default generic parameters: <T = DefaultType>
- 6. Practical example part 1: the pick-fields function signature
- 7. Practical example part 2: implementation and usage with autocomplete
- 8. Multiple constraints and constraint composition
- 9. Generic constraints compared side by side
- 10. Summary
- 11. FAQ
1. Why unconstrained generics are too permissive
A generic type parameter like <T> without any constraint accepts practically anything: strings, numbers, arrays, class instances, even undefined. That openness makes T nearly useless inside a function body, because the compiler doesn't know a single property or method that's guaranteed to exist on every possible type. A call like value.length or value.toUpperCase() fails, because TypeScript correctly refuses to assume that some arbitrary T has that property. Working around this with any or unknown either throws away type safety entirely or forces manual checks and casts at every access.
In practice, this problem shows up most often in generic API clients, form helper functions, or state management utilities: a function needs to work across many different data types, yet still needs access to specific fields. This is exactly where generic constraints come in. With the extends keyword, constraints on object shapes, and keyof, a type parameter can be narrowed just enough to stay flexible while still guaranteeing the exact properties a function actually needs.
2. The extends keyword: syntax and effect
The syntax <T extends SomeShape> limits the set of allowed types for T to every type that is structurally compatible with SomeShape. TypeScript's structural typing matters here: SomeShape doesn't need to be a class or an explicitly implemented interface, it's enough that the supplied type has matching properties. extends works differently than class inheritance here, it describes a superset and subset of types, not a runtime object hierarchy.
In the example below, the HasLength constraint guarantees that value has a length property of type number inside the function. Strings, arrays, and custom objects with a matching shape are accepted, while a plain number is rejected by the compiler before the code even runs.
// Without constraint: T could be anything, .length is unavailable
function logLength<T>(value: T): void {
console.log(value.length); // Error: Property 'length' does not exist on type 'T'
}
// With extends: T is now guaranteed to have a length property
interface HasLength {
length: number;
}
function logLengthSafe<T extends HasLength>(value: T): void {
console.log(value.length); // OK, TypeScript knows T has .length
}
logLengthSafe("hello"); // OK, string has .length
logLengthSafe([1, 2, 3]); // OK, array has .length
logLengthSafe({ length: 10 }); // OK, matches the shape
// logLengthSafe(42); // Error: number has no .length
Constraints aren't limited to object shapes. <T extends string | number>, for example, allows only primitive scalar values, while <T extends Function> allows only callable values. The rule stays the same: inside the function, the compiler only lets you use the properties and operations guaranteed by the constraint, regardless of how concrete the actual supplied type is.
3. Constraining to object shapes: object and Record
<T extends object> excludes primitive types like string, number, and boolean and only allows objects, arrays, functions, and class instances. This is useful when a function generally needs to work with referenceable structures but doesn't yet make any claim about specific properties. The downside: object alone guarantees no access to particular fields, the compiler only knows that it's some kind of object.
<T extends Record<string, unknown>> goes a step further and describes an object with arbitrary string keys and values of type unknown. This works well for generic functions that iterate over or merge objects without knowing the concrete field names. Important detail: arrays don't reliably satisfy Record<string, unknown> in every situation, so with mixed input it's worth adding an Array.isArray check or using a more precise constraint like a concrete interface.
4. Constraining with keyof: safe property access
keyof T produces a union of every property name of T as string or symbol literals. The combination <T, K extends keyof T> ties two type parameters together: K may only be an actual key of T. This connection is the core of many type-safe utility functions, because it lets the compiler reject invalid field names at compile time instead of returning an undefined value at runtime.
In the example below, getProperty doesn't just return some value, it returns exactly the type T[K], the indexed access type for the supplied key. Calling getProperty(product, 'price') tells TypeScript, while you're writing the code, that the result is of type number, and a typo in the key name is flagged immediately as an error.
// K extends keyof T restricts K to the actual property names of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
interface Product {
sku: string;
price: number;
inStock: boolean;
}
const product: Product = { sku: "MS-1001", price: 49.9, inStock: true };
const sku = getProperty(product, "sku"); // type: string
const price = getProperty(product, "price"); // type: number
// getProperty(product, "weight"); // Error: 'weight' is not in keyof Product
5. Default generic parameters: <T = DefaultType>
Just like function parameters, type parameters can also get a default value: <T = DefaultType> uses DefaultType whenever the caller doesn't supply an explicit type and the compiler can't infer one from the arguments either. This significantly reduces boilerplate at call sites, especially for functions or interfaces with multiple type parameters where most calls really only need one of them.
Default values can be combined with constraints, using the syntax <T extends Constraint = DefaultType>, where DefaultType itself must satisfy the constraint. One important rule: type parameters with a default value must appear after type parameters without a default in the parameter list, just like optional function parameters. In the example below, TOptions automatically falls back to Record<string, unknown> when no special options are needed, but can be set explicitly to a more precise type whenever required.
// Default generic parameter: TOptions defaults to Record<string, unknown>
interface FetchConfig<TResponse = unknown, TOptions extends object = Record<string, unknown>> {
url: string;
parse: (raw: unknown) => TResponse;
options?: TOptions;
}
function createRequest<TResponse, TOptions extends object = Record<string, unknown>>(
config: FetchConfig<TResponse, TOptions>
): FetchConfig<TResponse, TOptions> {
return config;
}
// TOptions falls back to Record<string, unknown> here
const simpleRequest = createRequest({
url: "/api/products",
parse: (raw: unknown) => raw as Product[],
});
// TOptions is set explicitly here
const advancedRequest = createRequest<Product[], { retries: number }>({
url: "/api/products",
parse: (raw: unknown) => raw as Product[],
options: { retries: 3 },
});
6. Practical example part 1: the pick-fields function signature
A type-safe pick-fields function should extract a subset of fields from an object, similar to the pick function from Lodash, but fully checked by the compiler instead of only correct at runtime. Its signature combines exactly the tools discussed so far: <T extends object, K extends keyof T> ensures that T is an object and that K can only be an actual key of T.
The keys parameter is typed as readonly K[] instead of K[], because callers usually pass a literal array like ['sku', 'price'] that shouldn't be mutated. The return type Pick<T, K> is a built-in TypeScript utility type that filters exactly the properties listed in K out of T. This lets the compiler know precisely, before the implementation even exists, what shape the result of every single call will have.
7. Practical example part 2: implementation and usage with autocomplete
The implementation itself iterates over keys and copies each field individually into a new object. Inside the function, a single cast to Pick<T, K> is necessary, because the compiler can't track the step-by-step population of an initially empty object line by line. This is a deliberate, tightly scoped compromise: the internal implementation gives up full type checking at one point, while the external signature stays one hundred percent type safe.
// Constrained generic signature for a typed "pick fields" utility
function pickFields<T extends object, K extends keyof T>(
source: T,
keys: readonly K[]
): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
result[key] = source[key];
}
return result;
}
const product: Product = { sku: "MS-1001", price: 49.9, inStock: true };
// Editor autocomplete only suggests "sku" | "price" | "inStock" here
const summary = pickFields(product, ["sku", "price"]);
// summary has type: { sku: string; price: number }
// pickFields(product, ["sku", "weight"]);
// Error: "weight" is not assignable to keyof Product
At the call site, the real benefit of the constraints becomes visible: as you type the keys array, the editor only suggests valid field names of Product, a typo like "weigth" is underlined immediately, and the return type of pickFields contains exactly the selected fields, no more and no less. In headless and API projects, this pattern reliably prevents bugs whenever only part of a large product or customer object needs to be passed to a component or an endpoint.
8. Multiple constraints and constraint composition
Several structural requirements can be combined into a single constraint with the intersection operator &: <T extends Identifiable & Timestamped> requires T to have both an id property and a createdAt field. This is especially useful for generic functions that need to work with several independently defined interfaces without one extending the other.
// Combining extends with intersection types
interface Timestamped {
createdAt: Date;
}
interface Identifiable {
id: string;
}
// T must satisfy both Identifiable and Timestamped
function touch<T extends Identifiable & Timestamped>(entity: T): T {
return { ...entity, createdAt: new Date() };
}
// A constraint that references another type parameter
function assignDefaults<T extends object, D extends Partial<T>>(
target: T,
defaults: D
): T {
return { ...defaults, ...target };
}
interface Settings {
theme: string;
language: string;
}
const settings = assignDefaults<Settings, Partial<Settings>>(
{ theme: "dark", language: "de" },
{ language: "en" }
);
A more advanced case is a constraint that references an already declared type parameter, such as <T extends object, D extends Partial<T>>. Here, D may only be an object whose properties form a subset of T, which works well for merge or default functions. TypeScript evaluates type parameters in declaration order, so a later parameter can always reference an earlier one, but not the other way around.
9. Generic constraints compared side by side
The following table sets unsafe or impractical patterns against the recommended generic-constraint patterns and shows the concrete benefit each solution brings.
| Scenario | Unsafe / Impractical | Recommended pattern | Benefit |
|---|---|---|---|
| Property access on a generic type | function get<T>(o: T, k: string): any |
<T, K extends keyof T>(o: T, k: K): T[K] |
Type-safe return type instead of any |
| Object constraint | <T extends {}> |
<T extends Record<string, unknown>> |
Real object shape instead of near-anything type |
| Repeated type parameter | createRequest<Product[], MyOptions>(...) |
<TOptions extends object = Record<string, unknown>> |
Sensible default reduces boilerplate |
| Pick utility without a constraint | function pick(obj: any, keys: string[]): any |
<T extends object, K extends keyof T> |
Autocomplete and compile-time errors |
| Overly strict constraint | <T extends ProductEntity> |
<T extends { sku: string }> |
Reusable without a class dependency |
How strict a constraint should be depends on the structure actually needed, not on the available class hierarchy. A common mistake is overconstraining: <T extends ProductEntity> forces every caller to use a specific class, even though the function really only needs a single field like sku. The leaner constraint <T extends { sku: string }> is reusable for any matching shape, regardless of inheritance, and follows TypeScript's structural type system more consistently than a class-bound restriction.
Mironsoft
TypeScript tooling, type-safe APIs, and frontend architecture for Magento and headless projects
Need a type-safe TypeScript architecture for your project?
We build and refactor TypeScript codebases with clean generic constraints, type-safe utility functions, and consistent API contracts, for Magento headless frontends, build scripts, and internal tools that stay maintainable months later.
Type audit
Reviewing existing generics, utility types, and API contracts for weaknesses
Utility library
Reusable, type-safe helper functions like pickFields for your project
TypeScript training
Hands-on team training on generics, constraints, and advanced types
10. Summary
Generic constraints solve the core problem of unconstrained type parameters: without extends, the compiler knows not a single property of T and refuses any meaningful access. With extends, keyof, and well-designed default values, you can demand exactly the structure a function actually needs, no more and no less. The pick-fields function in this article shows how <T extends object, K extends keyof T> turns a simple signature into a fully type-safe, autocomplete-friendly utility.
The most important principle remains restraint when writing constraints: the smaller the required structure, the more reusable the function. A constraint should only ever demand the properties that are actually used inside the function body, and never a concrete class when a structural object shape is enough. Applying this principle consistently produces TypeScript utilities that stay reusable across years and many projects without modification.
Generic Constraints in TypeScript - The Essentials at a Glance
extends for structure
<T extends Shape> binds a type parameter to a structural shape, whether an interface, an object literal, or a union.
keyof for safe keys
<K extends keyof T> ties two type parameters together and rejects invalid field names at compile time.
Default parameters
<T extends Constraint = Default> reduces boilerplate at call sites without sacrificing type safety.
pick-fields as a template
<T extends object, K extends keyof T> yields exactly the return type Pick<T, K> with full autocomplete support.