28 — TypeScript Essentials
Q1. A Coordinate class is passed to a function expecting a Point interface, without ever declaring implements Point. What happens?
interface Point {
x: number;
y: number;
}
class Coordinate {
constructor(public x: number, public y: number) {}
}
function distanceFromOrigin(p: Point): number {
return Math.sqrt(p.x ** 2 + p.y ** 2);
}
const c = new Coordinate(3, 4);
distanceFromOrigin(c);
- A compile error, because
Coordinatenever declaresimplements Point - It compiles and runs fine — TypeScript's structural typing considers
Coordinateassignable toPointbecause it has the same shape (anxandyof typenumber), regardless of any explicit relationship between the two types - It compiles but throws a runtime
TypeError, sinceCoordinateandPointare unrelated types - It only compiles if
Coordinateis renamed toPoint
Show Answer
Answer: B — It compiles and runs fine — TypeScript's structural typing considers Coordinate assignable to Point because it has the same shape (an x and y of type number), regardless of any explicit relationship between the two types
Explanation: TypeScript uses structural ("duck") typing — compatibility is determined by comparing the shape (member names and types) of two types, not by their declared names or explicit inheritance relationships. Since Coordinate has x: number and y: number, it structurally satisfies Point even though it never says implements Point. Contrast this with nominal typing systems like Java/C#, where a class must explicitly declare implements Point (or extend it) to be assignable — shape alone isn't enough there. Option A assumes nominal-typing rules that don't apply to TS. Option C is wrong because type checking is entirely a compile-time construct (see Q10 on type erasure) — there's no runtime shape check that could throw; if it type-checks, it runs like plain JS. Option D is a non-sequitur; renaming has nothing to do with structural compatibility.
Q2. A codebase defines a Shape interface and a class hierarchy (BaseShape → Circle) implementing it. A teammate then passes a plain object literal — never part of that hierarchy — into a function typed to accept Shape. Why does this compile?
interface Shape {
area(): number;
}
abstract class BaseShape implements Shape {
abstract area(): number;
}
class Circle extends BaseShape {
constructor(private radius: number) { super(); }
area() { return Math.PI * this.radius ** 2; }
}
function totalArea(shapes: Shape[]): number {
return shapes.reduce((sum, s) => sum + s.area(), 0);
}
const adHoc = { area: () => 42 };
totalArea([new Circle(2), adHoc]);
- It compiles cleanly —
Shapeis just an interface describing a required shape (area(): number), and any value satisfying that shape is assignable, whether or not it's ever part of the class hierarchy - It's a compile error —
adHocmust extendBaseShapeto be treated as aShape - It compiles, but
adHoc.area()will beundefinedat runtime since it isn't a real class instance - It compiles only because
BaseShapeis declaredabstract
Show Answer
Answer: A — It compiles cleanly — Shape is just an interface describing a required shape (area(): number), and any value satisfying that shape is assignable, whether or not it's ever part of the class hierarchy
Explanation: This is the same structural-typing rule as Q1, applied to a scenario that specifically tempts a "class hierarchy" mental model — a developer coming from Java/C# might assume that because Circle participates in an explicit implements/extends chain, only members of that hierarchy can be Shapes. But interface Shape only constrains what members must exist (a callable area(): number); a completely unrelated object literal that happens to have that member is just as valid a Shape as any class instance. Options B and D invent nominal-typing rules TS doesn't enforce. Option C is wrong because adHoc.area is a real function — it executes normally and returns 42; nothing is "missing" at runtime just because the object skipped the class hierarchy.
Q3. Inside the if (typeof id === "string") block, what does TypeScript infer the type of id to be, and why is this necessary?
function formatId(id: string | number): string {
if (typeof id === "string") {
return id.toUpperCase();
}
return id.toFixed(2);
}
-
idis stillstring | numberinside the block; TypeScript doesn't narrow based ontypeofchecks -
idbecomesanyinside the block becausetypeofreturns a string at runtime -
idis narrowed tostringinside the block (and implicitly tonumberafter it, sincetoFixedrequires it) — thetypeofcheck acts as a type guard, letting TypeScript prove which branch of the union applies before calling a method unique to that branch - TypeScript throws a compile error because
id.toUpperCase()isn't defined onnumber
Show Answer
Answer: C — id is narrowed to string inside the block (and implicitly to number after it, since toFixed requires it) — the typeof check acts as a type guard, letting TypeScript prove which branch of the union applies before calling a method unique to that branch
Explanation: TypeScript performs control-flow-based type narrowing: a typeof id === "string" check inside an if is recognized as a type guard, so within that branch the compiler narrows id's static type from string | number down to just string, permitting toUpperCase() (a string-only method) without a type error. After the if returns, the compiler also narrows the remaining code to the number branch by elimination, permitting toFixed(). Without this narrowing, calling either method directly on the union type would fail to compile, since neither method exists on both members of the union. Option D describes what would happen without the guard.
Q4. Why does err.field type-check inside the if branch, given that handleError's parameter is typed as the base Error?
class ValidationError extends Error {
constructor(public field: string) {
super(`Invalid field: ${field}`);
}
}
function handleError(err: Error) {
if (err instanceof ValidationError) {
console.log(err.field);
} else {
console.log(err.message);
}
}
- It doesn't type-check —
fieldisn't a member ofError, so this is a compile error regardless of theinstanceofcheck -
instanceofis purely a runtime check with no effect on TypeScript's static types;errstays typed asErrorthroughout, soerr.fieldcompiles only because of an implicitanyfallback -
fieldtype-checks because everyErrorinstance has afieldproperty by default in TypeScript's lib definitions -
instanceof ValidationErroris a recognized type guard, so inside that branch TypeScript narrowserr's static type fromErrordown toValidationError, exposing thefieldproperty that only exists on the subclass
Show Answer
Answer: D — instanceof ValidationError is a recognized type guard, so inside that branch TypeScript narrows err's static type from Error down to ValidationError, exposing the field property that only exists on the subclass
Explanation: Debug: like typeof, instanceof is one of TypeScript's built-in type guards for control-flow narrowing: checking err instanceof ValidationError proves (to the compiler, matching real class-based runtime behavior) that inside that branch err is specifically a ValidationError, not just any Error, so members unique to the subclass (field) become accessible. Option B is wrong — TS does perform static narrowing from instanceof, this isn't dynamic/any-typed access. Option C invents a nonexistent base Error member. Option A ignores narrowing entirely.
Q5. What does greet("") log, and what's the underlying reason?
function greet(name?: string) {
if (name) {
console.log(name.toUpperCase());
} else {
console.log("Hello, stranger");
}
}
greet("");
-
""(empty string) is truthy in JavaScript, so it logs the uppercased (still empty) string -
"Hello, stranger"— a truthiness check likeif (name)treats an empty string as falsy just likeundefined, so passing an empty string (a perfectly valid, non-undefinedargument) is silently routed into the "no name" branch, which is rarely the intent - A compile error, because
nameis optional and TypeScript refuses to call.toUpperCase()on a possibly-undefinedvalue even inside the truthy branch -
undefined, becausegreetwas called with an empty string instead of no arguments
Show Answer
Answer: B — "Hello, stranger" — a truthiness check like if (name) treats an empty string as falsy just like undefined, so passing an empty string (a perfectly valid, non-undefined argument) is silently routed into the "no name" branch, which is rarely the intent
Explanation: Debug: if (name) narrows away undefined, but it narrows based on JavaScript's actual runtime truthiness rules, not just "is it undefined" — and "" is one of JS's falsy values (alongside 0, NaN, null, undefined). So a caller who deliberately passes an empty string (a legitimate string, just an empty one) gets routed into the else branch as if no name were given at all, which is almost never the intent behind if (name). TypeScript's narrowing correctly mirrors this: it narrows name to string in the truthy branch (option C's premise is wrong — it compiles fine), but that says nothing about which runtime value ends up there. The safer, more explicit guard when the intent is "was an argument supplied" is if (name !== undefined).
Q6. What role does the shared kind literal property play here, and why does shape.radius compile inside the "circle" case without a separate typeof/instanceof check?
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
}
}
-
kindis a discriminant (tag) property with a distinct literal type per union member; switching on it lets TypeScript narrowshapeto the exact member type whosekindliteral matches the case, exposing that member's unique properties (radius,side) without any additional guard -
kindhas no special meaning to TypeScript — the code compiles by coincidence becauseradiusandsidehappen to both benumber - TypeScript merges
CircleandSquareinto one type with all properties optional, soshape.radiusis always accessible but may beundefined - The
switchmust be replaced withif (shape.kind === "circle")—switchstatements don't support narrowing in TypeScript
Show Answer
Answer: A — kind is a discriminant (tag) property with a distinct literal type per union member; switching on it lets TypeScript narrow shape to the exact member type whose kind literal matches the case, exposing that member's unique properties (radius, side) without any additional guard
Explanation: Idiom: this pattern — a union of object types that all share one property typed as a distinct literal — is called a discriminated (or "tagged") union. TypeScript specifically recognizes narrowing on that shared literal property, including inside switch statements: matching case "circle" proves shape is specifically the Circle member of the union in that branch, so radius (a property only Circle has) is safely accessible, and likewise side only inside case "square". This is idiomatic TS for modeling variant data (API responses, Redux actions, AST nodes) far more safely than typeof/instanceof-based narrowing when the members aren't classes. Option C is wrong; the union stays a true union, not a merged optional-everything type. Option D is false; switch narrowing on discriminants is a well-supported, common pattern.
Q7. Why does this compile, and what happens if type User = { name: string } were declared twice instead of interface User?
interface User {
name: string;
}
interface User {
age: number;
}
const u: User = { name: "Asha", age: 30 };
- Both
interfaceandtypeare 100% interchangeable in every situation, including repeated declarations - The
interfaceversion is a compile error too — TypeScript doesn't allow redeclaring any named type - TypeScript performs declaration merging on repeated
interfacedeclarations with the same name, combining their members into one type ({ name: string; age: number }); a repeatedtype User = ...alias, by contrast, is a compile error — duplicate identifier — since type aliases cannot be reopened -
typealiases support merging butinterfacedeclarations don't
Show Answer
Answer: C — TypeScript performs declaration merging on repeated interface declarations with the same name, combining their members into one type ({ name: string; age: number }); a repeated type User = ... alias, by contrast, is a compile error — duplicate identifier — since type aliases cannot be reopened
Explanation: Interfaces and type aliases are largely interchangeable for describing object shapes, but they differ in one important way: declaration merging. Multiple interface declarations sharing the same name in the same scope are automatically merged into a single interface containing all their members — a mechanism TS itself relies on for things like augmenting global types (e.g., extending Window). Type aliases have no equivalent; a second type User = { age: number } in the same scope is a hard compile error (Duplicate identifier 'User'), because a type alias is a fixed name bound once, not an open, extensible declaration. Option D reverses the actual rule.
Q8. What's the key compile-time difference between processA and processB?
function processA(value: any) {
value.toUpperCase();
}
function processB(value: unknown) {
value.toUpperCase();
}
- There's no difference — both compile and behave identically
-
processBcompiles andprocessAdoesn't, becauseunknownis more permissive thanany - Both fail to compile, since neither
anynorunknownallow method calls -
processAcompiles becauseanyopts the value out of type checking entirely, so any member access is allowed (and unverified) —processBfails to compile becauseunknownrequires the value's type to be narrowed (e.g., viatypeof) before any property or method can be accessed on it
Show Answer
Answer: D — processA compiles because any opts the value out of type checking entirely, so any member access is allowed (and unverified) — processB fails to compile because unknown requires the value's type to be narrowed (e.g., via typeof) before any property or method can be accessed on it
Explanation: Safety: any disables TypeScript's type checking for that value completely — every property access, method call, and assignment is allowed without verification, which defeats the purpose of using TypeScript at all for that value. unknown is the type-safe counterpart: it can hold any value (just like any), but the compiler refuses to let you do anything with it — call a method, access a property, use it as an argument — until you've narrowed it to a more specific type via typeof, instanceof, a custom type guard, or an assertion. processB fails to compile ('value' is of type 'unknown') precisely because that safety net is doing its job — that's a feature, not a limitation.
Q9. The console.log(data.name) line fails to compile. What's the idiomatic fix, and why is typing getUser's return as unknown (rather than any) the better design choice here?
async function getUser(id: string): Promise<unknown> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
const data = await getUser("42");
console.log(data.name);
- Narrow
datafirst — e.g.if (typeof data === "object" && data !== null && "name" in data), or validate it against a schema — before accessing.name; typing external/untrusted data (like a parsed JSON API response) asunknownforces every consumer to verify its actual shape before use, catching malformed responses at the access site instead of letting bad data silently flow through the app asany - Change the return type to
anysodata.namecompiles without any extra work - Add a non-null assertion:
data!.name - Cast to
anyat the call site only:(data as any).name
Show Answer
Answer: A — Narrow data first — e.g. if (typeof data === "object" && data !== null && "name" in data), or validate it against a schema — before accessing .name; typing external/untrusted data (like a parsed JSON API response) as unknown forces every consumer to verify its actual shape before use, catching malformed responses at the access site instead of letting bad data silently flow through the app as any
Explanation: Safety: data genuinely could be anything at runtime — the API could return malformed JSON, an error object, or a shape that doesn't match assumptions — so unknown is the honest, safe type for it. Options B and D "solve" the compile error by disabling type checking entirely (any), which reintroduces exactly the class of bug unknown exists to prevent: if the API's shape changes, .name silently returns undefined or throws deep inside unrelated code instead of failing where the assumption was made. Option C's non-null assertion (see Q16) only suppresses a null/undefined check — it does nothing about data's completely unknown shape. The idiomatic pattern is to narrow (a type guard, an in check, or a runtime validation library) right where the unknown value first enters typed code, converting it into a trusted type from that point forward.
Q10. Why does typeof value === "ApiResponse" fail to do what the function name suggests, and what will typeof value actually ever evaluate to?
interface ApiResponse {
status: number;
body: string;
}
function isApiResponse(value: unknown) {
return typeof value === "ApiResponse";
}
-
typeof valuecan only ever be one of JavaScript's runtime type tags ("string","number","object","function", etc.) — it can never equal"ApiResponse", becauseinterface/typedeclarations are a compile-time-only construct that TypeScript erases completely when generating JavaScript; there is no runtime trace ofApiResponseleft to check against - It works correctly — TypeScript compiles interface names into runtime string tags that
typeofcan check against - This is a compile error, since
typeofcannot be used inside a function body -
typeof value === "ApiResponse"works, but only in strict mode
Show Answer
Answer: A — typeof value can only ever be one of JavaScript's runtime type tags ("string", "number", "object", "function", etc.) — it can never equal "ApiResponse", because interface/type declarations are a compile-time-only construct that TypeScript erases completely when generating JavaScript; there is no runtime trace of ApiResponse left to check against
Explanation: Debug: TypeScript's type system exists purely to catch errors during compilation — every interface, type alias, and generic parameter is erased entirely when TS is transpiled to plain JavaScript, leaving zero runtime footprint or metadata. Because of this, JavaScript's typeof operator (which reflects real runtime type tags: "string", "number", "boolean", "object", "function", "undefined", "symbol", "bigint") has no way to know about interface names — ApiResponse never existed at runtime to compare against, so this condition is always false. To actually check "does this value look like an ApiResponse at runtime," you need a real runtime type guard that inspects the object's actual properties, e.g. typeof value === "object" && value !== null && "status" in value && "body" in value — reconstructing at runtime what the type system already knew at compile time.
Q11. What does this log, and what's the practical difference between timeout?: number and timeout: number | undefined?
interface Settings {
timeout?: number;
}
const s1: Settings = {};
const s2: Settings = { timeout: undefined };
console.log("timeout" in s1, "timeout" in s2);
-
true true— an optional property always exists on the object, just possibly holdingundefined -
false false— neither object has atimeoutkey set to anything -
false true—timeout?: numbermeans the property itself may be entirely absent (which is what happens fors1), whereas explicitly assigningtimeout: undefined(allowed because the property's value type includesundefined) actually creates the key with anundefinedvalue, whichinreports as present -
true false— the reverse of the above
Show Answer
Answer: C — false true — timeout?: number means the property itself may be entirely absent (which is what happens for s1), whereas explicitly assigning timeout: undefined (allowed because the property's value type includes undefined) actually creates the key with an undefined value, which in reports as present
Explanation: A ?-marked property means the key is optional — it may or may not exist on the object at all. s1 = {} never sets the key, so "timeout" in s1 is false. s2 = { timeout: undefined } explicitly creates the key with the value undefined, so "timeout" in s2 is true, even though timeout reads as undefined either way. This distinction is normally invisible (both read as undefined via s.timeout), but becomes strictly enforced under strict mode's exactOptionalPropertyTypes flag, which disallows explicitly assigning undefined to a ?-optional property specifically because it conflates "not provided" with "provided as undefined" — two states that Object.keys, in, and JSON.stringify all treat differently (a key explicitly set to undefined still shows up in Object.keys/for...in, but is dropped by JSON.stringify).
Q12. What's the practical type-safety difference between a and b after these calls?
function identity(x: any): any {
return x;
}
function identityGeneric<T>(x: T): T {
return x;
}
const a = identity(42);
const b = identityGeneric(42);
- None — both
aandbend up typednumber, since42is a number literal in both calls -
bisanytoo, sinceTis just an alias foranyunder the hood - This is a compile error — generic functions cannot be called without explicitly specifying
<T> -
ais typedany(whateveridentityreturns, since it accepts and returnsany), losing all type information about the input —bis typednumber, because the genericidentityGeneric<T>captures the actual argument type (Tis inferred asnumberfrom the call site) and returns that same specific type, preserving type safety without hardcodingnumberanywhere
Show Answer
Answer: D — a is typed any (whatever identity returns, since it accepts and returns any), losing all type information about the input — b is typed number, because the generic identityGeneric<T> captures the actual argument type (T is inferred as number from the call site) and returns that same specific type, preserving type safety without hardcoding number anywhere
Explanation: The any-based identity discards all type information — the compiler has no way to know what identity(42) returns beyond "could be anything," so a is any, and subsequent misuse (a.someMethodThatDoesntExist()) would compile without complaint. identityGeneric<T> instead lets the compiler infer T from the actual argument at each call site — identityGeneric(42) infers T = number, so the return type is genuinely number, not any, preserving full type safety without writing a separate overload for every possible input type. This is exactly why generics exist: reusable, type-safe code instead of reaching for any to make a function "work with anything." Option C is false — TypeScript infers T automatically in the vast majority of cases; explicit <T> is only needed when inference is ambiguous or impossible.
Q13. Why does this fail to compile, and what's the idiomatic fix?
function getLength<T>(item: T): number {
return item.length;
}
- It compiles fine — generics automatically allow any property access
-
Tis unconstrained, so it could be instantiated with any type at all (anumber, aboolean) that has no.lengthproperty; TypeScript rejects the access because it can't provelengthexists on an arbitraryT— the fix is to constrainT, e.g.function getLength<T extends { length: number }>(item: T): number - The fix is to change
Ttoany - Generic type parameters can only be used as function parameters, never in the return type or body
Show Answer
Answer: B — T is unconstrained, so it could be instantiated with any type at all (a number, a boolean) that has no .length property; TypeScript rejects the access because it can't prove length exists on an arbitrary T — the fix is to constrain T, e.g. function getLength<T extends { length: number }>(item: T): number
Explanation: A bare <T> places no restrictions on what T can be — callers could instantiate it with number, boolean, or any other type lacking a length property, so the compiler must reject item.length as unsafe for the general case, even though most real call sites (strings, arrays) do have one. Adding a constraint (T extends { length: number }, or an existing interface like ArrayLike<unknown>) tells the compiler "T can be any type, but only ones that include this member," which both permits item.length inside the function and still preserves T's specific inferred type at each call site — unlike option C's any, which would compile but throw away all type safety, defeating the point of using a generic in the first place.
Q14. Why does x compile with only a name property, but y requires both name and createdAt?
interface Timestamped {
createdAt: Date;
}
interface Named {
name: string;
}
type A = Timestamped | Named;
type B = Timestamped & Named;
const x: A = { name: "widget" };
const y: B = { name: "widget", createdAt: new Date() };
-
A(Timestamped | Named) is a union — a value only needs to satisfy one of the member types, so an object with justname(satisfyingNamed) is valid;B(Timestamped & Named) is an intersection — a value must satisfy all member types simultaneously, so it needs every property from bothTimestampedandNamedat once - This is backwards —
|should require both properties and&should require just one - Both
AandBrequire every property from every member type;|and&are just stylistic aliases for each other -
xfails to compile becauseTimestamped'screatedAtis missing
Show Answer
Answer: A — A (Timestamped | Named) is a union — a value only needs to satisfy one of the member types, so an object with just name (satisfying Named) is valid; B (Timestamped & Named) is an intersection — a value must satisfy all member types simultaneously, so it needs every property from both Timestamped and Named at once
Explanation: | (union) describes "this value is one of these types" — it only needs to be a valid instance of at least one member, so { name: "widget" } alone satisfies Timestamped | Named by matching the Named side. & (intersection) describes "this value is all of these types combined" — structurally, the resulting type has every member from every constituent, so a value must supply createdAt and name to satisfy Timestamped & Named. This mirrors set-theory intuition once you think of each type as the set of values that satisfy it: union is the set union (bigger, easier to satisfy), intersection is the set intersection (smaller, stricter — a value must qualify for every constituent set at once).
Q15. What happens when this code runs?
interface Config {
retries: number;
timeout: number;
}
const raw = JSON.parse('{"retries": 3}');
const config = raw as Config;
console.log(config.timeout.toFixed(0));
- TypeScript's compiler validates
raw's actual shape againstConfigbefore allowing the assertion, so this fails to compile sincetimeoutis missing -
config.timeoutis automatically set to0as a default value, sinceConfigdeclares it asnumber - It compiles cleanly (the
as Configassertion only tells the compiler "trust me, treat this as aConfig" without performing any runtime check), but crashes at runtime with aTypeError: Cannot read properties of undefined (reading 'toFixed'), because the real parsed object has notimeoutproperty - It compiles and runs fine —
JSON.parsealways produces objects matching the asserted type
Show Answer
Answer: C — It compiles cleanly (the as Config assertion only tells the compiler "trust me, treat this as a Config" without performing any runtime check), but crashes at runtime with a TypeError: Cannot read properties of undefined (reading 'toFixed'), because the real parsed object has no timeout property
Explanation: Safety: a type assertion (as Type) is purely a compiler-level instruction — it tells TypeScript "stop inferring, trust that this value is of this type," and performs zero runtime validation or transformation. Since raw came from JSON.parse (whose return type is genuinely any, with no real guarantee about shape), the assertion silently forces the type to Config even though the actual parsed object ({ retries: 3 }) has no timeout key at all. The compiler is satisfied, so it compiles cleanly, but at runtime config.timeout is undefined, and calling .toFixed(0) on undefined throws. This is precisely the class of bug real type checking (via a validation library, or narrowing an unknown value with actual runtime guards) would have caught before the crash — an assertion is a promise to the compiler, not a guarantee it enforces.
Q16. What does the ! after findUser("missing-id") do, and what's the risk?
function findUser(id: string): { name: string } | undefined {
return db.get(id);
}
const user = findUser("missing-id")!;
console.log(user.name);
- It performs a runtime check that throws a clear, descriptive error if the value is
undefined, before.nameis accessed - It converts
undefinedtonullautomatically so.namereturnsnullinstead of throwing - It's equivalent to
?., safely short-circuiting toundefinedifuseris nullish - The non-null assertion operator only suppresses TypeScript's compile-time warning that the value could be
undefined— it inserts no runtime check at all; iffindUsergenuinely returnsundefinedfor this id (as its own signature admits it can),user.namestill crashes at runtime with aTypeError, just without the compiler ever having warned about it
Show Answer
Answer: D — The non-null assertion operator only suppresses TypeScript's compile-time warning that the value could be undefined — it inserts no runtime check at all; if findUser genuinely returns undefined for this id (as its own signature admits it can), user.name still crashes at runtime with a TypeError, just without the compiler ever having warned about it
Explanation: Debug: the ! (non-null assertion) operator is purely a compile-time instruction telling TypeScript "I know this value isn't null/undefined here, stop warning me" — it compiles to literally nothing in the emitted JavaScript, adding zero runtime protection. findUser's own return type ({ name: string } | undefined) admits it can return undefined, and asserting it away with ! doesn't change that reality — it just silences the one warning that would have reminded a developer to handle that case. If "missing-id" genuinely isn't found, user is undefined at runtime and user.name throws exactly the crash the type system was trying to prevent, except now with no compile-time trail pointing at the unsafe access. Option C confuses ! with the optional chaining operator ?., which does add a real runtime short-circuit check — the opposite behavior.
Q17. What's a key practical difference between the enum and the string literal union, especially regarding what ends up in the compiled JavaScript output?
enum Status {
Active,
Inactive,
}
type StatusLiteral = "active" | "inactive";
console.log(Status);
- A numeric
enumlikeStatuscompiles into a real runtime object (roughly{ 0: "Active", 1: "Inactive", Active: 0, Inactive: 1 }) that exists in the output JavaScript and can be inspected/iterated at runtime, whereasStatusLiteralis purely a compile-time type — it's erased entirely, leaving no runtime object, variable, or footprint whatsoever - There's no difference — both
enumand a union of string literals are purely compile-time constructs that vanish entirely from the emitted JavaScript -
StatusLiteralproduces a runtime object too, just with string keys instead of numeric ones -
enummembers are always strings by default;Status.Activeevaluates to"Active"unless numbers are given explicitly
Show Answer
Answer: A — A numeric enum like Status compiles into a real runtime object (roughly { 0: "Active", 1: "Inactive", Active: 0, Inactive: 1 }) that exists in the output JavaScript and can be inspected/iterated at runtime, whereas StatusLiteral is purely a compile-time type — it's erased entirely, leaving no runtime object, variable, or footprint whatsoever
Explanation: Idiom: unlike almost every other TS-only construct (interfaces, type aliases, generic parameters), enum is one of the few features that isn't purely erased — it compiles to an actual runtime JavaScript object with both forward (Active: 0) and, for numeric enums, reverse (0: "Active") mappings, meaning it has a real bundle-size and runtime cost, and can be accidentally used as a value in ways type aliases never can. type StatusLiteral = "active" | "inactive", by contrast, follows the normal type-erasure rule (see Q10) — it exists purely for the compiler and leaves nothing behind in the emitted JS. This is a major reason idiomatic modern TypeScript often prefers string literal unions over enums for simple tag/status values: no runtime overhead, no reverse-mapping surprises, and values serialize naturally as plain strings. Option D is also factually wrong — unqualified enum members default to auto-incrementing numbers (0, 1, 2, ...), not their name as a string; that requires an explicit string enum (enum Status { Active = "active" }).
Q18. What does this log, given that x is declared readonly?
interface Point {
readonly x: number;
readonly y: number;
}
function mutate(p: Point) {
(p as any).x = 999;
}
const origin: Point = { x: 0, y: 0 };
mutate(origin);
console.log(origin.x);
-
999—readonlyis a compile-time-only constraint enforced by the type checker; it has no runtime representation or enforcement whatsoever, so casting toany(which disables type checking entirely) removes the only thing stopping the mutation, and the underlying plain JS object remains fully mutable -
0—readonlyprevents the mutation from ever taking effect, even through anas anycast, since TypeScript enforces immutability at runtime too - A runtime
TypeErroris thrown, sincereadonlyproperties are implemented as non-writable usingObject.definePropertyunder the hood -
undefined, because reassigning areadonlyproperty deletes it instead of mutating it
Show Answer
Answer: A — 999 — readonly is a compile-time-only constraint enforced by the type checker; it has no runtime representation or enforcement whatsoever, so casting to any (which disables type checking entirely) removes the only thing stopping the mutation, and the underlying plain JS object remains fully mutable
Explanation: Safety: like most of TypeScript's type-level features, readonly exists solely to catch accidental mutation during compilation — origin.x = 999 directly would indeed fail to compile (Cannot assign to 'x' because it is a read-only property). But it compiles to nothing in the emitted JavaScript (no Object.freeze, no defineProperty, no runtime check) — the underlying JS object is exactly as mutable as it would be without readonly at all. Casting through as any (or receiving the object from untyped/JS-authored code TypeScript can't check) removes the compiler's ability to object, and the assignment goes through as ordinary property mutation, logging 999. Genuine runtime immutability requires a separate mechanism like Object.freeze (with its own shallow-only caveat), not the readonly type modifier.
Q19. Under TypeScript's modern strict defaults, this fails to compile. Why, and what's the idiomatic fix?
try {
riskyOperation();
} catch (err) {
console.log(err.message);
}
- It shouldn't fail —
catchclause variables are always typedErrorin TypeScript, soerr.messageis always safe - The fix is to type the parameter explicitly as
catch (err: Error), which TypeScript allows and enforces at runtime - Since TS 4.4,
catchclause variables default to typeunknown(reflecting the reality that JavaScript allowsthrowing any value, not justErrorinstances) rather thanany;err.messagefails becauseunknownrequires narrowing first — the fix is to check the actual shape, e.g.if (err instanceof Error) console.log(err.message); - This is a syntax error;
catchclauses cannot have their variable's type inferred at all
Show Answer
Answer: C — Since TS 4.4, catch clause variables default to type unknown (reflecting the reality that JavaScript allows throwing any value, not just Error instances) rather than any; err.message fails because unknown requires narrowing first — the fix is to check the actual shape, e.g. if (err instanceof Error) console.log(err.message);
Explanation: Idiom: JavaScript places no restriction on what can be thrown — throw "just a string" or throw 42 are both completely legal, so a caught value's real runtime type is never guaranteed to be Error. Older TypeScript defaulted catch variables to any (silently unsafe); modern TypeScript (with useUnknownInCatchVariables, on by default under strict since TS 4.4) types them as unknown instead, correctly reflecting that uncertainty and forcing a narrowing check — typically if (err instanceof Error) — before accessing anything Error-specific like .message. Option B is a common but invalid attempt: TypeScript explicitly disallows annotating a catch variable with anything other than any or unknown, precisely because the compiler cannot guarantee what was actually thrown matches a narrower annotation.
Q20. Shape later gains a third variant, { kind: "triangle"; base: number; height: number }, but the switch in area isn't updated. What's the benefit of the default branch's const exhaustiveCheck: never = shape; line?
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
default:
const exhaustiveCheck: never = shape;
return exhaustiveCheck;
}
}
- Nothing changes —
neveris just a stylistic type with no compile-time effect - It causes a runtime error only when
areais actually called with a triangle shape, not at compile time -
neverbehaves identically tounknownhere — it accepts any narrowed type without complaint - It causes a compile error the moment
triangleis added: inside thedefaultbranch,shapeis now narrowed to the unhandled remaining member ({ kind: "triangle"; ... }), which is not assignable tonever(a type with no valid values) — this deliberately fails compilation and flags the missingcaseat the exact spot it was forgotten, rather than silently returningundefinedat runtime for triangles
Show Answer
Answer: D — It causes a compile error the moment triangle is added: inside the default branch, shape is now narrowed to the unhandled remaining member ({ kind: "triangle"; ... }), which is not assignable to never (a type with no valid values) — this deliberately fails compilation and flags the missing case at the exact spot it was forgotten, rather than silently returning undefined at runtime for triangles
Explanation: Idiom: never is the type with no possible values — nothing (except another never) is assignable to it, which makes it a useful compile-time trick. When every case in the switch is handled, TypeScript's narrowing eliminates every union member by the time control reaches default, so shape's narrowed type there really is never, and the assignment type-checks at zero cost. The moment a new variant (triangle) is added to Shape without a matching case, shape in the default branch narrows to the remaining unhandled { kind: "triangle"; ... } instead of never — and assigning that object to a variable typed never is a compile error, forcing a developer to notice and handle the new case immediately, at compile time, rather than discovering the gap only when a triangle silently returns undefined in production. This "exhaustiveness check" pattern is one of the most valuable idioms discriminated unions unlock over plain object shapes.