Eine Funktion fordert ein Timeout in Milliseconds an. Du übergibst 5000. Später fordert eine andere Funktion ein Timeout in Seconds an. Du übergibst 5. Irgendwo dazwischen rufst du setTimeout(duration, callback) auf, und für eine Stunde und dreiundzwanzig Minuten passiert nichts.
TypeScript hilft dir hier nicht. 5000 und 5 sind beide number. Der Compiler erkennt keinen Unterschied zwischen einer Distanz in Meters und einer Distanz in Feet, einer Temperatur in Celsius und einer Temperatur in Fahrenheit, einem Timestamp und einer Duration. Dein Test-Suite fängt das vermutlich auch nicht ab, weil die Mathematik korrekt ist. Die Units sind einfach falsch.
Die Lösung ist, aufzuhören, Units als Documentation zu behandeln, und sie stattdessen als Types zu behandeln.
Warum number der falsche Type für physikalische Größen ist
TypeScript verwendet Structural Typing. Zwei Objekte sind kompatibel, wenn ihre Shapes übereinstimmen. Das ist normalerweise ein Feature, aber für Primitives wie number bedeutet es, dass alle Zahlen austauschbar sind. Eine number ist eine number ist eine number.
Runtime-Checks können Unit-Fehler erkennen, aber sie sind teuer in der Wartung und leicht zu überspringen. Du müsstest jedes Function Argument, jeden API Response, jede Konstante, die in einer anderen Datei definiert ist, validieren. In der Praxis macht das niemand. Die Checks werden zu Comments, und Comments lügen.
Die Alternative ist, die Unit direkt in den Type zu encoden. Zur Compile-Zeit werden Seconds und Milliseconds zu inkompatiblen Types. Multipliziere Meters mit Meters und du erhältst SquareMeters. Addiere Miles zu Kilometers und der Compiler verweigert. Zur Runtime ist der Wert immer noch nur eine Zahl. Es gibt kein Wrapper Object, keine Runtime-Validation, keine Performance-Kosten. Das ist eine Zero-Cost Abstraction.
Wie Phantom Types eine Zahl in eine branded Unit verwandeln
TypeScript unterstützt kein Nominal Typing für Primitives, aber es unterstützt Intersection Types und unique Symbols. Du kannst ein Primitive branden, sodass zwei Brands inkompatibel sind, selbst wenn der zugrundeliegende Wert identisch ist.
Hier ist das Pattern:
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
Die __brand Property existiert zur Runtime nicht. Es ist ein Phantom Type. Er existiert nur im Type System. Aber das reicht aus, um Meters und Kilometers gegenseitig inkompatibel zu machen.
Du kannst nicht versehentlich eine plain number einem branded Type zuweisen. Das ist der Punkt. Du musst explizit eine konstruieren, was dich zwingt, die Unit anzugeben.
Ein funktionierendes Unit-System in TypeScript
Hier ist eine minimale, aber vollständige Implementation, die Construction, Conversion und Arithmetic handhabt.
type Brand<T, B> = T & { readonly __brand: B };
type Meters = Brand<number, "Meters">;
type Kilometers = Brand<number, "Kilometers">;
type Seconds = Brand<number, "Seconds">;
type Milliseconds = Brand<number, "Milliseconds">;
type MetersPerSecond = Brand<number, "MetersPerSecond">;
function meters(value: number): Meters {
return value as Meters;
}
function kilometers(value: number): Kilometers {
return value as Kilometers;
}
function seconds(value: number): Seconds {
return value as Seconds;
}
function milliseconds(value: number): Milliseconds {
return value as Milliseconds;
}
function toMeters(km: Kilometers): Meters {
return meters(km * 1000);
}
function toSeconds(ms: Milliseconds): Seconds {
return seconds(ms / 1000);
}
function toMilliseconds(s: Seconds): Milliseconds {
return milliseconds(s * 1000);
}
function addMeters(a: Meters, b: Meters): Meters {
return meters(a + b);
}
function speed(distance: Meters, time: Seconds): MetersPerSecond {
return (distance / time) as MetersPerSecond;
}
Verwendung:
const d1 = kilometers(5);
const d2 = meters(200);
const t = seconds(10);
// This compiles.
const totalDistance = addMeters(toMeters(d1), d2);
const velocity = speed(totalDistance, t);
// This does not.
const bad = addMeters(d1, d2);
// ^^^ Argument of type 'Kilometers' is not assignable to parameter of type 'Meters'.
const alsoBad = speed(totalDistance, milliseconds(5000));
// ^^^^^ Argument of type 'Milliseconds' is not assignable to parameter of type 'Seconds'.
Der Fehler erscheint dort, wo der Bug eingeführt wird, nicht dort, wo der Wert schließlich verwendet wird. Du musst velocity nicht durch drei Dateien zurückverfolgen, um herauszufinden, dass jemand Milliseconds an einen Seconds-Parameter übergeben hat.
Ableitung von Compound Units aus Base Units
Das Pattern skaliert auf Compound Units. Anstatt MetersPerSecond per Hand zu schreiben, kannst du es aus Base Types mit einem generic Constructor ableiten.
type Per<A, B> = Brand<number, { numerator: A; denominator: B }>;
type Times<A, B> = Brand<number, { left: A; right: B }>;
type MetersPerSecond = Per<Meters, Seconds>;
type SquareMeters = Times<Meters, Meters>;
function per<A, B>(numerator: Brand<number, A>, denominator: Brand<number, B>): Per<A, B> {
return (numerator / denominator) as Per<A, B>;
}
function times<A, B>(left: Brand<number, A>, right: Brand<number, B>): Times<A, B> {
return (left * right) as Times<A, B>;
}
In der Praxis brauchst du vielleicht keine vollständige dimensional analysis. Die meisten Teams erreichen diminishing returns nach etwa einem Dutzend Unit Types. Das Ziel ist nicht, Physik zu modellieren. Das Ziel ist es, die teuerste Kategorie von Bugs zu eliminieren: Die, bei der die Mathematik funktioniert, aber die Units nicht.
Die Trade-offs, die du kennen solltest
Branded Types sind nicht umsonst. Sie kosten Ergonomie.
Jedes Literal muss in einen Constructor gewrappt werden. setTimeout(callback, 5000) wird zu setTimeout(callback, milliseconds(5000)). Das ist mehr Typing. Wenn dein Team bei Constructors inkonsistent ist, endest du mit unsafe Casts, die über die Codebase verstreut sind. Das Pattern funktioniert nur, wenn es jeder verwendet.
Type Inference wird auch noisy. Array Methods und generic Functions können die Brand in Error Messages exposen. Ein plain number[] ist leichter zu lesen als (number & { readonly __brand: "Milliseconds" })[]. Du brauchst vielleicht Type Aliases, um Signaturen lesbar zu halten.
Serialization ist ein weiterer friction point. JSON hat kein Konzept von branded Types. Wenn du einen Meters-Wert over the wire sendest, kommt er auf der anderen Seite als plain number an. Du musst die Brand an der Boundary rekonstruieren. Das ist der richtige Ort dafür, aber es ist extra Code.
Die größte Limitation ist, dass das eine TypeScript-only Technik ist. Wenn dein System Python Services, Go Microservices oder plain JavaScript Consumers enthält, verschwinden die Brands an der Language Boundary. Du brauchst trotzdem Runtime Validation an den System Edges. Branded Types schützen internen TypeScript Code. Sie ersetzen keine Schemas für externe Daten.
Wie du das einführst, ohne dein Team zu nerven
Brande nicht jede Zahl in deiner Codebase. Beginne mit den Parametern, die echte Incidents verursacht haben.
- Identifiziere die letzten drei unit-related Bugs in Production. Suche nach Milliseconds vs Seconds, Currencies in verschiedenen Denominations, Latitude vs Longitude oder Screen Coordinates vs Document Coordinates.
- Brande diese spezifischen Types. Füge Constructors und Conversion Functions hinzu.
- Aktualisiere die Functions, in denen diese Werte verwendet werden. Lass dich vom Compiler führen.
- Füge eine Lint Rule hinzu, die raw
numberfür diese Parameter verbietet.
Brande keine Loop Counters, Array Indices oder Percentages. Die sind dimensionslos. Eine Brand dort hinzuzufügen ist Ceremony ohne Wert.
Wenn du in einer Sprache mit stärkerem Nominal Typing arbeitest, hast du bessere Optionen. Rust-User sollten einen Blick auf das uom-Crate werfen. F# und OCaml haben Units of Measure direkt in den Compiler eingebaut. TypeScripts Structural Type System macht das zu einem Workaround, nicht zu einem First-Class Feature. Aber der Workaround ist gut genug, um echte Bugs zu fangen.
FAQ
Fügt das Runtime Overhead hinzu?
Nein. Die Brand ist ein Compile-Time-only Construct. Nach der Compilation ist meters(100) einfach die Zahl 100. Es gibt kein Wrapper Object, keine extra Property, keinen Runtime Check.
Was ist mit Multiplikation und Division?
Du brauchst explicit Functions oder overloaded Operators. TypeScript unterstützt kein Operator Overloading, also muss distance / time durch eine per()- oder speed()-Function gehen. Das ist verbose, aber das ist auch der Grund, warum der Bug gefangen wird.
Kann ich das mit Third-Party Libraries verwenden?
Nur wenn die Library deinen branded Type akzeptiert. Wenn setTimeout eine number erwartet, kannst du Milliseconds übergeben, weil die Brand eine Intersection mit number ist. Das Gegenteil stimmt nicht. Wenn eine Library eine number zurückgibt, musst du sie explizit branden, bevor du sie als Unit Type verwendest.
Wie handle ich fractional Units wie HalfSeconds?
Verwende die Base Unit und einen Constructor. halfSeconds(1) gibt Milliseconds(500) zurück. Erstelle keine Brand für jede Unterteilung. Halte die Anzahl der Base Units klein.
Hör auf, Unit-Namen in Variablennamen zu schreiben
Eine Variable timeoutInMs zu nennen, ist Documentation. Documentation driftet. Sie timeout: Milliseconds zu nennen, ist ein Type. Types werden enforced.
Das nächste Mal, wenn du ein Issue debuggst und feststellst, dass das Timeout um einen Faktor von Tausend danebenlag, frag dich, ob der Variablenname ausgereicht hat. Das hat er nicht. Encode die Unit in den Type, lass den Compiler die Arbeit machen, und hör auf, dir selbst zu vertrauen, dass du dich daran erinnerst, ob diese bestimmte Function Seconds oder Milliseconds will.