Die Menge der Programmiersprachen-Features, die absolut radioaktiv sind und die niemand jemals benutzen sollte, ist meiner Überzeugung nach kleiner als viele glauben. Egal ob JavaScript-Features aus der Jungsteinzeit oder any in TypeScript, ich persönlich greife sehr gerne in die Mottenkiste, wenn es hilft, das aktuelle Problem zu lösen. Natürlich gehört zum Einsatz dieser... kontroversen Features immer auch der eine oder andere Safeguard, damit die immer auch vorhandenen negativen Aspekte der jeweiligen Features eingehegt werden. Aber selbst allgemein akzeptierte Sprachfeatures, selbst manche der komplett abgefeierten, haben Safeguard-Bedarf, denn auch sie können negative Auswirkungen haben. Das gilt unter anderem auch für unknown in TypeScript.

Selbstdisziplinierung mit unknown

Dem TypeScript-Typ unknown kann jeder andere Typ zugewiesen werden, aber er ist selbst nicht direkt benutzbar. Sinn und Zweck von unknown ist meist das Erzwingen eines Typechecks, wie z.B. im folgenden Beispiel:

function isString(input: unknown): boolean {
  return typeof input === "string";
}

In die Funktion isString() können wir jeden denkbaren Wert hineinstecken, denn einem Parameter von Typ unknown ist jeder andere Typ zuweisbar. Innerhalb der Funktion können wir aber mit input nichts anderes tun, als seinen eigentlichen Typ zu überprüfen (Type Narrowing, per Typcheck oder Vergleich) - andere Operationen sind mit unknown nicht zulässig. Im Prinzip würde als Parameter-Typ auch any funktionieren, denn input kann buchstäblich alles Mögliche sein. Allerdings passt in any nicht nur jeder Wert hinein, sondern mit any ist auch jede Operation möglich! Das bedeutet, dass wir versehentlich Fehler auslösen könnten:

function containsFooNumber(obj: any): boolean {
  return typeof obj.foo === "number"; // nachlässiger Typcheck nimmt an, dass obj nicht null/undefined ist
}

containsFooNumber({ foo: 42 }); // ok - true
containsFooNumber({ foo: "a" }); // ok - false
containsFooNumber({ bar: null }); // ok - false
containsFooNumber(undefined); // RUNTIME-FEHLER: cannot read "foo" of undefined

Tauschen wir any gegen unknown, kann die Funktion weiterhin mit allem möglichen Input gefüttert werden, doch wir sind gezwungen, die Funktion selbst umzuschreiben - der Zugriff auf obj.foo ist nur erlaubt, wenn wir sicherstellen, dass obj nicht null oder undefined ist:

// Nicht von TS akzeptiert
function containsFooNumber(obj: unknown): boolean {
  return typeof obj.foo === "number"; // TS: obj.foo geht nicht (obj ist unknown, d.h. ggf. null/undefined)
}

// Nur so funktioniert's
function containsFooNumber(obj: unknown): boolean {
  return (
    typeof obj === "object" &&
    obj !== null &&
    "foo" in obj &&
    typeof obj.foo === "number"
  );
}

Zusammengefasst können wir also sagen:

  • any kann alles zugewiesen werden und mit any kann alles gemacht werden
  • unknown kann alles zugewiesen werden und mit unknown kann nichts gemacht werden, außer es any/unknown zuzuweisen oder es in einen anderen Typ zu überführen

Als Selbstdisziplinierungsmaßnahme für die Autoren von Funktionen, die ihren Input einem Runtime-Typecheck unterziehen müssen/wollen, ist unknown also sehr nützlich. Aber welche Funktionen sind das eigentlich?

TypeScripts blinde Flecken

Wer TypeScript-Fans trollen möchte, kann ganz gut argumentieren, dass das gesamte Typsystem und all seine Features nur eine kollektive Illusion sind. Schließlich existieren TypeScript-Typen nur so lange, bis der Compiler aus den .ts-Dateien ganz normale .js-Dateien macht, in denen von TS nichts mehr zu sehen ist. Sobald es ernst wird und der Code tatsächlich laufen muss, sind sämtliche Typechecks nicht mehr vorhanden und theoretisch könnte es allerlei Fehler geben!

Darauf folgend müssen wir natürlich fragen, ob zu diesem Zeitpunkt noch Code existiert, der in Abwesenheit von Typechecks noch ein Problem auslösen kann. Ist das gesamte Projekt von vorn bis hinten in TypeScript geschrieben, entsteht schließlich nur Code, der alle Anforderungen des Typsystems erfüllt. Die Hürden des Typsystems werden zwar vom Compiler bei der Übersetzung von .ts nach .js abgebaut, aber es ist vorher nie Code entstanden, der in Abwesenheit dieser Hürden Fehler auslösen könnte. Die Typechecks sind zwar nicht mehr da, aber es ist kein Code entstanden, der diese Lücken ausnutzen könnte.

Das Problem hieran: kaum ein Projekt ist wirklich von vorn bis hinten in TypeScript geschrieben und selbst 100%-TS-Projekte müssen mit Systemen interagieren, die keine Typechecks haben. Dazu gehören:

  • Code ohne Typen, z.B. Dependencies mit schluderigem TS-Support oder eigener Code mit zu viel any
  • API-Endpunkte und Datenbanken, denn HTTP oder SQL werden nicht von TypeScript überprüft. Und selbst wenn die APIs oder Queries in TS geschrieben sind oder Typdefinitionen dafür gebaut/generiert wurden, entstehen die tatsächlichen Daten meist nicht unter den exakten Annahmen des angeflanschten Typsystems. Ein toll getypter HTTP-Endpunkt ist am Ende des Tages doch nur ein anderer Computer, auf dem alles mögliche los sein könnte.
  • JSON-Payloads z.B. aus LocalStorage könnten von alten Programmversionen erzeugt oder von Nutzern, Browser-Extensions oder sonstigen Dritten verändert worden sein.
  • Funktionsaufrufe von Dritten, die ggf. JavaScript statt TypeScript benutzen oder etwas zu freizügig any benutzen. Das ist besonders relevant, wenn das Projekt eine Library für den Einbau in anderen Projekten ist.

All diese blinden Flecken sind der Anlass, den Typ-Aluhut aufzusetzen und gründliche Runtime-Typchecks durchzuführen. Vertrauen ist gut, Kontrolle ist besser! Und zum Zweck der Selbstkontrolle verwenden wir unknown. Das könnte wie folgt aussehen:

type Options = {
  foo: number;
};

// Fehlschlagender Runtime-Typcheck wirft einen Fehler
function checkOptions(options: unknown): asserts options is Options {
  if (
    !options ||
    typeof options !== "object" ||
    !("foo" in options) ||
    typeof options.foo !== "number"
  ) {
    throw new TypeError("Runtime type check failed");
  }
}

// Öffentliche Funktion
export function publicFunction(options: unknown): void {
  // options hat hier den Typ "unknown"
  checkOptions(options);
  // options hat ab hier Typ "Options"
}

Unsere publicFunction() ist für den Einsatz durch Dritte gedacht und Dritten ist nicht zu trauen. Mit unknown zwingen wir uns innerhalb von publicFunction() zum Typecheck via checkOptions() und stellen damit zu 100% sicher, dass wir den options-Parameter erst anrühren, wenn wir sicher wissen, dass er exakt enthält, was wir erwarten.

Alles gut? Mitnichten! Denn falls Benutzer von publicFunction() TypeScript statt Vanilla JS verwenden, haben wir ihnen durch den Einsatz von unknown das Leben soeben schwerer statt leichter gemacht.

Die zwei Seiten von unknown

Einer der größten Vorteile von TypeScript ist die smarte Autovervollständigung, die uns z.B. bei einem Funktionsaufruf verrät (und überprüft), welche Parameter welchen Typ brauchen. Das Problem mit Funktionsparametern vom Typ unknown ist, dass dieses Feature uns dann auch tatsächlich unknown anzeigt:

Die Autovervollständigung zeigt an, dass ein Funktionsparameter vom Typ 'unknown' ist

Das ist zwar rein technisch korrekt, aber absolut nicht hilfreich. Die Funktion sollte definitiv mit Options gefüttert werden – unknown ist eine reine Vorsichtsmaßnahme! Die Vorsichtsmaßnahme verbirgt aber nun den eigentlichen Soll-Typ vor der Autovervollständigung. Und schlimmer noch: es gibt in der IDE nun auch keine keinerlei Typchecks mehr:

Eine TypeScript-Funktion akzeptiert einen offensichtlich zu einem Laufzeit-Fehler fürenden Wert, da er zu 'unknown' passt

Der Funktionsaufruf in Zeile 24 ist offensichtlich falsch und wird offensichtlich in einem Runtime-Fehler enden, doch der Editor sagt uns das nicht voraus – und das, obwohl der korrekte Typ nur wenige Zeilen vorher ordentlich definiert wurde.

Der Einsatz von unknown sorgt also im Endeffekt dafür, dass die Autoren von publicFunction() zwar vom Typsystem zur Durchführung eines Runtime-Typchecks angehalten werden, andererseits haben die Benutzer von publicFunction() keine sinnvolle Autovervollständigung mehr und auch keinerlei Typchecks (denn unknown kann jeden Wert zugewiesen bekommen). Anders gesagt: bei einer Funktion, die unknown als Parameter-Typ hat, profitieren die Autoren der Funktion von mehr Typsicherheit (sie können mit dem Parameter keinen Blödsinn anstellen), die Benutzer der Funktion haben praktisch gar keine Typsicherheit mehr. Das ist alles logisch und nachvollziehbar, aber alles andere als akzeptabel.

Die Lösung: unknown aus aufrufbaren Signaturen verbannen!

Meine Schlussfolgerung aus dem beschriebenen Problem mit unknown ist, dass Funktionen mit unknown als Parameter ein extrem heißer Kandidat für einen Overload sein sollten:

export function publicFunction(options: Options): void;
export function publicFunction(options: unknown): void {
}

Ein Overload einer Funktion ist in TypeScript eine alternative Funktionssignatur. Die eigentliche Funktionssignatur beschreibt die Implementierung, die Overloads beschreiben die (ggf. vielen verschiedenen) Aufruf-Signaturen, die durch die Implementierung umgesetzt werden. Hierbei können die Overloads durchaus restriktivere Signaturen bereitstellen, als die Implementierung eigentlich unterstützen würde:

function addOrConcat(a: string, b: string): string;
function addOrConcat(a: number, b: number): number;
function addOrConcat(a: bigint, b: bigint): bigint;
function addOrConcat(a: string | number | bigint, b: string | number | bigint): string | number | bigint {
  if (typeof a === "string" || typeof b === "string") {
    return String(a) + String(b);
  }
  if (typeof a === "bigint" || typeof b === "bigint") {
    return BigInt(a) + BigInt(b);
  }
  return a + b;
}

addOrConcat() hat dank seiner Overloads drei Signaturen, die aufgerufen werden können:

  1. (string, string) => string
  2. (number, number) => number
  3. (bigint, bigint) => bigint

Die Implementierung würde, reiner JavaScript-Logik folgend, auch andere Aufrufe wie etwa (string, bigint) => string unterstützen, aber die Overloads bieten nur die drei obigen Signaturen an; der theoretische Aufruf von (string, bigint) => string wird vom Compiler nicht akzeptiert. Dieser Aufruf würde zwar auf die Implementierungssignatur der Funktion passen, doch diese ist gewissermaßen privat und nur innerhalb der Funktion für die lokalen Typen von a und b relevant.

Das bedeutet für unsere publicFunction(), dass wir zeitgleich eine Implementierungssignatur mit unknown und eine Aufrufsignatur mit Options haben können! Innerhalb von publicFunction() zwingen wir uns zur Runtime-Überprüfung der Parameter, Aufrufende können diese Überprüfung zur Entwicklungs-Zeit vom Typsystem machen lassen:

export function publicFunction(options: Options): void;
export function publicFunction(options: unknown): void {
  // Hier ist options "unknown"
}

publicFunction(/* hier ist options "Options" */);

Wer TS nutzt, hat die erwartete Developer Experience, wer TS nicht nutzt (oder zu viel any verwendet), wird über Fehler erst (aber auch sicher) zur Laufzeit informiert. Alle Parteien haben die aus ihrer jeweiligen Perspektive korrekten Typchecks und die maximal mögliche Unterstützung ihrer IDE. Win-Win!

Bedingungen für unknown-Overloads

Es versteht sich von selbst, dass nicht jede Funktion mit unknwon einen Overload mit einem anderen Typ braucht. Zunächst mal braucht es für einen solchen Overload überhaupt einen passenderen Typen – und der ist nicht immer gegeben. Reine Typ-Überprüf-Funktionen haben prinzipbedingt unbekannte Typen als Parameter:

// der Parameter-Typ ist prinzipbedingt unbekannt
function isString(input: unknown): boolean {
  return typeof input === "string";
}

Zweitens ist es für einen Overload erforderlich, dass überhaupt jemand von dem Overload profitieren kann. Und das ist nicht der Fall, wenn die unbekannten Daten aus einem der erwähten blinden Flecken von TypeScript stammen:

function getSomeDataFromSomewhere(): unknown {
 // unwichtig
}

function checkAndProcessData(input: unknown): void {
  // unwichtig
}

function main(): void {
  checkAndProcessData(getSomeDataFromSomewhere());
}

Der unbekannte Input von checkAndProcessData() ist, ähnlich wie bei Typ-Überprüf-Funktionen, prinzipbedingt unbekannt, wenngleich er theoretisch einen bestimmten Typ haben sollte. Wir verwenden unknown allein, weil der Datenquelle getSomeDataFromSomewhere() nicht zu trauen ist, da diese ihre Daten aus einem von TypeScript blinden Flecken bezieht. Wir sparen uns an dieser Stelle den Overload, da es keinen menschlichen Nutzer gibt, der jemand davon profitieren könnte. Der Input für checkAndProcessData() kommt immer direkt aus getSomeDataFromSomewhere(), ist aus Vorsichtsgründen immer unknown, und wird niemals von Hand ausgeschrieben. Niemandes Autovervollständigung ist durch dieses unknown jemals beeinträchtigt.

Fazit

In der Hauptsache sind Overloads für Funktionen mit unknown-Parameter etwas für öffentliche Funktionen mit potenziellen menschlichen Nutzern. Das betrifft vor allem Libraries, aber auch API- und Service-Endpunkte aller Art; alle Funktionen, in denen wir uns als Autoren der Funktion zu gründlichen Runtime-Typchecks animieren möchten, ohne der TypeScript-Nutzerschaft die Developer Experience zu runinieren. unknown allein ist nützlich, hat aber notwendigerweise auch zur Folge, dass die Compile-Time-Typechecks für die Benutzer der betroffenen Funktionen kaputtgehen. Um das zu reparieren, brauchen meiner Meinung nach die entsprechenden Funktionen immer eine explizite Aufruf-Signatur ohne unknown und eine Implementierungssignatur mit unknown.