TypeScript's build-time safety disappears entirely at runtime. The JavaScript engine that executes your production code has never heard of your type annotations.
The bug was a financial calculation. An order total displayed correctly in development, passed all unit tests, cleared TypeScript compilation with zero errors, and went to production. Three days later, a customer's invoice showed a total of $1,234,567,890,123,456.800 instead of $1,234,567,890,123,456.734.
The difference was 66 cents. The cause was a double-precision floating-point rounding error. The customer noticed because they were a bank reconciling against a ledger that cared about every cent. The engineers involved were not junior — they understood TypeScript well. What they had not internalized was that TypeScript's number type compiles to JavaScript's Number, which is an IEEE 754 double-precision float, which has a maximum safe integer of 2^53 - 1, which is 9,007,199,254,740,991, well below the IDs and amounts they were working with.
TypeScript is an excellent tool. It is not a runtime. In production, JavaScript data types behave exactly as they did before TypeScript existed, and their mismanagement is a quiet driver of runtime crashes, silent data corruption, and garbage collection pressure that manifests as UI stutters on your users' devices.
This post is about what happens after the build step: how the eight primitive and structural types in JavaScript actually behave in memory, across network boundaries, and at the edge cases that automated tests rarely exercise.
The mechanism: what TypeScript actually guarantees and what it doesn't
TypeScript's type system is entirely erased at compile time. Every type annotation, every interface, every generic constraint — none of it exists in the JavaScript that your browser or edge runtime executes. What you get from TypeScript is a development-time correctness check and a documentation layer. What you do not get is any runtime enforcement of those constraints.
// TypeScript is perfectly happy with this
function calculateTotal(amount: number): number {
return amount * 1.2;
}
// At runtime, JavaScript will execute this with no complaints
// and return a float with potential precision drift
calculateTotal(0.1 + 0.2); // 0.30000000000000004 * 1.2
The TypeScript compiler saw number and number and confirmed they matched. The JavaScript runtime saw IEEE 754 double-precision floating point arithmetic and produced the only result it knows how to produce. Both were correct according to their own rules. The bug lived in the gap between them.
This gap exists for every type boundary where JavaScript's runtime behavior differs from TypeScript's type-level model, and there are several that matter in production.
The real-world cost: three type failures that TypeScript cannot prevent
Number precision drift in financial and high-scale systems
JavaScript's Number type can represent integers exactly up to 2^53 - 1 (9,007,199,254,740,991). Beyond that threshold, integers begin to lose precision. This is not a bug; it is how IEEE 754 double-precision floating point works, and it affects every language that uses the same representation.
The problem in modern frontend applications is that 64-bit database identifiers and financial values regularly exceed this threshold. A Snowflake ID, a Twitter/X post ID, or a high-value financial transaction amount can all be numbers that JavaScript cannot represent exactly as a Number.
// Safe integer limit
Number.MAX_SAFE_INTEGER; // 9007199254740991
// Beyond the limit — precision is lost
9007199254740992 === 9007199254740993; // true — they're the same number to JavaScript
9007199254740992 + 1; // 9007199254740992 — the increment is silently dropped
BigInt solves the precision problem but creates a new architectural constraint: BigInt values cannot be mixed with Number values in arithmetic operations, and they cannot be serialized by JSON.stringify.
const id = BigInt('9007199254740993');
JSON.stringify({ id }); // TypeError: Do not know how to serialize a BigInt
This crash does not happen in TypeScript's type checker. It happens at runtime, when a real payload hits a real serializer. The fix is an explicit ingress boundary: all large integers enter the system as strings at the API layer, are converted to BigInt only for computation, and are serialized back to strings for network transport.
// Ingress boundary — string comes in from API
const rawId = response.data.id; // "9007199254740993" (string)
const safeId = BigInt(rawId); // BigInt for computation
const outgoing = safeId.toString(); // string for serialisation
Null versus undefined: the API contract that breaks database records
null and undefined are distinct values that JavaScript treats differently. TypeScript's type system represents both, but the semantic contract between them—what each one means in terms of application intent—is rarely enforced at the type level, and the consequences of conflating them are worst precisely where the stakes are highest: partial update operations against a database.
undefined means a value was never provided. An omitted key in a JSON payload is undefined; the field was not included in the request, which should mean "do not change this field." null is an intentional explicit absence—the field was included in the request with a value of null, which should mean "clear this field."
// These are semantically completely different operations
const patchA = { name: 'Alice', email: undefined };
// Should mean: update name, leave email alone
const patchB = { name: 'Alice', email: null };
// Should mean: update name, explicitly clear email
When an API layer or ORM conflates the two, treating both as "empty" and applying the same operation, patchA deletes the user's email address when it was only supposed to update their name. This bug passes TypeScript compilation. It passes most unit tests, because unit tests rarely mock the database layer with enough fidelity to catch the distinction. It surfaces in production when a user reports that data they never touched was overwritten.
The fix is an enforced convention at the API contract level: document and lint for null as explicit deletion and undefined (omitted key) for all patch operations, and validate incoming payloads against this contract using a runtime schema validator zod, valibot, or a custom boundary check—rather than relying on TypeScript types that vanish at runtime.
Object creation pressure and garbage collection in high-frequency paths
Primitives: string, number, boolean, symbol, bigint, null, undefined are stack-allocated and immutable. When you use a primitive, the engine copies its value. When you use an object, including arrays, functions, maps, and any non-primitive, the engine allocates heap memory and copies a reference.
This distinction matters in high-frequency execution paths. Code that runs once on page load can create objects freely. Code that runs on every scroll event, every animation frame, or every item in a large render list creates garbage at a rate that can visibly impact UI performance.
// High-frequency path — creates a new object on every call
items.forEach(item => {
const { id, name, value } = item; // object destructuring allocates
render({ id, name, value }); // new object literal allocates
});
// Optimised — passes the reference, no new allocation
items.forEach(item => {
render(item); // same reference, no garbage created
});
The garbage collector eventually reclaims the abandoned objects from the first version. The problem is that when it runs, the GC pauses JavaScript execution to collect, and in a tight rendering loop, this pause manifests as a frame drop. On low-end mobile devices with constrained heaps, the GC runs more frequently, making the stutter worse exactly where your users can least afford it.
The same concern applies to Symbol. Teams that need to attach hidden metadata to objects often resort to string keys with naming conventions (__internalId__, _private_ref). String keys are visible to JSON.stringify, visible to Object.keys, and subject to collision if a third-party library uses the same convention. Symbol keys are non-enumerable, non-serializable, and guaranteed unique—exactly the right tool for private metadata that should not leak across library boundaries.
The fix: three runtime type safety patterns
Numeric ingress boundaries
Every payload that crosses a network boundary is a string. Treat it as one at the point of entry and convert to the appropriate numeric type explicitly:
import { z } from 'zod';
const OrderSchema = z.object({
id: z.string(), // 64-bit ID — stays as string
amount: z.string().transform(v => BigInt(v)), // monetary — convert to BigInt
quantity: z.number().int().safe(), // small integer — Number is fine
});
type Order = z.infer<typeof OrderSchema>;
The zod schema runs at runtime, not compile time. It validates the actual shape and values of the incoming data, something TypeScript cannot do.
Explicit null/undefined API convention with runtime enforcement
Define the contract once and enforce it at the boundary:
// Convention: in PATCH payloads, undefined = omit, null = clear
type PatchPayload<T> = {
[K in keyof T]?: T[K] | null;
};
function applyPatch<T extends object>(record: T, patch: PatchPayload<T>): T {
const result = { ...record };
for (const key of Object.keys(patch) as (keyof T)[]) {
if (patch[key] === null) {
delete result[key]; // explicit clear
} else if (patch[key] !== undefined) {
result[key] = patch[key] as T[typeof key]; // explicit update
}
// undefined keys are not processed — no change
}
return result;
}
This makes the semantic distinction mechanical rather than conventional — the code enforces the contract at runtime regardless of what TypeScript's types say.
Object reuse in hot paths
Profile before optimizing, but know the pattern:
// Pre-allocate and reuse in high-frequency paths
const renderBuffer = { id: 0, name: '', value: 0 };
function renderItems(items: Item[]) {
for (let i = 0; i < items.length; i++) {
renderBuffer.id = items[i].id;
renderBuffer.name = items[i].name;
renderBuffer.value = items[i].value;
renderToCanvas(renderBuffer); // same reference every iteration
}
}
This is a micro-optimization; apply it only to paths your profiler confirms are GC-pressured, not everywhere.
Key takeaway
TypeScript guides you to write correct code. It does not execute your code. The JavaScript runtime that runs in your users' browsers does not know about your type annotations; it knows IEEE 754 floats, heap-allocated objects, and the eight primitive types that have governed JavaScript since 1995.
Senior engineering requires holding both models simultaneously: TypeScript's type-level model for development-time correctness and JavaScript's runtime model for production-time safety. The bugs that production data corruption and runtime crashes produce are almost always in the gap between the two values that were correctly typed at compile time but incorrectly handled at the boundary where TypeScript's guarantees end and JavaScript's runtime behavior begins.
Your build passes. Your tests pass. Your TypeScript is clean. None of that tells you what happens when a 64-bit identifier hits JSON.stringify, or when a null patch overwrites a field your user never touched.
What to audit this week
Find unsafe numeric handling:
# Find Number() conversions that could lose precision on large values
grep -rn "Number(" src/ | grep -v "// safe"
# Find JSON.stringify calls that might receive BigInt values
grep -rn "JSON.stringify" src/
Find null/undefined conflation in patch operations:
# Find PATCH/PUT calls to check their payload construction
grep -rn "method.*PATCH\|method.*PUT" src/
Find object creation in render loops:
# Find destructuring inside .map() or .forEach() — highest-risk pattern
grep -rn "\.map(\|\.forEach(" src/ | grep "const {"

Top comments (1)
Never, ever use floating point for financial stuff. Rookie mistake