A senior frontend candidate at a 60-person fintech in early 2026 froze on a question that had nothing to do with algorithms: why does declaring interface User twice in the same file quietly merge into one type, while doing the exact same thing with type User throws a duplicate-identifier error. He'd written TypeScript for three years and had never once had to reason about how the compiler actually resolves a type, only about which annotation made the red squiggle go away. TypeScript now sits at 48.8 percent adoption among professional developers, sixth among all languages in the 2025 survey (Stack Overflow, 2025). It stopped being the "nice to have" a long time ago. Most frontend and full-stack loops now assume you've used it, and a growing share test whether you understand it or just tolerate it.
Here's an opinion that might be wrong: prep material for TypeScript interview questions spends most of its time on utility types and generics because they demo well in a blog post, screenshot-friendly, tidy code samples. Type narrowing, specifically discriminated unions and the way never catches an unhandled branch in a switch, is the thing that actually separates a candidate who's internalized the type system from one who's memorized syntax. You can ship years of production TypeScript leaning on Partial and Pick without ever writing a real discriminated union by hand. You can't survive a code review on a team that uses them everywhere without understanding how narrowing actually works underneath.
This page covers TypeScript interview questions across eight areas: interfaces versus type aliases and structural typing, generics, union and intersection types (plus the satisfies operator), enums, type narrowing, the any/unknown/never trio, the built-in utility types, and as const, readonly, decorators, and the strict flags in tsconfig. Code examples are TypeScript throughout, not pseudocode.
Interfaces, type aliases, and how TypeScript actually checks types
Every loop opens somewhere near here, even for a candidate with five years on the resume. It's a warm-up question, but a shaky answer sets the tone for the thirty minutes that follow.
Easy questions
15Both describe the shape of a value, and for a plain object shape they're mostly interchangeable. The differences that matter: interfaces support declaration merging (two interface declarations with the same name combine into one), and interfaces can only describe object-like shapes. Type aliases can't merge, but they can alias anything, unions, tuples, primitives, mapped types, conditional types, not just objects.
interface User {
id: string;
}
interface User {
email: string;
}
// merges into { id: string; email: string }
type Status = "pending" | "shipped" | "cancelled"; // interface can't do this directlyMy rule of thumb: interface for anything meant to be extended by consumers of a library, type for unions, tuples, and anything that isn't a plain object shape. Plenty of teams just pick one and enforce it with a lint rule, which is a defensible answer too.
Generics let a function or type stay type-safe while working across more than one concrete type, without throwing away the relationship between its inputs and outputs the way any does. Any accepts anything and tells you nothing back. A generic accepts anything, but remembers exactly what it accepted.
function firstElement(arr: any[]) {
return arr[0]; // return type is any, all type info is gone
}
function firstElementGeneric<T>(arr: T[]): T {
return arr[0]; // return type is T, tied to whatever array you passed in
}
const n = firstElementGeneric([1, 2, 3]); // n: number
const s = firstElementGeneric(["a", "b"]); // s: stringA union, A | B, means a value is one of those types, and you can only safely use members that both types share until you narrow it. An intersection, A & B, means a value must satisfy both types at once, so it has every member from both combined.
type Cat = { meow: () => void };
type Dog = { bark: () => void };
type Pet = Cat | Dog; // has meow OR bark, can't call either without narrowing
type Hybrid = Cat & Dog; // has meow AND bark, both requiredA numeric enum auto-increments from 0 unless you set explicit values, which makes the numbers meaningless outside the enum itself and hard to debug from a raw log line. A string enum requires every member to have an explicit string value, which costs a little more typing but makes the runtime value self-describing.
enum StatusNumeric {
Pending, // 0
Shipped, // 1
Cancelled, // 2
}
enum StatusString {
Pending = "PENDING",
Shipped = "SHIPPED",
Cancelled = "CANCELLED",
}
console.log(StatusNumeric.Shipped); // 1, meaningless without the enum
console.log(StatusString.Shipped); // "SHIPPED", readable on its ownInside the branch of a typeof check, TypeScript restricts the variable's type to whichever members of the union match that check, so you can safely call methods specific to that narrowed type without a cast.
function format(value: string | number) {
if (typeof value === "string") {
return value.trim(); // value: string here
}
return value.toFixed(2); // value: number here
}any turns off type checking entirely for that value, you can call any method, access any property, assign it anywhere, and the compiler stays silent even when it's wrong. unknown is the type-safe version of the same idea: it can hold any value, but you can't do anything with it, no property access, no method call, until you narrow it to something more specific first.
function processAny(input: any) {
input.toUpperCase(); // compiles, may crash at runtime if input isn't a string
}
function processUnknown(input: unknown) {
input.toUpperCase(); // error, must narrow first
if (typeof input === "string") {
input.toUpperCase(); // fine, narrowed to string
}
}They all take an existing type and transform it into a related one, instead of you writing that shape out by hand a second time.
interface User {
id: string;
name: string;
email: string;
}
type PartialUser = Partial<User>; // every field optional
type RequiredUser = Required<User>; // every field required
type ReadonlyUser = Readonly<User>; // every field readonly
type UserPreview = Pick<User, "id" | "name">; // only id and name
type UserNoEmail = Omit<User, "email">; // everything except email
type UsersById = Record<string, User>; // dictionary keyed by stringPartial when only some use cases need the fields optional, an update payload where the caller sends only the fields that changed, is the classic case. Making the original interface itself optional everywhere would make every other place that reads a full, guaranteed User start needing null checks it shouldn't have to do.
function updateUser(id: string, changes: Partial<User>) {
// changes might only include { name: "new name" }
}readonly on a property blocks reassignment of that property after the object is constructed, and readonly T[] (or ReadonlyArray<T>) removes mutating array methods like push, pop, and splice from the type, leaving only the non-mutating ones like map and filter.
interface Point {
readonly x: number;
}
const p: Point = { x: 1 };
p.x = 2; // error, x is readonly
const nums: readonly number[] = [1, 2, 3];
nums.push(4); // error, push doesn't exist on readonly number[]What it doesn't prevent: mutation of a nested object inside a readonly property. readonly is shallow by default, not deep, so readonly user: { name: string } still lets you write point.user.name = "new" even though you can't reassign point.user itself.
An array type describes a sequence of unknown length where every slot has the same type (or a union of types), like string[]. A tuple type fixes both the length and the type at each position, like [string, number, boolean]. At runtime there's no difference at all, a tuple is just a plain JS array, all the extra checking is compile time only.
Tuples show up a lot for function return values you want to destructure by position, similar to how React's useState returns a pair. TypeScript also supports optional and rest elements inside a tuple, like [string, number?] or [string,...number[]]. One gap worth knowing: array mutation methods like push() still work on a tuple-typed variable and can silently add extra elements the type doesn't describe, because TypeScript never fully locked down mutating methods for tuples.
Marking a property with ? means the key itself can be absent from the object entirely, not just set to undefined. When you read that property elsewhere, TypeScript widens its type to include undefined automatically, so age?: number is read back as number | undefined.
This is a subtly different guarantee than writing age: number | undefined directly, where the key must still be present, just possibly holding undefined. If you turn on the exactOptionalPropertyTypes flag, TypeScript enforces that distinction strictly and will complain if you explicitly assign undefined to an optional property instead of omitting the key, which catches a real category of bugs in object spreads and API payloads.
Undefined is what you get from a variable that was declared but never assigned, a missing object property, or a function parameter that wasn't passed. Null is usually an intentional value someone assigned on purpose to mean "no value here," and it shows up a lot in DOM APIs, like document.querySelector returning null when nothing matches.
Without strictNullChecks, both null and undefined are quietly assignable to every other type, so nothing in the type system stops you from calling a method on something that turns out to be null at runtime. With the flag on, null and undefined become their own distinct types that only show up in a variable's type if you put them there yourself, forcing you to narrow with a check before you can use the value.
Void means "don't rely on this function's return value," it's a contract about intent rather than a description of what actually comes back. A callback parameter typed to return void, like the one Array.prototype.forEach takes, will happily accept a function that returns something else entirely, because the caller has promised it won't look at the result.
That's different from declaring a function's own return type as void, which does actively forbid you from writing return someValue; with an actual value inside that function's body. So void is permissive on the "accepting a callback" side and restrictive on the "declaring your own function" side, which trips people up the first time they see it.
Keyof takes an object type and produces a union of the literal types of its known keys. For an interface with properties name and age, keyof Person gives you 'name' | 'age'.
It's most useful for writing generic helper functions that need to stay tied to a specific object's shape, like a getProperty function where the key argument has to be one of that object's actual keys, and the return type can then be expressed as an indexed access, T[K]. It also underpins most of the built-in mapped and utility types.
An index signature like { [key: string]: number } tells TypeScript that an object can have any number of string keys, all mapped to values of the given type, which is the standard way to type a dictionary-style object where you don't know the exact key names in advance.
Once you add a string index signature, every named property you also declare on that type has to be compatible with it, so you can't mix an index signature of type number with a named property typed as string. Keep in mind that object keys are coerced to strings at runtime even when you write a numeric index signature, JavaScript doesn't actually have integer keys the way some languages do.
Medium questions
25Structural typing means a value satisfies a type if it has the right shape, regardless of what name, if any, it was declared with. Java and C# use nominal typing: a class only satisfies an interface if it explicitly declares that it implements it, matching fields aren't enough.
interface Point {
x: number;
y: number;
}
function logPoint(p: Point) {
console.log(`${p.x}, ${p.y}`);
}
const obj = { x: 1, y: 2, z: 3 };
logPoint(obj); // fine, obj has the shape Point needs, extra fields are allowedContextual typing means TypeScript infers a parameter's type from the position an expression sits in, not from the expression alone. Pass a callback to array.map and TypeScript already knows the array's element type, so it applies that type to the callback's parameter without you writing it out.
const names = ["ada", "grace", "margaret"];
names.map((name) => name.toUpperCase());
// name is inferred as string, no annotation neededPull that same arrow function out into a standalone variable and the inference disappears, because there's no longer a contextual position feeding it a type. const upper = (name) => name.toUpperCase() on its own gives name an implicit any under noImplicitAny, which throws an error. Same function, different result, purely because of where it's written.
A constraint narrows a generic type parameter down to types that have at least a certain shape, using extends. Without one, TypeScript has to assume the type parameter could be absolutely anything, which blocks you from accessing any property on it at all inside the function body.
function longest<T extends { length: number }>(a: T, b: T): T {
return a.length >= b.length ? a : b;
}
longest("hello", "hi"); // fine, strings have.length
longest([1, 2, 3], [1]); // fine, arrays have.length
longest(3, 5); // error, numbers don't have.lengthInterviewers use this to check whether you reach for a constraint on purpose or bolt on any the moment the compiler complains about a missing property.
A generic default lets callers omit the type argument entirely and fall back to a sensible default, the same idea as a default parameter value, but applied to a type instead of a runtime value.
async function apiGet<T = unknown>(url: string): Promise<T> {
const res = await fetch(url);
return res.json();
}
const data = await apiGet("/api/users"); // data: unknown, safe default
const users = await apiGet<User[]>("/api/users"); // users: User[], caller opts inDefaulting to unknown instead of any here is the detail that separates a careful answer from a sloppy one. It forces every caller who skips the explicit type argument to narrow before using the result, instead of silently getting an untyped value that looks safe and isn't.
Widening happens when TypeScript infers the type of a mutable binding. Declare a value with let and TypeScript assumes you'll reassign it, so it widens "pending" up to the general string. Declare the same value with const, and since it can never be reassigned, TypeScript keeps the specific literal type.
let status1 = "pending"; // type: string
const status2 = "pending"; // type: "pending"
function setStatus(s: "pending" | "shipped") {}
setStatus(status2); // fine
setStatus(status1); // error, string is not assignable to "pending" | "shipped"satisfies checks that a value matches a type without changing the value's own inferred type the way an annotation would. Introduced in TypeScript 4.9 (Microsoft TypeScript blog, 2022), it's meant for exactly this gap: you want validation, but you also want to keep the narrow literal type of what you actually wrote.
type Colors = Record<string, [number, number, number]>;
const palette = {
red: [255, 0, 0],
green: [0, 255, 0],
} satisfies Colors;
palette.red[0]; // still known to be a tuple, works
// with `: Colors` instead of `satisfies Colors`, palette.red would widen to
// (number | number[])[], and.red would no longer be recognized by nameCandidates who've only used TypeScript through a framework template rarely know this one exists. It's worth a mention even unprompted if the conversation touches config objects.
My honest answer: no, not by default anymore. A union of string literals gets you almost everything an enum gives you, exhaustiveness checking, autocomplete, comparability, with better interop (a plain string literal works anywhere a string is expected; an enum member doesn't) and none of the const-enum bundler footguns.
type Status = "pending" | "shipped" | "cancelled";
function isTerminal(s: Status): boolean {
return s === "shipped" || s === "cancelled";
}I still reach for enum on teams that already use it consistently, because a lone holdout convention costs more than the enum itself does. If I'm starting a project from scratch, I skip it. Not everyone on the TypeScript team agrees with that take, and it's a fair thing to disagree on.
instanceof narrows a value based on its prototype chain at runtime, so it works reliably for class instances. It quietly fails for plain object literals and for values that cross a realm boundary, an object created in a different iframe or worker context, because that object's prototype chain points at a different constructor than the one you're comparing against, even if the shape looks identical.
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
function handle(err: Error) {
if (err instanceof ApiError) {
console.log(err.status); // narrowed to ApiError
}
}A discriminated union is a set of object types that all share one common property, the discriminant, with a distinct literal value per variant. Checking that one property lets TypeScript narrow the entire object, not just that one field.
type LoadingState = { status: "loading" };
type SuccessState = { status: "success"; data: string[] };
type ErrorState = { status: "error"; message: string };
type FetchState = LoadingState | SuccessState | ErrorState;
function render(state: FetchState) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.join(", "); // state: SuccessState,.data exists
case "error":
return state.message; // state: ErrorState,.message exists
}
}The discriminant has to be a literal type, not a widened string, because narrowing works by comparing specific values. If status were typed as plain string, TypeScript couldn't tell which branch of the union a given value of "loading" actually corresponds to, and the whole mechanism falls apart.
A type predicate is a function whose return type is value is SomeType instead of a plain boolean, telling TypeScript that a true return narrows the argument to that type everywhere the function is called as a check. You write one when the logic that decides "is this a Fish" is more than a simple typeof or instanceof can express.
interface Fish { swim: () => void }
interface Bird { fly: () => void }
function isFish(pet: Fish | Bird): pet is Fish {
return (pet as Fish).swim !== undefined;
}
function move(pet: Fish | Bird) {
if (isFish(pet)) {
pet.swim(); // narrowed to Fish
} else {
pet.fly(); // narrowed to Bird
}
}Narrow it first, using typeof, instanceof, a type predicate, or an explicit type assertion you can justify. There's no shortcut around this, and that's the entire point of choosing unknown over any in a function signature that genuinely accepts arbitrary input, parsing JSON from an API response, say.
function parseUser(data: unknown): { name: string } {
if (
typeof data === "object" &&
data !== null &&
"name" in data &&
typeof (data as any).name === "string"
) {
return { name: (data as any).name };
}
throw new Error("Invalid user payload");
}Pick<T, K> keeps only the listed keys. Omit<T, K> keeps everything except the listed keys. For a fixed, fully-known interface, picking the fields you want and omitting the fields you don't want produce the same final shape.
They diverge once the base type gets a new field added later. Pick<User, "id" | "name"> stays exactly the same two fields no matter what gets added to User. Omit<User, "email"> silently grows to include any new field, since it's defined by exclusion, not inclusion. That difference matters more than most candidates expect once a type actually changes over time in a real codebase.
Record<K, T> is the right call whenever you need to guarantee every key in a known, finite set maps to a value, a state machine's transition table, a lookup of handler functions per action type, a config object keyed by environment name.
type Environment = "development" | "staging" | "production";
const apiBaseUrl: Record<Environment, string> = {
development: "http://localhost:3000",
staging: "https://staging.api.example.com",
production: "https://api.example.com",
};
// missing a key here, or adding one not in Environment, fails to compileThat last line is the actual value. A plain object literal wouldn't catch a missing environment at compile time. Typing it as a Record does.
as const tells TypeScript to infer the narrowest possible type for a literal instead of widening it: string literals stay as their exact literal type, array literals become readonly tuples instead of a mutable array type, and every nested property becomes readonly too.
const config1 = { env: "production", retries: 3 };
// type: { env: string; retries: number }
const config2 = { env: "production", retries: 3 } as const;
// type: { readonly env: "production"; readonly retries: 3 }
const tuple1 = [1, 2, 3]; // type: number[]
const tuple2 = [1, 2, 3] as const; // type: readonly [1, 2, 3]Useful anywhere downstream code needs the literal value preserved for narrowing, a discriminant field, a fixed tuple passed to a function expecting an exact-length input.
strict: true is a bundle flag that turns on several checks at once: strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization, strictBindCallApply, noImplicitThis, alwaysStrict, and useUnknownInCatchVariables.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}strictNullChecks is almost always the one that breaks the most existing code the moment it's flipped on, because every place a value could be null or undefined and wasn't checked for it before now becomes a real compile error instead of a silent runtime crash waiting to happen. Turning strict mode on for an existing large codebase midstream is a genuinely multi-week project, not a config toggle you flip on a Friday afternoon.
A mapped type iterates over a union of keys, usually produced by keyof, and produces a new property for each one. The basic shape is { [K in keyof T]: SomeTransform, and TypeScript literally walks the key union member by member building the result type.
Since TypeScript 4.1 you can also remap the key itself with an as clause inside the mapped type, which is how you'd build something like a set of getter methods generated from an object's properties.
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
interface Person { name: string; age: number; }
type PersonGetters = Getters<Person>;
// { getName: () => string; getAge: () => number }You can also map a key to never inside that clause to drop it from the result entirely, which is how utility types like Omit are implemented beneath that.
A conditional type has the shape T extends U ? X : Y. When T is a bare, unqualified type parameter and you plug in a union type, TypeScript doesn't check the whole union against U at once, it distributes, running the check separately for each member of the union and unioning the results back together.
That means a type like NonNullable applied to string | number | null checks each of string, number, and null individually against null | undefined, throws away the one that matches, and unions the rest, giving you back string | number. This distributive behavior is what makes a lot of the built-in utility types work correctly across unions without any extra code, but it also means conditional types don't behave the way you'd expect if you're thinking of the union as one single entity rather than a set of alternatives.
Infer lets you introduce a new type variable inside the extends clause of a conditional type and have TypeScript fill it in by pattern matching against the type you're checking, instead of you having to already know the shape ahead of time.
A simple example is pulling the element type out of an array type without knowing it in advance.
type ElementType<T> = T extends (infer U)[] ? U : never;
type A = ElementType<string[]>; // string
type B = ElementType<number[]>; // numberBuilt-in utility types lean on this heavily, ReturnType is defined as T extends (...args: any[]) => infer R ? R : any, it's pattern matching on the shape of a function type to pull out just the return position.
You can declare several call signatures above a single function body, each one describing a valid way to call that function. Callers only ever see the overload signatures, never the implementation signature, so the implementation signature itself needs to be broad enough internally to cover every case the overloads promise, since it's the actual code doing the work.
function parse(value: string): number;
function parse(value: string[]): number[];
function parse(value: string | string[]): number | number[] {
return Array.isArray(value)
? value.map(Number)
: Number(value);
}A common mistake is forgetting that the implementation signature is invisible to callers, people sometimes think they can call the function with an argument type that only appears in the implementation signature and get confused when TypeScript rejects it, because as far as the caller is concerned only the declared overloads exist.
TypeScript lets you declare a fake first parameter named this in a function declaration purely for type checking, it gets erased and isn't a real parameter at runtime. This is mostly useful for functions that get detached from an object and handed somewhere else, like event handlers, where you want TypeScript to check that whatever ends up calling the function actually binds the right kind of this.
function reset(this: HTMLButtonElement) {
this.disabled = true;
}
const btn = document.querySelector('button')!;
btn.addEventListener('click', reset); // fine, this is inferred correctly
reset.call({}); // error, {} is not HTMLButtonElementWithout that this parameter, TypeScript would default the this type to any inside a loose function and you'd lose all checking on how the function is expected to be called.
TypeScript merges multiple declarations of the same interface or namespace name into one combined type, which is what lets you extend third party types without touching their source. For Express, the usual pattern is declaring a global augmentation in one of your own.d.ts files.
You'd write something like a global block that reopens the Express namespace and adds a property to its Request interface, so req.user becomes a recognized, typed property everywhere in your app after a middleware sets it. This only works because interfaces (and namespaces) are designed to merge across separate declarations, type aliases don't have this behavior, declaring the same type alias name twice is just an error.
Template literal types let you build new string literal types out of existing ones using the same backtick syntax as a JS template string, but at the type level. Combined with a union, they distribute the same way conditional types do, generating every combination.
type Events = 'click' | 'focus' | 'blur';
type HandlerName<T extends string> = `on${Capitalize<T>}`;
type Handlers = HandlerName<Events>;
// 'onClick' | 'onFocus' | 'onBlur'Real usage tends to be things like typed event handler prop names, typed CSS custom property names, or route parameter extraction from a path string. The one thing to watch is that this combinatorial expansion can get expensive on large unions, applying a template literal type across a union of a few hundred string literals can noticeably slow down compilation.
Writing "propertyName" in value inside an if check narrows a union of object types down to just the members that actually declare that property, based purely on structural shape rather than any explicit tag or class identity.
This matters when you're dealing with a union of plain object shapes that don't share a common discriminant literal field and aren't class instances either, so typeof won't help (they're both objects) and instanceof won't help (neither is a class). A common real case is narrowing between two possible shapes of an external API response that differ only in which optional field is present, where in lets you branch correctly without needing to add a fake tag field yourself.
A type predicate function returns an actual boolean and you use it inside an if condition to narrow a variable for the branch that follows. An assertion function doesn't return a boolean at all, it either throws or returns void, and its signature uses the asserts keyword instead of a boolean return type.
function assertIsString(val: unknown): asserts val is string {
if (typeof val !== 'string') {
throw new Error('Expected a string');
}
}
function greet(name: unknown) {
assertIsString(name);
console.log(name.toUpperCase()); // name is string here
}The narrowing takes effect for the rest of the enclosing scope right after the call, rather than only inside an if branch, which makes assertion functions a natural fit for guard clauses at the top of a function, especially for validating unknown input from JSON, form data, or query params.
ReturnType, Parameters, and ConstructorParameters let you pull types directly off an existing function or class rather than duplicating them. This is genuinely useful when you're wrapping or mocking a third party function whose exact internal type isn't exported, or when you're writing a typed wrapper around a custom hook and don't want two copies of its return shape to drift apart.
A common combo is Awaited to get the resolved value type of an async function without needing to know or import its promise's inner type separately. This pattern shows up constantly in test utilities, where you want a mock's argument types to always track the real function's signature automatically.
Hard questions
12const enum tells the compiler to inline every reference at the call site instead of generating a runtime lookup object, so StatusString.Shipped compiles directly to "SHIPPED" with no enum object left behind. That's faster and produces less output.
The problem shows up with single-file transpilers, esbuild, SWC, Babel, that process one file at a time without full program knowledge. They can't inline a const enum's values because the definition might live in a different file, so const enums break under isolatedModules: true unless the toolchain specifically supports them. I've been bitten by this exact error message more than once switching a project onto esbuild.
never represents a value that can't logically occur, the return type of a function that always throws, or the type left over once every member of a union has been narrowed away by a series of checks. Exhaustiveness checking exploits that second case: assign the remaining value in a default branch to a variable typed never, and if a new union member gets added later without handling it, the assignment fails to compile instead of failing silently at runtime.
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 _exhaustive: never = shape; // fails to compile if a case is missing
throw new Error(`Unhandled shape: ${_exhaustive}`);
}
}This is the pattern I'd actually want to see a senior candidate reach for unprompted. Plenty of mid-level candidates know never exists as trivia and can't apply it to catch a real bug.
TypeScript 5.0 added support for the TC39 Stage 3 ECMAScript decorators proposal, which works without any compiler flag and produces different runtime output than TypeScript's older, experimental decorators (TypeScript 5.0 release notes). The older system requires "experimentalDecorators": true in tsconfig and is what Angular and NestJS still build on, since it predates the standardized proposal by years and relies on reflect-metadata for some of its behavior.
function logged(target: any, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
return function (this: any,...args: any[]) {
console.log(`Calling ${methodName}`);
return target.call(this,...args);
};
}
class Service {
@logged
fetchData() {
return "data";
}
}I wouldn't assume a candidate has hands-on decorator experience unless the job description mentions Angular or NestJS specifically. It's fair to ask about it conceptually anywhere, but plenty of solid React and Node candidates have genuinely never written one.
Structural typing on its own would actually allow the extra property, an object with more properties than a target type requires is still a valid match, that's the whole point of structural subtyping. Excess property checking is a separate, stricter pass TypeScript runs only on object literals created fresh at the point of assignment or as a function argument.
The reasoning is that a freshly written literal has no other purpose or reference elsewhere in the code, so if it has a property the target type doesn't expect, that's almost always a typo rather than intentional extra data, and TypeScript flags it as a heuristic. The moment you assign that same object to a variable first, or spread it, or pass it through a type assertion, it's no longer a fresh literal at the check site, it becomes an ordinary structural assignability question, and the extra property is allowed since structural typing was never actually strict about it.
With strictFunctionTypes on, standalone function types are checked contravariantly on their parameters, which is the sound behavior, a function that can accept a wider type is safe to use wherever a function accepting a narrower type is expected. Method shorthand signatures declared inside interfaces or classes are deliberately excluded from that stricter check and stay bivariant.
The reason is backward compatibility with a genuinely common OOP pattern, subclasses that narrow a method's parameter type in what looks like a covariant override, similar to how Animal.makeSound might be overridden by Dog.makeSound(sound: Bark) in a way most codebases treat as fine even though it's technically unsound. If you pass a method off as a first-class callback value instead of calling it directly on its object, that unsoundness can actually bite you at runtime, so it's worth remembering method signatures get a pass here that plain function type properties don't.
The usual technique is branding, intersecting the primitive with a phantom tag property that never actually exists at runtime, something like type UserId = string & { readonly __brand: 'UserId' }.
You construct a UserId only through one boundary function that validates a raw string and then asserts the result as UserId, so everywhere else in the codebase, a plain string simply won't satisfy the type anymore because it's missing that phantom property structurally. Some teams use a unique symbol instead of a string literal for the brand key to guarantee the tag can never collide with another brand by accident. It costs nothing at runtime, the whole thing disappears after compilation, it's purely a compile-time discipline for keeping semantically distinct strings or numbers from being swapped by mistake.
This shows up with recursive conditional or mapped types that don't reach a base case fast enough, or where TypeScript's structural comparison between two deeply recursive generic types can't prove they terminate within its internal recursion budget. A classic trigger is a hand-rolled deep partial or JSON-schema-style recursive type applied to something with a circular reference, like a self-referencing tree node, where the recursion never actually bottoms out at a primitive.
The fix is usually to add an explicit depth counter as an extra type parameter that decrements on each recursive step and bails out to a plain fallback once it hits zero, or to restructure the recursive type so each step genuinely strips away a layer of the input instead of recursing on an equivalent shape. Sometimes the recursive type isn't even yours, it's coming from a third party library, and the practical fix is just widening or simplifying the input type before handing it to that generic rather than fighting the library's type definition.
Distribution only kicks in when the type being checked is a bare, unwrapped type parameter. Wrapping both sides of the extends clause in a one-element tuple forces TypeScript to compare the union as a single unit instead of splitting it member by member.
type IsNever<T> = [T] extends [never] ? true : false;
type A = IsNever<never>; // true
type B = IsNever<string>; // falseYou want this whenever you're asking a question about the union as a whole rather than about each of its members separately, like checking whether a type is exactly never (a naked check on never over a union always distributes into an empty result), or writing something like an IsUnion helper that needs to compare a union against itself without it silently collapsing to a single member during the comparison.
A regular symbol type just says "this value is some Symbol," every symbol-typed value is treated as interchangeable with every other one, which defeats the point of using symbols as guaranteed-collision-free keys in the first place. Unique symbol is a nominal type tied to one specific const declaration.
const kBrand: unique symbol = Symbol();
const kOtherBrand: unique symbol = Symbol();
interface Tagged { [kBrand]: true; }
// a value keyed with kOtherBrand is not assignable to TaggedTwo separate unique symbol declarations are never assignable to each other even though both are ordinary Symbol() calls at runtime. This is what powers well-known-symbol style APIs and library-internal branding keys, and it's the only way TypeScript can track a computed property key's exact identity well enough to resolve it correctly inside an interface.
Type argument inference in TypeScript is all-or-nothing per call. The moment you supply any explicit type arguments, TypeScript stops trying to infer the remaining ones from the actual arguments you passed and expects every type parameter to be filled in positionally from that point on.
This catches people off guard on generic wrapper functions, things like a typed reducer hook where you only want to pin the state type and let the action type keep inferring normally. Two common workarounds: reorder the type parameters so the one you want to specify explicitly comes first and give the rest sensible defaults, or split the function into a small factory that only takes the fixed type argument and returns another function that still has full inference available for the rest, which is the curried generic pattern several typed validation and data fetching libraries use to get around this exact limitation.
Classic node resolution imitates the old CommonJS require lookup rules and mostly ignores a package's exports map in package.json. Nodenext (and bundler mode) actually respects conditional exports, including the separate import and require type branches a dual CJS/ESM package can declare, so a library that only ships.d.ts files matching its CJS build while its exports map points ESM consumers somewhere else will suddenly have missing or wrong types under nodenext, even though node mode found something usable by guessing at file paths.
Nodenext also enforces that relative imports inside ESM-mode TypeScript files include an explicit file extension, writing./util.js rather than./util, because that's what Node's real ESM loader requires at runtime and TypeScript is just holding you to the same rule ahead of time rather than inventing a new one. In practice this shows up as a sudden wave of module-not-found errors right after bumping module and target in tsconfig, and the actual fix is usually adding those missing extensions plus checking whether your dependencies genuinely ship correct dual-package exports, since node mode was never really validating that correctly in the first place.
A direct as assertion only works between two types that already overlap enough for TypeScript to consider one a plausible subtype or supertype of the other, it's not a general purpose sideways cast. Routing through unknown first disables that overlap check entirely, since every type is assignable to and from unknown, which is why it lets you jump between two otherwise unrelated types.
It's a legitimate tool at real boundaries where you have outside knowledge TypeScript has no way to derive on its own, for example reinterpreting a JSON.parse result you've already validated against a schema elsewhere, or narrowing an overly generic return type from a third party library after actually confirming its real runtime shape from the docs. It's a red flag when it's used to paper over two of your own domain types that TypeScript is correctly telling you don't line up, in that situation the fix is almost always writing a real mapping or adapter function, because the double assertion removes every bit of future protection the moment either shape changes and nobody notices until it breaks at runtime.
How to prepare for a TypeScript interview in 2026
Skip another slide of "here are the utility types." Build one small thing that forces you to model real state: a fetch wrapper with a discriminated FetchState union, a config loader validated with satisfies, a tiny state machine typed with Record. Turn on strict: true in the tsconfig and fix every error it surfaces by hand instead of reaching for any to make it go away. Watching your own code go from thirty errors to zero teaches narrowing faster than reading about it ever will.
Across frontend and full-stack mock interviews run through LastRoundAI, the discriminated-union question trips up more candidates than the generics question does, even though generics gets more prep-guide attention by a wide margin. My guess is that generics reads as the "advanced" topic people study on purpose, while narrowing feels like something you'll just absorb on the job. We don't have a precise number for that gap. It shows up often enough in review to flag here, not often enough that I'd stake a specific percentage on it.
One more thing worth knowing going into 2026: the compile-step tax that used to be the strongest argument against TypeScript is smaller than it was. Node.js added experimental type stripping in version 22.6, and by 22.18 (the LTS line) and 23.6, plain TypeScript files with erasable syntax run directly with no build step and no flag at all (Node.js documentation, TypeScript modules). That doesn't erase the honest trade-off, type-only features like enums and namespaces still need real compilation, but "you always need a build step to run TypeScript" isn't quite true anymore, and an interviewer who's kept up with this will notice if your answer treats it like it still is.
Get the reps in before the real thing
Explaining a type on paper is not the same as defending it out loud once an interviewer swaps one field and asks what breaks. LastRoundAI's mock interview mode runs live coding rounds with real-time follow-up questions in your browser, and the free plan includes 15 credits a month that reset monthly rather than piling up unused. Starter is $19/mo if a handful of sessions isn't enough runway.
Once your answers hold up under a follow-up, the slower part of the job hunt is usually just getting in front of enough frontend and full-stack roles that actually test TypeScript instead of treating it as a checkbox. Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.
Questions about either product go to contact@lastroundai.com. That's the only inbox we check.
How this list was built
Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.
What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.
If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.
Frequently asked questions
Do I need hands-on TypeScript experience to pass?
It shows quickly either way. Textbook answers hold up until the interviewer asks what you did when it broke, and that is usually the question that separates candidates. A small real project you can discuss honestly beats a longer list of familiarity claims.
Is TypeScript still worth learning in 2026?
For interview purposes the question is really whether the teams you are targeting use it, which is worth checking against their actual job postings rather than general popularity rankings. Where it is in use it tends to be deeply embedded and slow to replace.
Should I memorise TypeScript syntax for the interview?
Rarely worth it. Most interviewers care that you know what to reach for and why, and will not fail you for forgetting an exact flag. Being confidently wrong about behaviour costs far more than admitting you would check the documentation.
What is the most common mistake in TypeScript interviews?
Answering the question that was asked and stopping there. The strongest candidates add the trade-off or the failure mode without being prompted, which is what signals real use rather than revision.

