typescript
conventions
naming
code-quality
eslint

This article is not diving into technical aspects of generics, it's focusing only on naming conventions.

Naming Generics in TypeScript

Generics are a powerful feature in TypeScript, allowing developers to write flexible, reusable code. One of the key aspects of using generics effectively is choosing the right names for them. This article will explore the naming conventions for generics in TypeScript, illustrate with examples, and discuss the significant differences and implications of different naming strategies.

Why the Generics Naming is Important?

This is because, particularly in advanced generic type definitions and the associated code, it's easy to get lost. Authors of libraries often produce such complex type definitions. If you find yourself frequently crafting and reading these definitions, the naming convention becomes important. However, for simpler type definitions or those specific to an application, it's not worth the effort to overcomplicate the naming.

But just out of curiosity, here you have the example to illustrate how complex it can be...

type Transform<T, U, V> = T extends Array<infer R>
  ? U extends { new(...args: any[]): infer S }
    ? V extends (a: R, b: S) => infer Q
      ? Q[]
      : never
    : never
  : never;

function deepTransform<T, U extends { new(...args: any[]): any }, V extends (a: any, b: any) => any>(
  input: T[],
  clazz: U,
  transformFn: V
): Transform<T[], U, V> {
  const instance = new clazz();
  return input.map(item => transformFn(item, instance)) as Transform<T[], U, V>;
}

// Example Usage
class Processor {
  multiply(n: number) { return n * 2; }
}

const numbers = [1, 2, 3];
const transformed = deepTransform(numbers, Processor, (x, processor: Processor) => processor.multiply(x));

console.log(transformed); // [2, 4, 6]

I’ll skip the technical details of this code and focus instead on the naming. In this context, the unclear naming makes it nearly impossible for newcomers to understand what is going on.

In programming, one of the worst things you can do is make code understandable only to yourself. The same occurs in the context of TypeScript type definitions. Let's explore the options we have.

Common Naming Conventions

Naming generics is not just a trivial decision; it can affect the readability and maintainability of your code. Here are some available approaches:

Single Upper Case Letters (T, U, V)

This is the most traditional way of naming generics and is borrowed from languages like C# and Java. It’s concise and generally used when the specific role of the generic type is either clear from context or not extremely important to the understanding of the code.

Descriptive Names (Element, Key, Value)

This approach involves using more descriptive names for type parameters. It is particularly useful when the type parameter plays a specific, identifiable role in the function or component, making the code more readable.

Mixed Names (TElement, TKey, TValue)

This approach involves using both a descriptive name and the T prefix to indicate a generic type. It provides clear separation between non-generic types and generics, adding semantic clarity.

It's similar to the Hungarian convention used for interface naming, such as IEnumerable, to differentiate between interfaces and implementations in languages like C# or Java.

Which Convention Should You Use?

For really simple code and type definitions, the (T, U, V) convention will be 100% fine. If the function name indicates its purpose and the function itself is simple, there shouldn't be any problem understanding what's going on.

function identity<T>(arg: T): T {
    return arg;
}

function mergeObjects<A, B>(first: A, second: B): A & B {
    return {...first, ...second};
}

However, if you have more advanced functions, like the ones below, consider using the (Element, Key, Value) convention.

function mapObject<Key, Value, Result>(
  obj: Record<Key, Value>,
  transform: (key: Key, value: Value) => Result,
): Record<Key, Result> {
  const result: Record<Key, Result> = {} as Record<Key, Result>;
  for (const [key, value] of Object.entries(obj)) {
    result[key as Key] = transform(key as Key, value as Value);
  }
  return result;
}

If you're using other similarly named types in the same files, use the (TElement, TKey, TValue) convention.

type Key = string;
type Value = unknown;
type Result = unknown;

function mapObject<
  TKey extends string,
  TValue extends Value,
  TResult extends Result,
>(
  obj: Record<TKey, TValue>,
  transform: (key: TKey, value: TValue) => TResult,
): Record<TKey, TResult> {
  const result: Record<TKey, TResult> = {} as Record<TKey, TResult>;
  for (const [key, value] of Object.entries(obj)) {
    result[key as TKey] = transform(key as TKey, value as TValue);
  }
  return result;
}

Forcing Consistency (Optional)

Consistency is key to an easy-to-understand and navigable codebase. Therefore, if you're working on a library, it's better to use only one convention. So, even for simple generics, using the (Element, Key, Value) convention will be beneficial. The same principle applies to applications.

To enforce a specific rule, you may create your own eslint plugin. I've tried to find one already created by the community, but there isn't any. Thus, you can craft something similar (which is, of course, totally optional). Not every convention applied in a project requires an eslint rule.

// File: rules/enforce-naming-convention.js

const rule = {
    meta: {
        type: "suggestion",
        docs: {
            description: "enforce naming conventions for generic type arguments",
            category: "Stylistic Issues",
            recommended: false,
        },
        schema: [], // No options for this rule
        messages: {
            invalidName: "Generic type argument '{{ name }}' does not follow the naming convention or is too short.",
        },
    },
    create(context) {
        return {
            TSTypeParameter(node) {
                const name = node.name.name;
                // Check if the name is at least 3 characters long
                if (name.length < 3) {
                    context.report({
                        node,
                        messageId: "invalidName",
                        data: { name },
                    });
                }
                // Additional checks can be added here
            }
        };
    }
};

module.exports = rule;

Comparing Conventions

To understand the difference, let's start with an example from beginning.

type Transform<T, U, V> = T extends Array<infer R>
  ? U extends { new(...args: any[]): infer S }
    ? V extends (a: R, b: S) => infer Q
      ? Q[]
      : never
    : never
  : never;

function deepTransform<T, U extends { new(...args: any[]): any }, V extends (a: any, b: any) => any>(
  input: T[],
  clazz: U,
  transformFn: V
): Transform<T[], U, V> {
  const instance = new clazz();
  return input.map(item => transformFn(item, instance)) as Transform<T[], U, V>;
}

Now, let's convert the (T, U, V) convention to (Element, Key, Value).

type Transform<ArrayType, ConstructorType, FunctionType> = ArrayType extends Array<infer ElementType>
  ? ConstructorType extends { new(...args: any[]): infer InstanceType }
    ? FunctionType extends (element: ElementType, instance: InstanceType) => infer ResultType
      ? ResultType[]
      : never
    : never
  : never;

function deepTransform<ArrayType, ConstructorType extends { new(...args: any[]): any }, FunctionType extends (element: any, instance: any) => any>(
  input: ArrayType[],
  Constructor: ConstructorType,
  transformFunction: FunctionType
): Transform<ArrayType[], ConstructorType, FunctionType> {
  const instance = new Constructor();
  return input.map(item => transformFunction(item, instance)) as Transform<ArrayType[], ConstructorType, FunctionType>;
}

From my perspective, the second version is a bit more readable. However, it takes up more space. Sometimes, it's better to put effort into more descriptive names rather than striving for the shortest code possible. After all, humans are reading the code (don’t worry about AI taking your job 💥).

The problem with the (Element, Key, Value) approach is that it's easy to mix the generic types with static types that are imported to the same file or defined in the same file. That's why I personally always use (TElement, TKey, TValue) to maintain a clear distinction and avoid confusion in the future.

type Transform<TArray, TConstructor, TFunction> = TArray extends Array<
  infer ElementType
>
  ? TConstructor extends { new (...args: any[]): infer InstanceType }
    ? TFunction extends (
        element: ElementType,
        instance: InstanceType,
      ) => infer ResultType
      ? ResultType[]
      : never
    : never
  : never;

function deepTransform<
  TArray,
  TConstructor extends { new (...args: any[]): any },
  TFunction extends (element: any, instance: any) => any,
>(
  input: TArray[],
  Constructor: TConstructor,
  transformFunction: TFunction,
): Transform<TArray[], TConstructor, TFunction> {
  const instance = new Constructor();
  return input.map((item) => transformFunction(item, instance)) as Transform<
    TArray[],
    TConstructor,
    TFunction
  >;
}

Summary

If you ask me which one you should use, I would say - do not use the second option. The risk of mixing normally named types with generics creates confusion - seriously, it's hard to understand what is going on in more complex cases.

Pick the first or third option. Personally, I always use the third option, regardless of whether it's an app or a library. This choice ensures future scalability, readability, and reduces cognitive load when reading and analyzing code.

It's only a convention, so pick whatever you prefer and stick to it. That's the most important factor.

Author avatar
About Authorpraca_praca

👋 Hi there! My name is Adrian, and I've been programming for almost 7 years 💻. I love TDD, monorepo, AI, design patterns, architectural patterns, and all aspects related to creating modern and scalable solutions 🧠.