Utils sind Extensions
December 20, 2022
typescript
Weiß der Entwickler nicht, wo Code einzuordnen ist, landet es bei den “Utils”. Utils verkommen schnell zum Müllberg der Codebase. Doch was sind eigentlich Utils, und welches Problem versuchen wir damit wirklich zu lösen?
Entwickeln wir Web-Applikationen, stoßen wir früher oder später auf das Problem, dass wir nicht wissen wohin bestimmter generischer Code liegen soll. Dieser Code landet dann bei den Utils. Utils haben die schöne Eigenschaft, dass sie dessen Definition sehr unspezifisch sind — Code der dort liegt ist halt irgendwie nützlich. Keiner im Team weiß deshalb so wirklich was Utils sind, bzw was dessen Kriterien sind.
Für die Einen sind Utils simple, generische Funktionen. Andere wussten nicht, wohin mit ihrem Code in der aktuellen Projektstruktur. Das konkrete Problem, was dahinter steht, wird selten hinterfragt — oder es findet sich schlicht nicht in der Projektstruktur wieder.
Ich bin der Meinung Utils lösen ein konkretes Problem: Sie wollen Funktionalität an ein externes Paket hinzufügen, auf das wir keinen Einfluss haben.
Doch wie sieht das in der Praxis aus?
Beispiel
Angenommen wir befinden uns in einem React-Projekt. Die Struktur könnte beispielsweise so aussehen:
package.json
- dayjs
- zod
- react
utils/
Wir haben also ein Projekt, das in irgendeiner Form dayjs
, zod
und react
als UI-Ebene nutzt.
Erweitern vorhandener Pakete um Funktionen
Aus irgendeinem Grund brauchen wir jetzt eine Funktion mit der Signatur
type getDateBetween = (start: dayjs.Dayjs, end: dayjs.Dayjs) => Date[]
^---------------^------------DayJS Modell
Wir legen also eine neue Datei in unsere Utils, die die entsprechende Implementierung bereitstellt.
...
utils/
+ getDatesBetween.ts
Doch, alleine wenn man sich die Signatur anschaut merkt man eines: Das gesamte Konstrukt baut auf dem Modell von dayjs
.
Anders gedacht: Wenn im dayjs
Objekt eine getDatesBetween
-Funktion existieren würde, würde die gesamte Funktion gar nicht existieren.
Wir erweitern also gerade das Modell von dayjs.
Diese Erweiterung kann verschiedene Ausprägungen haben:
- Erweitern von Paketen um eigene Funktionalitäten (eigenes Modell)
- Erweitern von Pakete um Integrationen (z.B. Integration vom Framework)
- Erweitern der Runtime (erweitern von “Array”, etc)
- Erweitern um Abstraktionen (Framework-Framework-Integration)
Erweitern von Paketen um Integrationen
Bestehende Pakete müssen häufig in das Framework wie React integriert werden. Durch diese Integrationen können Daten die im Framework vorgehalten werden ausgelesen (Inbound), oder ein Neurendern angestoßen werden (Outbound). Das passiert bei einer React-Integration z.B. durch Hooks oder bei Angular durch Module und Injectables.
Die Bilbiothek zod zum Beispiel, stellt eine einfache Möglichkeit validierungsreglen im Code abzubilden bereit.
Damit sich diese mit React integrieren lässt.
Speziell für zod
existiert für Formulate eine bereits fertige Integration.
Wenn diese nicht existieren würde, läge es an uns, eine Integration zwischen zod
und react
herzustellen.
Das könnte in der Praxis beispielsweise so aussehen.
extensions/
zod/
react/
useForm.ts
Diese Struktur lässt sich ganz einfach lesen: Ein Datenmodell von Zod integriert sich in das React-Modell mittels einer useForm
Hook.
Erweitern der Runtime
Eigentlich geht das Erweitern der Runtime Hand-In-Hand mit dem Erweitern von Paketen.
Nicht selten installieren wir Pakete wie ramda
oder lodash
, um die teilweise dürftige API der Laufzeitumgebung zu erweitern.
Würde es diese Pakete nicht geben, müsste eine Implementierung der Funktionalität trotzdem irgendwo leben — meistens bei den Utils.
In der Realität erweitern diese Funktionalitäten aber die Runtime und dessen Datenstrukturen / APIs.
ramda
bietet beispielsweise diverse Higher Order Functions an, um angenehmer mit Arrays und Objekten arbeiten zu können — eine Aufgabe, die eigentlich von einer Standardbibliothek übernommen wird.
const union = (a: any[], b: any[]): boolean
^---------^--------^------ Runtime-Modell (Array, Boolean)
Erweitern um Abstraktionen
In einem Projekt habe ich den Lifecycle von react-query
in den ionic
-Lifecycle integrieren müssen.
Das hieß in der Praxis, dass ionic in der Praxis keine Komponenten unmountet.
Ein Erneuern der Daten musste deshalb selbst, onEnter
, getätigt werden.
Es war für mich also nötig, jede Nutzung von useQuery
durch eine Abstraktion auszutauschen,
die zusätzlich eine Integration in den Ionic Lifecycle vollzogen hat.
Genau diese Abstraktion findet in useIonicQuery.ts
statt.
extensions/
react-query/
ionic/
useIonicQuery.ts
Angewandt auf verschiedene Beispiele, könnte sich eine extensions
-Struktur wie folgt ergeben.
package.json
- dayjs
- zod
- react
components/
MyApp.tsx
extensions/
dayjs/
core/
getDatesBetween.ts
react/
useDayJs
react-query
ionic/
useIonicQuery.ts
zod/
core/
createZodObject.ts
react/
useForm.ts
typescript/
core/
anyMatch.ts
Wo ist der Mehrwert?
Es ist berechtigt zu fragen, was eine Betrachtung von utils als extensions für einen Mehrwert hat. Professionelle Software hat immer einen architektonischen Aspekt inne. Architektur bedeutet in diesem Kontext, dass Abhängigkeiten zwischen Modulen kontrolliert werden. Beispielsweise kann eine Regel sein, dass DayJS nur in components, nicht aber in services genutzt werden — eine Betrachtung die durch ein Utils-Layer völlig außen vor bleibt — denn: Was unterscheidet in dieser Betrachtung ein zod-Util von einem dayjs-util?
Durch eine extension-Sicht, werden Regeln geerbt: Alles was in extensions/dayjs
liegt unterliegt den selben import-Regeln wie das Package dayjs.
Das bedeutet in der Praxis, wenn ein import aus Dayjs für ein bestimmtes Modul verboten ist, dann ist auch der import aus dessen extension package verboten.
Eine explizite Sicht der Architektur ist damit viel klarer.
Fazit
Werden die Signaturen der Util-Functions betrachtet lassen sich häufig erkenne, wozu diese eigentlich gehören, oder auch: Welches Modell sie Sprechen. Die meisten Utils lassen sich als Funktionserweiterung, Integrationserweiterung oder Abstraktionserweiterung betrachten. Auch die Runtime ist in einer Form ein externes Package z.B. wenn eine grundlegende Datenstruktur wie ein Array um Funktionalität erweitert werden soll: typescript. Die meisten Pakete, wie ramda oder lodash lassen sich 1 zu 1 in diese extension einordnen.
Extensions haben den Vorteil, dass diese 1 zu 1 die Architektur Regeln ihres Kern-Packages beerben können und somit architekturell gesondert betrachtet werden können.