Generics in der Softwareentwicklung
Autor
Christian Südkamp
Software Developer
bei SYZYGY Techsolutions
Lesedauer
9 Minuten
Publiziert
02. April 2024
Letztens beim Coden… Da waren sie wieder, die zwei spitzen Klammern mit den Buchstaben T und R dazwischen. Zwar arbeite ich immer wieder mal mit “Generics”, so richtig intensiv beschäftigt habe ich mich jedoch noch nicht damit. Es war Zeit, das zu ändern.
Was sind „Generics“?
Das Konzept von “Generics” tauchte historisch zuerst in Ada auf, einer Programmiersprache, die 1980 von der US-amerikanischen Verteidigungsbehörde entwickelt wurde. In C++ werden sie als “Templates” bezeichnet und in Java als “Generics”.
Generics sind ein Ansatz in der Softwareentwicklung, bei dem allgemeine und wiederverwendbare Funktionen, Schnittstellen und Klassen geschrieben werden, die nicht nur mit einem spezifischen Datentyp arbeiten können. So gut wie alle modernen Programmiersprachen haben dieses Konzept integriert, meine nachfolgenden Codebeispiele sind in Typescript geschrieben.
Hier ein erstes Beispiel, der useState Hook von React:
/**
* Returns a stateful value, and a function to update it.
*
* @version 16.8.0
* @see https://react.dev/reference/react/useState
*/
function useState<S>(
initialState: S | (() => S)
): [S, Dispatch<SetStateAction<S>>];
// convenience overload when first argument is omitted
In diesem Beispiel wird der Typ S als Typenparameter für die Funktion useState verwendet. Der Typ S wird erst zur Laufzeit durch den übergebenen Parameter festgelegt. Dabei kann S ein beliebiger Typ oder eine Funktion sein, die einen beliebigen Typ zurückgibt.
Wann lohnt es sich, generische Funktionen oder Interfaces zu verwenden?
Nur ein paar Beispiele:
- Eine Funktion, bei der der Typ des Rückgabewerts noch nicht zur Entwicklungszeit feststeht.
- Eine Funktion, die mit verschiedenen Typen arbeiten kann, ohne sich z.B. auf Union Types festzulegen.
- Ein Interface, das mithilfe von 2 Typenparametern eine Relation zwischen 2 Typen beschreibt.
- Eine asynchrone Funktion, die einen Promise zurückgibt; Hier lässt sich der Typ des aufgelösten Promises mit einem Typenparameter definieren.
Wie werden Generics verwendet?
Im Folgenden zeige ich drei verschiedene Typen von Generics:
- Generische Funktionen
- Generische Interfaces
- Generische Klassen
Generische Funktionen
function whatsMyType<Type>(myType: Type): Type {
return myType;
}
Diese generische Funktion kann explizit oder implizit aufgerufen werden:
Explizit:
const myType = whatsMyType<string>('Hello World');
console.log(myType); // Hello World
Der Typenparameter wird hier explizit angegeben.
Implizit:
const myType = whatsMyType('Hello World');
console.log(myType); // Hello World
Der Typenparameter wird vom Compiler inferiert. Das funktioniert gut mit simplen Typen. Um komplexere Typen zu inferieren, kann es sein, dass der Typ explizit angegeben werden muss, beispielsweise mit einem generischen Interface.
Any?
Ein paar Worte zu Any. Bei diesem Beispiel könnte man auf die Idee kommen, den Funktionsparameter myType als any zu typisieren, um beliebige Typen beim Funktionsaufruf übergeben zu können. Ohne jetzt genau darauf einzugehen, warum die Nutzung von Any ein Bad Practice ist und in produktivem Code nichts zu suchen hat, möchte ich dennoch ein paar konkrete Gründe aufführen, die für die Nutzung von generischen Type-Parametern sprechen:
- Im Gegensatz zu der Verwendung des Typs any, wird der Typ Type zur Laufzeit hier nicht verändert. Das bedeutet, dass die Typsicherheit gewährleistet ist.
- Die meisten TypeScript Konfigurationen verbieten die implizite Verwendung des Typs any.
- Wird any eingesetzt gibt es keine IntelliSense Unterstützung.
Generische Interfaces
interface MyGenericFunction {
<Type>(myType: Type): Type;
}
function whatsMyType<Type>(myType: Type): Type {
return myType;
}
const returnTheType: MyGenericFunction = whatsMyType;
returnTheType('I <3 TypeScript'); // I <3 TypeScript
In diesem Beispiel wird ein generisches Interface MyGenericFunction definiert, das eine Funktion beschreibt, die einen Parameter vom Typ Type erwartet und einen Rückgabewert vom Typ Type zurückgibt.
Die Benennung des Typenparameters ist hier nicht relevant. Es ist lediglich eine Konvention, dass der Typenparameter Type genannt wird (oder einfach T).
Ebenso kann einem Interface direkt der Typenparameter übergeben werden:
interface MyGenericFunction<CustomType> {
(myType: CustomType): CustomType;
}
function whatsMyType<Type>(myType: Type): Type {
return myType;
}
const returnStringType: MyGenericFunction<string> = whatsMyType;
const returnNumberType: MyGenericFunction<number> = whatsMyType;
returnStringType('I <3 TypeScript'); // I <3 TypeScript
returnNumberType(9001); // 9001
Der Typenparameter ist auf diese Weise für alle Properties und Methoden des Interfaces verfügbar.
Generische Klassen
class MyGenericClass<Type> {
myType: Type;
constructor(myType: Type) {
this.myType = myType;
}
}
const myGenericClass = new MyGenericClass<string>('Hello World');
In diesem Beispiel wird ein generischer Typ Type definiert, der für alle Properties und Methoden der Klasse verfügbar ist.
Generics mit Constraints
Manchmal ist es notwendig, dass der Typenparameter von einem bestimmten Typ abgeleitet wird. Das kann mit Constraints erreicht werden.
interface MyGenericFunction<Type extends string> {
(argument: Type): Type;
}
function makeBigger<T extends string>(stringToMakeBigger: T) {
return stringToMakeBigger.toUpperCase();
}
let myGenericFunction: MyGenericFunction<string> = makeBigger;
myGenericFunction('hello'); // HELLO
myGenericFunction(9001); // Argument of type 'number' is not assignable to parameter of type 'string'.
Bei diesem Beispiel wird das Interface MyGenericFunction mit einem Constraint versehen. Der Typenparameter Type muss vom Typ string sein und folglich auch die Methode toUpperCase besitzen.
Mehrere Constraints
Bei der Restriktion kann man auch mehrere Typen angeben:
interface MyGenericFunction<Type extends string | Person> {
(argument: Type): Type;
}
interface Person {
name: string;
age?: number;
[key: string]: any;
}
function transformNameToUppercase<T extends string | Person>(typeParameter: T) {
if (typeof typeParameter === 'string') {
return typeParameter.toUpperCase();
} else if (typeParameter.name) {
return typeParameter.name.toUpperCase();
}
return typeParameter;
}
let returnUppercasedName: MyGenericFunction<string | Person> =
transformNameToUppercase;
let aPerson: Person = { name: 'Chris', age: 36 };
returnUppercasedName('Zoe'); // ZOE
returnUppercasedName(aPerson); // CHRIS
returnUppercasedName({ name: 'John', lastname: 'Doe' }); // JOHN
returnUppercasedName(9001); // Argument of type 'number' is not assignable to parameter of type 'string | Person'.
Einen Typenparameter von einem anderen Typenparameter ableiten
interface Developer {
name: string;
type: 'frontend' | 'backend' | 'fullstack';
skillLevel: number;
}
function returnDeveloperProperty<Developer, Property extends keyof Developer>(
developer: Developer,
propertyName: Property
): Developer[Property] {
return developer[propertyName];
}
let chris: Developer = {
name: 'Chris',
type: 'frontend',
skillLevel: 9001,
};
returnDeveloperProperty(chris, 'skillLevel'); // 9001
returnDeveloperProperty(chris, 'techstack'); // Argument of type '"techstack"' is not assignable to parameter of type 'keyof Developer'.
In diesem Beispiel wird die Funktion returnDeveloperProperty definiert, die 2 Typenparameter erwartet. Der erste Typenparameter Developer ist der Typ, von dem der zweite Typenparameter Property abgeleitet wird.
Stolperfallen
Ein häufiger Fehler beim Definieren von generischen Funktionen ist, dass der Rückgabewert nicht vom selben Typ wie der Typenparameter ist, sondern lediglich dem Constraint entspricht.
interface Person {
name: string;
}
interface Developer extends Person {
isDeveloper: boolean;
}
function returnDeveloper<T extends Person>(developer: T): T {
if (developer.isDeveloper) {
return developer;
}
return { name: developer.name };
}
/*
Type '{ name: string; }' is not assignable to type 'T'.
'{ name: string; }' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint 'Person'.
*/
function whatsMyType<Type>(myType: Type): Type {
return myType;
}
console.log(whatsMyType(9001)); // 9001
Abschließendes Beispiel
Abschließend ein einfaches Beispiel aus einer Komponentenbibliothek für ein Kundenprojekt. Bei dem Beispiel handelt es sich um eine Komponente, die ein Label Element für ein beliebiges Formularfeld rendern kann.
type Props<T extends React.ElementType> = {
/**
* Text to render inside the label.
*/
label: string;
/**
* Displays an optional text for the Component to compliment the label.
*/
additionalText?: string;
/**
* Applies `className` to the label.
*/
className?: string;
/**
* Used to render the label as a different element type.
* @default label
*/
as?: 'label' | 'div';
} & React.ComponentPropsWithoutRef<T>;
/**
* A label is used to display text that identifies a component’s input.
*/
export const FormLabel = <T extends React.ElementType>({
label,
additionalText,
as: Component = 'label',
className,
...rest
}: Props<T>) => {
const emptySpace = ' ';
return (
<Component
{...rest}
className={classNames(styles['form-label'], className)}
>
{label}
{additionalText && (
<span className={styles['additional-text']}>
{emptySpace}
{additionalText}
</span>
)}
</Component> );
};
Man kann die Komponente entweder mit einem div oder einem label Element rendern und hat alle möglichen Properties von div und label zur Verfügung.
Ich finde man sieht hier sehr gut, wie Generics die Flexibilität und Wiederverwendbarkeit von Code erhöhen können.
Fazit
Generics können dabei helfen, Code zu schreiben, der flexibel, wieder verwendbar und typsicher ist. Sie können aber auch die Komplexität des Codes erhöhen. Daher gilt es, sich Fragen zu stellen wie:
- Steht der Typ des Rückgabewerts zur Entwicklungszeit fest?
- Muss die Funktion mit verschiedenen Typen arbeiten können?
- Kann ich eine Relation zwischen 2 Typen beschreiben?
Head of Technology