TypeScript interview questions: where the type system stops being true
A senior TypeScript round isn't a vocabulary test. Almost every question in it is the same question in a different costume: do you know where the compiler's guarantees end, and what you do at that line?
Ask someone to describe Partial and you find out whether they've read the handbook. Ask them what happens to their types at runtime and you find out whether they've shipped anything.
The six questions below come up in senior screens constantly, and they're connected. Every one of them stands on the same fact: TypeScript checks your code and then deletes itself, so nothing it promised is present when the program runs. Candidates who've internalised that tend to answer all six well. Candidates who haven't answer the first two and then drift. If you'd rather work through the topic than read about it, the TypeScript track covers the same ground one concept at a time.
Question 1: what type is the JSON you just fetched?
If I had one question, this would be it. It looks like a syntax question. It's a question about trust.
res.json() returns Promise<any>, and any isn't a type. It's a switch that turns the checker off for that value and for everything downstream of it. The value gets destructured, passed to three functions and written to a field, and the compiler raises nothing at any of those points because it was told not to. When the field turns out to be missing in production, the error surfaces four layers away from the boundary it came in through.
The annotation people reach for instead is worse in an interesting way, because it looks careful:
// Both of these compile. Neither one has checked anything.
const user: User = await res.json();
const user2 = (await res.json()) as User;
// unknown keeps the checker on, so the proof has to exist somewhere.
const data: unknown = await res.json();
const user3 = parseUser(data); // returns User, or throws saying why notunknown is the same absence of knowledge as any, but it keeps the checker on. You can hold an unknown and pass it around. You can't read a property off it or call it until you've proved what it is, so the proof has to appear somewhere in the file. That's the whole difference and it's the whole point: any removes the obligation, unknown relocates it.
The follow-up is where you put the boundary. A strong answer says there should be few of them, each one named, and that everything past a boundary is typed honestly because the check happened at it. A weaker answer validates in a scattering of places and can't tell you which ones. Worth saying out loud that this is bigger than fetch: JSON.parse, localStorage, process.env, a message payload and the return of anything from an untyped package are all the same boundary.
Question 2: does a class need to implement an interface to satisfy it?
No, and the reason is what's being tested. TypeScript compares types by shape, not by name. If a value has the members the target wants, it's assignable, whatever it was declared as and whoever wrote it. That's structural typing, and most candidates get that far. The part that sorts them is the exception.
Why does this object literal error when the same object in a variable doesn't?
Junior answer
Because the literal has a property that isn't on the type. Assigning through a variable works because TypeScript infers the wider type from the variable first, so the extra key comes along with it.
Senior answer
Both objects are structurally compatible, so neither should error. The literal gets an extra check the variable doesn't, because a fresh literal written at the assignment site is the one moment the compiler can be confident an unexpected key is a typo rather than a wider object being passed through. It's a deliberate exception to structural typing, aimed mostly at misspelled optional properties, and the freshness is lost as soon as the object has been stored anywhere. That's also why spreading the literal makes the check go away, which is what people do when they want the wider behaviour on purpose.
The follow-up is usually whether structural typing ever causes problems, which is the branded types question underneath.
That follow-up is worth preparing, because the answer is yes and it's specific. A UserId and an OrderId that are both string are interchangeable everywhere, and the compiler will never notice you passing one where the other belongs. If you want nominal behaviour you have to build it, usually by intersecting the string with a tag no runtime value carries. The honest version of the answer includes the cost: every construction site now needs a factory or a cast, so it's worth it for identifiers that get mixed up and not much else.
Question 3: model a request that can be loading, failed, or done
This one usually arrives as a small design task rather than a question, and the two answers are different in a way you can see from across the room.
How would you type the state of an async request?
Junior answer
An interface with loading: boolean, an optional error and an optional data. The component checks loading first, then error, then renders the data.
Senior answer
A union tagged by status, so the three states are three separate shapes: loading carries nothing, failed carries an error, done carries data. The optional-fields version describes eight states and only three of them are real, so every consumer has to handle combinations that can't happen and the compiler makes them, because it has no way to know which ones are impossible. With the union, data doesn't exist on the type until the status has been checked, so reading it isn't a convention any more, it's the only route to the field.
The follow-up is what happens when a fourth state gets added, and the answer they want is that the switch statements stop compiling.
Volunteer that last point rather than waiting for it. If each switch ends in a default branch that assigns the narrowed value to never, then adding a cancelled case turns every site that doesn't handle it into a compile error with a file and a line number. Exhaustiveness is the practical payoff of a discriminated union, and it's the moment the type system stops describing your code and starts doing work for you. It's also a clean example of the gap between a correct answer and a senior one, since both versions of the code run fine today.
Question 4: enum, string literal union, or a const object?
This reads as a taste question. It's a runtime question wearing a costume, and it's the fastest way to find out whether someone knows what survives compilation.
Types are erased. Interfaces, aliases and unions vanish, and the emitted JavaScript holds no trace of them. A TypeScript enum is the exception: it compiles to a real object that ships in your bundle. That single fact explains most of the trade-off.
- A union of string literals costs nothing at runtime and reads as a plain string in a log line, a network payload or a database column. It's the default, and it's the right answer most of the time.
- An enum gives you one place to rename from and a namespace to reach through, at the price of that runtime object.
const enumavoids the object by inlining the values, and breaks under any toolchain that compiles files in isolation, which is now most of them. - A const object with a derived union is the middle path. You keep the values to iterate at runtime, and
typeof Routes[keyof typeof Routes]gives you the union for the type positions. More ceremony, no surprises in the bundle.
If a candidate names only one option and can't say what it costs, the follow-up is what ends up in the bundle. Numeric enums are worth a sentence if you want to sound like you've been bitten: they emit a reverse mapping from value back to name, so the object is bigger than it looks, and for years the compiler would accept any number at all where one was expected.
The live task: type this function generically
Most rounds have one small keyboard moment, and it's usually a function that works on one shape being asked to work on many. A getProperty(obj, key), or a pluck(rows, field). What's being scored isn't whether you land the syntax. It's the order you work in.
- Write or read the concrete version first and say what it does. Abstracting before the shape is understood is where people get lost, and it's visible.
- Replace the specific type with a parameter and stop there.
<T>(obj: T, key: string)compiles and is still wrong, and noticing that out loud is better than not noticing. - Constrain it so the relationship between the arguments is expressed.
keyisn't astring, it'skeyof T, and the return isn'tunknown, it'sT[K]. This is the step being tested. A generic with no constraint is usuallyanyin a hat. - Try to break it in front of them. Pass a key that doesn't exist and confirm the compiler complains. Interviewers want to see the checker used as a tool rather than as a formality.
The syntax is the part you can look up afterwards. The narration is the part that gets scored, and it's the part that's hard to rehearse alone, because the follow-up is the interview.
Question 5: what does satisfies do that an annotation doesn't?
A newer question and a good one, because it separates people who kept up from people who learned TypeScript once and stopped.
An annotation is a constraint that replaces what the compiler inferred. Write const routes: Record<string, string> and you've said the value is a record of strings, so the specific keys you just typed are gone.
const a: Record<string, string> = { home: "/", blog: "/blog" };
a.hme; // string. No error, and no such route.
const b = { home: "/", blog: "/blog" } as const;
b.hme; // Error. But nothing checked that the values are strings.
const c = { home: "/", blog: "/blog" } satisfies Record<string, string>;
c.hme; // Error, and the values were checked.as const goes the other way from an annotation: it freezes the value to its literal types, deep and readonly, so you keep the exact keys but nothing has checked them against a contract. satisfies is the combination. It verifies the value against the type and then leaves the narrow inferred type in place, which is what you want for config objects, route maps, permission tables, and anything you'll index into later.
The senior version of the answer names when a plain annotation is still right: when you genuinely want the wider type, usually because the value is about to be handed to something that shouldn't depend on today's keys.
Question 6: is TypeScript type safe?
A common closing question, and a trap only if you think it wants a yes. TypeScript is deliberately unsound. There are programs the checker accepts that crash, and they aren't oversights, they're documented trades of safety for usability. Naming the places is the answer.
- Arrays are covariant. A
Dog[]is assignable to anAnimal[], and then something pushes aCatinto it. Sound languages refuse this and pay for it in ceremony. - Method parameters are bivariant.
strictFunctionTypesmade function parameters behave as you'd expect, and it deliberately doesn't apply to methods written with the shorthand syntax, because that would breakArrayand much of the standard library. Two declarations that look identical check differently. - Assertions aren't checked.
asis you overriding the compiler, andas unknown as Xis the escape hatch that at least admits what it's doing. Neither one inspects a value at runtime. - Index access lies by default.
arr[10]has typeT, notT | undefined, on an array holding two elements.noUncheckedIndexedAccessfixes it, and it isn't part ofstrict.
Is TypeScript type safe?
Junior answer
Yes, as long as you avoid any and keep strict mode on. Most type bugs get caught at compile time before they reach production.
Senior answer
Not soundly, and on purpose. Array covariance, method bivariance and assertions are all holes the language kept because closing them would make ordinary code painful to write. So what I get isn't a proof, it's a very good smoke alarm, and I aim the strictness at the places where a wrong assumption is expensive: check at the boundaries, keep assertions rare and next to a comment saying what makes them true, and treat a file with several of them as a design problem rather than a style one.
This is the question where answering 'yes' with confidence is the weakest option available.
Common questions
How much TypeScript comes up in a React or Node round?
More than candidates expect, and rarely as a section of its own. It arrives inside another question: how you'd type props and a context in a React round, or how you'd type a request and a response at an API boundary. That second one is usually question one from this article in different clothes.
Do conditional and mapped types come up?
Sometimes, and almost never as 'write one'. The realistic version is reading one, or saying what a utility type does underneath, since Partial and Pick are mapped types and ReturnType is a conditional type using infer. Knowing they're built from two features you could use yourself is worth more than memorising the list.
Is it ever fine to say you'd use any?
Yes, and saying it well is a signal. Reaching for any inside a migration that's half converted, or in a test double where the shape doesn't matter, is a normal engineering call. What gets marked down is any at a boundary, where it isn't a shortcut, it's the check you skipped.
What if a library ships no types?
Say what you'd actually do. Check whether types exist as a separate package, and if they don't, write a small declaration file covering only the surface you call rather than typing the whole library. It's the boundary answer again, and an interviewer will take a narrow honest declaration over a wide guessed one.