Low-Level Design (Object-Oriented Design) Module 9 – One design, six languages
LLD in TypeScript with erasable syntax only
Write a low-level design in TypeScript that Node.js 24 runs as it is: unions instead of enums, declared fields, #private state and checks that hold.
What you will learn
- Model closed sets with union types and discriminated unions instead of enums
- Write classes that run unchanged under Node.js 24 type stripping
- Choose between TypeScript private and JavaScript
- Design identifiers, modules and cleanup that survive type stripping
Before you start
What you’ll learn · Before you start
What you will learn
- Model closed sets with union types and discriminated unions instead of enums
- Write classes that run unchanged under Node.js 24 type stripping
- Choose between TypeScript private and JavaScript
- Design identifiers, modules and cleanup that survive type stripping
Before you start
On this page
A low-level design in TypeScript can run on Node.js 24 exactly as written, with no build step, if it uses only
erasable syntax: TypeScript that becomes plain JavaScript when its type annotations are deleted. Node.js runs a
.ts file by replacing every type with whitespace, a feature that has been stable since Node.js 24.12.0, and it checks
none of the types. Syntax that needs generated JavaScript cannot be deleted, so Node.js refuses it: an enum, a
namespace that holds code, a parameter property such as constructor(private seat: string), and an
import x = require() alias. TypeScript 5.8 added --erasableSyntaxOnly, which makes tsc report the same constructs
as error TS1294 before anything runs. The limits cost a design little. A closed set becomes a union of string
literals, a lifecycle becomes a discriminated union and a table, a class declares its fields and assigns them in its
constructor, and hidden state goes into #private fields that the JavaScript engine enforces.
Every example in this lesson ran on Node.js 24.21.0, and every file type-checks with TypeScript 6.0.3 under --strict
and --erasableSyntaxOnly, except the ones that are meant to fail.
Erasable syntax in one example
One TypeScript file, two tools: Node.js runs it without checking, tsc checks it without running
Text description of the diagram
A flow diagram with one source file at the top, booking.ts, which holds classes, unions, interfaces and branded IDs.
- The path "to run it" goes to node booking.ts on Node.js 24.21.0, then to "types replaced by whitespace, nothing is checked", then to "plain JavaScript runs".
- The dashed path "to check it" goes to tsc --noEmit --erasableSyntaxOnly on TypeScript 6.0.3, then to "type errors, and TS1294 for syntax that needs generated code".
- Both paths also lead to a note: enum, namespace with code, parameter properties and import = alias need generated JavaScript, so both tools refuse them. The line from Node.js is labelled ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX and the line from tsc is labelled TS1294.
Seat classes are a classic closed set. Written as an enum, they are a runtime object that the compiler would have to
generate. Written as an object of constants marked as const, plus a union type derived from it, they are ordinary
JavaScript once the types are gone:
session · enums_session.mjs
import { node, version } from './session.mjs';
// The enum version is refused before anything runs; the erasable version runs as it is.
version();
node('enums/seat_class_enum.ts');
node('enums/seat_class.ts'); Output
# Node.js v24.21.0 $ node enums/seat_class_enum.ts SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode [exit status 1] $ node enums/seat_class.ts [ 'standard', 'premium', 'recliner' ] 5000 15000
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node enums_session.mjs
enums/seat_class_enum.ts
// Seat classes as a TypeScript enum. An enum is a runtime object that the compiler generates,
// so there is nothing Node.js could strip: it refuses the file.
enum SeatClass {
Standard = "standard",
Premium = "premium",
Recliner = "recliner",
}
function surcharge(seatClass: SeatClass): number {
return seatClass === SeatClass.Recliner ? 15000 : seatClass === SeatClass.Premium ? 5000 : 0;
}
console.log(surcharge(SeatClass.Premium)); enums/seat_class.ts
// The same closed set in erasable syntax: an object of constants marked as const, and a union type
// derived from it. Strip the types and plain JavaScript is left.
export const SeatClass = {
Standard: "standard",
Premium: "premium",
Recliner: "recliner",
} as const;
export type SeatClass = (typeof SeatClass)[keyof typeof SeatClass];
// One row per class: tsc refuses this table when a class is missing (satisfies).
const SURCHARGE_PAISE = {
standard: 0,
premium: 5000,
recliner: 15000,
} as const satisfies Record<SeatClass, number>;
export function surcharge(seatClass: SeatClass): number {
return SURCHARGE_PAISE[seatClass];
}
console.log(Object.values(SeatClass), surcharge(SeatClass.Premium), surcharge("recliner")); session.mjs
// Runs TypeScript files the way a terminal shows them: "$ node <file>", then what the program printed. Node.js runs a
// .ts file by stripping its types and checks none of them. When Node.js refuses a file or the program throws, only the
// error line is shown, followed by the exit status.
import { spawnSync } from 'node:child_process';
export function version() {
console.log(`# Node.js ${process.version}`);
}
export function node(file) {
console.log(`$ node ${file}`);
const run = spawnSync(process.execPath, [file], { encoding: 'utf8' });
process.stdout.write(run.stdout);
if (run.status !== 0) {
const lines = run.stderr.split('\n');
console.log(lines.find((line) => /^\w*Error\b/.test(line)) ?? lines.filter(Boolean).at(-1));
console.log(`[exit status ${run.status}]`);
}
return run.status;
} The enum version never starts: Node.js stops at enum SeatClass with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX. The rewrite
keeps what callers used. SeatClass.Premium is still a name, the type SeatClass accepts exactly the three strings,
and surcharge("recliner") type-checks while surcharge("vip") would not. The price table ends in
satisfies Record<SeatClass, number>, so a fourth class added without a price fails the type check.
In a machine-coding round this saves more time than it seems to. A design that runs with node design.ts needs no
bundler, no compile step and no configuration, so the first run happens minutes into the round, and the same files run
in the tests. Keep the type checker in the loop anyway, because Node.js runs code whose types are wrong without a word.
The rest of a TypeScript design follows from the same rule:
- Closed sets and lifecycles are unions of literals plus tables that
tscchecks for a missing state. - Private state goes into
#privatefields, because TypeScript’sprivatedisappears with the other types. - Branded IDs make a
ShowIdand aSeatIdtwo different types, although both are strings at run time. - Modules import types with the
typekeyword, or Node.js looks for them as values that do not exist. - Cleanup that must always happen goes into a
usingdeclaration with a[Symbol.dispose]()method.
Check yourself
3 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Closed sets and lifecycles that tsc checks Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- TypeScript private against #private, measured
- Branded IDs: a ShowId that cannot pass as a SeatId
- Modules under type stripping: import type and file extensions
- Cleanup that cannot be skipped: using and Symbol.dispose
- Practise: rewrite a price list in erasable syntax
- Interview questions
- What is the difference between TypeScript's private modifier and a JavaScript #private field?
- A TypeScript service has 40 enums and parameter properties in most classes, and the team wants to run it directly on Node.js 24 without a build step. How do you plan the migration, and what replaces each construct?
- In a TypeScript booking service, a ShowId was passed where a SeatId was expected, and two customers were given the same seat. How do you design the identifiers so that this cannot compile, and where do you check them at run time?
- A checkout holds seats, charges a card and then confirms. Every hold must end, whether the charge succeeds, is declined or throws. How do you design this in TypeScript on Node.js 24, and what can still leave a seat held?
- Key takeaways
6 runnable examples (book.ts, book_plain_import.ts, booking.ts, check.ts, holds.ts, ids.ts, pricing.ts, seat.ts, seat_states.ts, session) · 3 interview answers · about 17 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Read a complete sample first: Low-level design: parking lot (the model)
References
- Modules: TypeScript (Node.js v24 documentation) (OpenJS Foundation)
- TypeScript 5.8 release notes: the --erasableSyntaxOnly option (Microsoft (TypeScript project))
- TypeScript Handbook: Narrowing (Microsoft (TypeScript project))
- TypeScript Handbook: Classes (member visibility) (Microsoft (TypeScript project))
- TypeScript 4.9 release notes: the satisfies operator (Microsoft (TypeScript project))
- TypeScript 5.2 release notes: using declarations and explicit resource management (Microsoft (TypeScript project))
- TSConfig reference: verbatimModuleSyntax (Microsoft (TypeScript project))
- Private elements (MDN Web Docs) (MDN Web Docs (Mozilla))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress