Blog
Mein Notizblog

Julian Strecker
TypeScript, JavaScript, Linux
Feb 8, 2020 - 30 Minuten Lesezeit

TypeScript Module schreiben

1. Ziel

Ich möchte dir erklären, wie du deinen TypeScript-Code derart in ein Modul in einer Package-Umgebung bettest, dass andere Entwickler dein Modul verwenden können, egal ob sie TypeScript oder JavaScript verwenden.

Wenn ich im Umfeld von TypeScript oder JavaScript arbeite, nutze ich in erheblichem Maße Code, der von anderen entwickelt wurde. Dazu benutze ich Node Package Management npm (meist über yarn, einer alternativ-CLI zu npm aber das führt zu weit). Ich habe mal in meiner Umgebung herumgefragt, warum die Leute nicht auch mal selbst ein solches Paket veröffentlichen, wenn sie eine tolle Idee umgesetzt haben. Neben individuellen Gründen gibt es auch einen ziemlich durchgängigen Grund:

Die Leute wissen nicht, wie sie ein Paket so sauber verschnüren, dass mögichst viele Entwickler das Geschenk darin auch auspacken können!

Diesen Grund möchte ich beseitigen. Leider kann ich nicht auf alle verschiedenen möglichen Setups eingehen daher nehme ich so eine Art Minimal-Wunsch-setup von mir.

Ich werde hier vorstellen, wie ich

  • ein neues TypeScript-Modul aufsetze,
  • TypeScript und Linting konfiguriere,
  • den Build-Prozess aufsetze,
  • Jest als Test-Framework einsetze um Unit-Tests zu implementieren und
  • das resultierende Package nutze.

2. Vorbedingungen

  • Du brauchst git auf deinem System
  • Du brauchst node auf deinem System
  • Du brauchst yarn oder npm (global) installiert
  • Du brauchst tsc (global) installiert (yarn global add typescript)

3. Aufbau

TLDR: Wenn du gerne einfach einen Boilerplate hättest, habe ich das Ergebnis aller folgenden Schritte zusammengefasst in ein GitHub-Repo gelegt.

Ich werde zwei verschiedene Projekte starten:

  1. ts-package-skeleton, ein Typescript-Package, das eine Klasse Greeter bereitstellt, deren Objektinstanzen "Hello World!" ausgeben können. Auf ziemlich komplizierte Art und Weise.
  2. ts-package-consumer, ein Package, das Greeter aus ts-package-skeleton verwendet um "Hello World!" auszugeben.

4. Initialisierung von ts-package-skeleton

Mein neues Package existiert bisher nur in meinem Kopf. Es braucht ein Verzeichnis.

mkdir ts-package-skeleton && cd $_

Der erste Teil erstellt das Verzeichnis und der zweite Teil wechselt in dieses neu erstellte Verzeichnis. In diesem Verzeichnis bleibe ich ab jetzt. Ich verlasse es nicht mehr, bevor ich fertig bin. Alle Befehle gehen von diesem Verzeichnis aus.

4.1. git Init

Ich möchte ein git-Repo haben. Also initialisiere ich eines

git init

4.2. yarn Init

Ich möchte ein Node-Package machen und dafür initialisiere ich es mit yarn init: Da, wo ENTER steht habe ich einfach Enter gedrückt.

➜  ts-package-skeleton git:(master) yarn init
yarn init v1.22.0
question name (ts-package-skeleton): ENTER
question version (1.0.0): 0.0.0
question description: A basic package providing boilerplate
    and a TypeScript module implementing Hello World!
question entry point (index.js): dist/index.js
question repository url: ENTER
question author: Julian Strecker
question license (MIT): ENTER
question private: ENTER
success Saved package.json
Done in 48.44s.

Das erzeugt eine eine package.json-Datei, die so aussieht:

{
  "name": "ts-package-skeleton",
  "version": "0.0.0",
  "description": "A basic package providing boilerplate and a TypeScript module implementing Hello World!",
  "main": "dist/index.js",
  "author": "Julian Strecker",
  "license": "MIT"
}

Die package.json ist die zentrale Beschreibung des Pakets, das ich jetzt erstelle. Hier ist mir bis hierhin eigentlich alles austauschbar oder selbsterklärend außer

"main": dist/index.js. Das bedeutet, dass die Einstiegsdatei für Benutzer meines Moduls in dist/index.js liegt (und nicht, wie default, in index.js im root des Pakets).

4.3. tsc --init

Da ich in TypeScript programmieren werde, mache ich noch die Initialisierung der tsconfig.json mittels

tsc --init

Ergebnis ist, dass eine neue Datei namens tsconfig.json entsteht. Sie hat sehr viele auskommentierte Zeilen und Textkommentare. Da ich nicht genau weiß, was bei dir generiert wird, schreibe ich mal auf, was ich daraus gemacht habe. Ich habe alle Kommentare entfernt und noch etwas hinzugefügt:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "declaration": true,
    "outDir": "./dist/",
    "strict": true,
    "esModuleInterop": true
  }
}

Ich erkläre die Bedeutung der Zeilen:

  • compilerOptions: Ich liefere an den Benutzer meines Moduls nur kompiliertes JavaScript aus. die compilerOptions beschreiben, wie und wohin gebaut werden soll.
  • target: Die JavaScript-Version für die mein Paket Compiliert werden kann. ES5 wird so grob von allen Browsern vollständig unterstützt.
  • module: Es gibt verschiedene Konventionen, wie Module geschrieben werden können, sodass sie sich gegenseitig verwenden können (CommonJS, AMD, UMD, ES6, ESNext, ...). Ich entscheide mich für CommonJS, weil es eine gute Unterstützung bietet.
  • declaration: Ganz wichtig und nicht default! Ich möchte den Benutzern meines TypeScript Moduls zwar das JavaScipt-Kompilat geben, den TypeScript-Nutzern aber den Bonus von TypeScript nicht vorenthalten. Also brauchen sie TypeDefinitionen. Diese werden von tsc (TypeScript Compile) mit erzeugt, wenn ich hier auf true stelle.
  • outDir: Auch wichtig und nicht default! Hier gebe ich an, dass das Kompilat im Ordner dist landen soll. Du erinnerst dich hoffentlich noch an die package.json und main...
  • strict: Auf true gesetzt schaltet strict striktes TypeChecking an. Für mich bedeutet das, dass ich TypeScript schreiben muss und nicht JavaScript oder Typenangaben schreiben kann. Das ist wichtig, damit andere TypeScript-Programmierer mein Paket mit ihren TypeDefinitions nutzen können.
  • esModuleInterop: Ermöglicht das Importieren von CommonJS Modulen in eine ES6-TypeScript-Umgebung. Hier gibt es eine gute Erklärung dazu.

Im Moment sieht mein Projektverzeichnis so aus und belegt 256kB Speicher.

.
├── .git
│   └── ...
├── package.json
└── tsconfig.json

4.4. TypeScript

Ich habe alles für TypeScript vorbereitet und habe tsc auch für mich (global) zur Verfügung. Ich möchte aber das gesamte Ökosystem geschlossen im Paket definiert haben. Daher lege ich fest, dass typescript eine Entwicklungs-Dependency von meinem Paket ist.

yarn add -D typescript

Das -D bedeutet, dass es sich bei typescript um eine Entwicklungs-Abhängigkeit handelt. Für den Verwender von ts-package-skeleton ist TypeScript nicht notwnedig.

4.5 .gitignore

Ich habe durch die Installation von typescript jetzt viel, viel mehr Daten in meinem Projektverzeichnis - und zwar in node_modules. Mein Projekt ist jetzt schon 51MB groß geworden. Diese ganzen Daten herunterzuladen, möchte ich nicht gitlab, github oder anderen Entwicklern zumuten und ignoriere sie deshalb aus meinem Repository heraus:

echo "node_modules" > .gitignore

In die .gitignore können noch andere Verzeichnisse und Dateien aufgenommen und damit aus dem Repo exkludiert werden. Meine .gitignore sieht so aus:

/dist
/.idea
node_modules

Erkläreung:

  • dist: Gebauter Code kommt nicht ins Repo, weil er aus dem Repo erzeugt wird. Er ist redundant.
  • idea: Ich benutze PHPStorm. Dessen Einstellungen etc. sind zwar für mich aber nicht für alle interessante. Raus damit.
  • node_modules: Richtig viel Datengewicht. Und auch redundant, weil die Information über benötigte Pakete und ihre Versionen in der package.json stehen.

5. Quellcode einfügen

Mein Quellcode soll im Verzeichnis src unter dem Projektverzeichnis liegen. Ich lege das Verzeichnis und die Haupt-Quelldatei an:

mkdir src; touch src/index.ts

Diese index.ts enthält jetzt den eigentlichen Code, den ich bereitstellen möchte.

Merke: bereitstellen = export.

Der Code der Datei bringt die Ausgabe von "Hello World" auf eine ganz neue Stufe der Komplexität. Der Grund ist, dass ich natürlich auch Konzepte von TypeScript verwenden möchte, um zu zeigen, dass das Kontrukt in einer JavaScript-Umgebung dennoch funktioniert. Hier ist er:

const greetingPrefix = 'Hello';
const greetingSuffix = '!';

export class Greeter {
    get name(): string {
        return this._name;
    }

    private readonly _name: string;
    private _greetingString: string = '';

    constructor(greetedName: string) {
        this._name = greetedName;
        this.generateGreetingString();
    }

    private generateGreetingString(): void {
        this._greetingString = `${greetingPrefix} ${this._name}${greetingSuffix}`;
    }

    public greet(callback: (greeting: string) => void): void {
        callback(this._greetingString);
    }
}

Das ganze kann ich jetzt schon kompilieren, indem ich yarn tsc ausführe.

.
├── dist             <-- Ergebnis des Build
│   ├── index.d.ts   <-- TypeDefinitionen
│   └── index.js     <-- JavaScript Code ES5-kompatibel
├── .git             <-- Git Daten
│   └── ...
├── .gitignore       <-- exkludierte Dateien
├── node_modules     <-- Abhängigkeiten
├── package.json     <-- Paketinformationen
├── src              <-- Quellcode
│   └── index.ts     <-- Einzige Quelldatei
├── tsconfig.json    <-- TypeScript Compiler-Informationen
└── yarn.lock        <-- Informationen über installierte Abhängigkeiten

Um den Befehl yarn tsc durch etwas eingängigeres zu ersetzen, möchte ich in der package.json ein Script definieren. Außerdem möchte ich die TypeDefinitions bekanntmachen. Damit Entwickler die mein Paket nutzen, diese direkt eingebunden bekommen in ihrer IDE.

Dafür ändere ich die package.json derart ab, dass sie so beginnt (types und scripts habe ich hinzugefügt):

{
  "name": "ts-package-skeleton",
  "version": "0.0.0",
  "description": "A basic package providing boilerplate and a TypeScript module implementing Hello World!",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "test": "jest --config jestconfig.json",
    "build": "tsc",
    "lint": "tslint -p tsconfig.json"
  },
  "author": "Julian Strecker",
  "license": "MIT",
  ...

Ab jetzt kann ich mit yarn build bauen. Die andern Befehle gehen noch nicht.

6. Linting

Für Linting mit TypeScript brauche ich (nur als Entwickler) tslint:

yarn add -D tslint

Es bedarf jetzt noch einer tslint.json

touch tslint.json

mit Inhalt. Ich habe mal meine aus einem anderen Projekt verwendet. Hier gilt: Jeder Geschmack ist einzigartig und daher kann jede tslint.json auch anders aussehen! Mein Ansatz soll nur als Inspiriation dienen.

{
  "defaultSeverity": "error",
  "extends": [
    "tslint:recommended"
  ],
  "jsRules": {},
  "rules": {
    "eofline": true,
    "no-trailing-whitespace": true,
    "ordered-imports": false,
    "comment-format": false,
    "object-literal-sort-keys": false,
    "interface-name": false,
    "trailing-comma": [
      true,
      {
        "multiline": {
          "objects": "never",
          "arrays": "never",
          "functions": "never",
          "typeLiterals": "ignore"
        },
        "esSpecCompliant": true
      }
    ],
    "quotemark": [
      true,
      "single"
    ],
    "no-console": false,
    "one-line": false,
    "no-consecutive-blank-lines": true,
    "curly": true,
    "cyclomatic-complexity": [
      true,
      5
    ],
    "no-irregular-whitespace": true,
    "whitespace": [
      true,
      "check-postbrace",
      "check-module",
      "check-preblock",
      "check-decl",
      "check-branch",
      "check-separator"
    ],
    "no-magic-numbers": [
      true,
      -1,
      0,
      1,
      2,
      3,
      10
    ],
    "only-arrow-functions": false,
    "no-shadowed-variable": true,
    "no-var-keyword": true,
    "no-duplicate-imports": true,
    "space-within-parens": true,
    "variable-name": {
      "options": "allow-leading-underscore"
    }
  },
  "rulesDirectory": []
}

7. Dateien für Paket-Export white-listen

Da ich einige Dateien im Repo habe, die explizit für die Entwicklung, nicht aber für die Verwendung des Pakets nötig sind. Ich möchte also in der package.json angeben, welche Dateien genau im Paket veröffentlicht werden sollen. Das geht durch Hinzufügen des Schlüssels files:

  ...
  "types": "dist/index.d.ts",
  "files": [
    "dist/**/*"
  ],
  ...

Also alle Dateien und Ordner innerhalb des dist-Orders oder seinen Kindern usw. sollen publiziert werden.

8. Testen und Tests hinzufügen

Testen ist ein sehr wichtiger Aspekt der Softwareentwicklung. Du solltest immer daran denken, deinen Projekten sinnvolle Tests hinzuzufügen. Du kannst diesen Punkt auch überspringen und später vielleicht hinzufügen.

Für das Testen meines Codes benutze ich Jest. Damit ich Jest verwenden kann, muss ich einige Pakete installieren:

yarn add -D @types/jest @types/node jest ts-jest
touch jestconfig.json

Die jestconfig.json, die ich verwende, ist ganz default für TypeScript-Proejkte und sieht so aus:

{
  "transform": {
    "^.+\\.(t|j)s$": "ts-jest"
  },
  "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts)$",
  "moduleFileExtensions": ["ts", "js", "json", "node"]
}

Sie beschreibt im Grunde, welche Dateien als Test-Dateien benutzt werden sollen. Dies sind Dateien, die in Ordnern liegen die __tests__ heißen und die mit test.ts oder test.js enden. So eine Datei lege ich jetzt an:

mkdir src/__tests__ && touch src/__tests__/Greeter.test.ts

Ihr Inhalt sieht so aus:

import { Greeter } from '../index';

// @ts-ignore
global.console = {
    warn: jest.fn(),
    log: jest.fn()
};

test('Test the Greeter Construction', () => {
    const greeter = new Greeter('World');
    expect(greeter).toBeTruthy();
});

test('Test Name Setting and Getting', () => {
    expect(new Greeter('World').name).toBe('World');
    expect(new Greeter('').name).toBe('');
});

test('Greeting Logging', () => {
    const greeter = new Greeter('World');
    const consoleLogger = (greeting: string) => console.log(greeting);
    greeter.greet(consoleLogger);
    expect(console.log).toHaveBeenCalledWith('Hello World!');
});

Mit yarn test kann ich jetzt meinen Code testen.

Das Paket ist fertig!

9. Das Package verwenden

Schließlich versetze ich mich in die Position eines Entwicklers, der den großartigen Greeter verwenden möchte. Er muss in seinem Projekt eigentlich nicht viel tun:

  1. Er muss das Paket als Dependency verwenden. Da das Paket aber gar nicht bei npm liegt, sondern in einem Verzeichnis auf meiner Festplatte sieht das so aus:

      "dependencies": {
        "ts-package-skeleton": "../ts-package-skeleton"
      },
  2. Er muss in seinem TypeScript-Code den Greeter importieren und benutzen:

    import { Greeter } from "ts-package-skeleton";
    const greeter = new Greeter('World');
    greeter.greet((greeting) => console.log(greeting));

Das Paket wird erfolgreich genutzt. Ich habs getestet. Viel Spaß beim erstellen eines wiederverwendbaren Pakets.