Generic Constraints: Sensibly Restricting Types
AI generated
<T>
type
TypeScript · Generics · Type Safety · Utility Types
Generic Constraints: Sensibly Restricting Types
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.

12 min. read extends · keyof · default parameters TypeScript 5.x · Utility Types

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.

11. FAQ: Generic Constraints in TypeScript

1What is the difference between <T> and <T extends SomeType>?
<T> accepts any type with no guarantees. <T extends SomeType> restricts T to structurally compatible types, allowing safe access to SomeType's properties.
2Why can't I call properties on an unconstrained generic?
The compiler can't assume that some arbitrary type has a given property. Without a constraint, the common denominator across all types is empty.
3What does <T extends object> do?
Excludes primitives like string, number, and boolean, but allows objects, arrays, functions, and classes. It doesn't yet guarantee any specific fields.
4When should I use Record<string, unknown> instead of object?
When arbitrary string keys with values of type unknown are expected, e.g. for iteration or merge functions. object is enough for a plain referenceable structure.
5How exactly does K extends keyof T work?
keyof T produces a union of all property names. K extends keyof T binds a second parameter to it, so T[K] delivers the exact return type.
6What is a default generic parameter and when is it useful?
<T = DefaultType> applies when no type is given or inferable. Useful for multiple type parameters where usually only one is really needed.
7Can I combine a default parameter with a constraint?
Yes: <T extends Constraint = DefaultType>. The default must satisfy the constraint and must come after parameters without a default.
8How do I build a type-safe pick-fields function?
With function pickFields<T extends object, K extends keyof T>(source: T, keys: readonly K[]): Pick<T, K>. The constraints guarantee valid field names and the exact return type.
9What does it mean to combine multiple constraints with &?
<T extends A & B> requires T to structurally satisfy both types. Useful for functions that work with several independent interfaces.
10What is overconstraining and how do I avoid it?
Unnecessarily binding a function to a concrete class. A minimal structural constraint like <T extends { sku: string }> is more reusable.