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

The naming of generics is not just a trivial decision; it can affect the readability and maintainability of your code. Here are two common 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.

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<KeyType, ValueType, ResultType>(
    obj: Record<KeyType, ValueType>,
    transform: (key: KeyType, value: ValueType) => ResultType
): Record<KeyType, ResultType> {
    let result: Record<KeyType, ResultType> = {} as Record<KeyType, ResultType>;
    for (const [key, value] of Object.entries(obj)) {
        result[key as KeyType] = transform(key as KeyType, value as ValueType);
    }
    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 💥).

Summary

If you ask me which one to use, I'll answer as usual in programming: it depends! However, I use both, and it totally depends on the context.

  1. Writing a standalone library: I use (Element, Key, Value) by default.
  2. App codebase: I pick one and try to be consistent.

Try both and consider which one fits better for you.