Skip to content
Interview prep ยท questions

TypeScript interview questions

14 questions, from core to senior. Say your answer out loud first, then open the model answer and check it against the points a strong answer covers.

What does TypeScript give you over plain JavaScript, and what can it not do?

  • Core
Show the model answer

TypeScript adds static types to JavaScript, so many mistakes are found in the editor or in the build, but it checks nothing at run time. It catches wrong arguments, misspelled properties and values that may be null. It also gives safe refactoring and autocomplete. The type system is structural: two types are compatible when their shapes match, and their names do not matter. So an object with extra properties can be passed where a smaller shape is expected. The important limit is that types are erased when the code is compiled. Only JavaScript remains, so TypeScript cannot check data that comes from outside, such as an API response, a form or localStorage. That data must be validated by code. The compiler also trusts you when you write any or a type assertion, so wrong types give a false feeling of safety.

A strong answer covers

  • Says errors are found before run time, and names the tooling benefits
  • Explains structural typing: the shape matters, not the name
  • States that types are erased and do not exist at run time
  • Concludes that external data still needs run-time validation
  • Admits the limit: any and type assertions switch the checks off

Likely follow-up

If typing is structural, why does TypeScript report an extra property in an object literal?

What is the real difference between a type alias and an interface, and how do you choose?

  • Core
Show the model answer

For a plain object shape they are almost the same, and the choice is mostly a team convention. The real differences are these. An interface can describe only object shapes, including functions and classes. A type alias can name anything: a union, a tuple, a primitive, or a mapped or conditional type. An interface supports declaration merging: two declarations with the same name are joined into one. This is how you add a property to the global Window or to a library's types. A type alias cannot be declared twice. An interface extends another one with extends, and a conflict between property types is reported as an error. A type alias uses an intersection, with &, and a conflict there silently turns the property into never. My rule: interface for object shapes that are public or meant to be extended, type for unions and everything else.

A strong answer covers

  • Says both work for object shapes and are close in practice
  • Knows only a type alias can name unions, tuples and primitives
  • Explains declaration merging and gives a use, such as extending Window
  • Compares extends (a conflict is an error) with & (a conflict becomes never)
  • States a clear rule for choosing between them

Likely follow-up

How would you add a custom property to the global Window type?

What is the difference between any, unknown and never?

  • Core
Show the model answer

any switches type checking off for a value, unknown accepts any value but forces a check before use, and never is the type that has no values. With any the compiler stays silent, and it spreads: everything you read from an any value is also any. With unknown you cannot use the value until you narrow it, for example with typeof or a validation function. So unknown is the right type for data from outside: parsed JSON, an API response, or the error in a catch block. never is the return type of a function that always throws. It is also what remains when narrowing has removed every member of a union. That gives an exhaustive check: in the default branch of a switch, assign the value to a variable of type never. When someone adds a new member to the union, that line stops compiling.

A strong answer covers

  • Says any turns the checks off and spreads through the code
  • Says unknown accepts anything but must be narrowed before use
  • Uses unknown for external data and for caught errors
  • Defines never as a type with no values and gives an example
  • Shows the exhaustive check: assign to never in the default branch

Likely follow-up

What type does the error in a catch block have, and how do you read its message safely?

What is type narrowing, and why does this function compile without a type assertion?

  • Core
type State =
  | { status: "loading" }
  | { status: "error"; error: Error }
  | { status: "success"; data: string[] };

function label(state: State): string {
  if (state.status === "loading") return "Loading";
  if (state.status === "error") return state.error.message;
  return state.data.join(", ");
}
Show the model answer

Narrowing means that TypeScript follows the control flow and gives a variable a more specific type after a check that it understands. typeof narrows primitives, instanceof narrows class instances, and the in operator narrows by the presence of a property. Equality and truthiness checks narrow too, for example to remove null. The most useful pattern is the discriminated union in the snippet: every member has the same property, status, with a different literal type. After the loading check and the error check, only the success member is left, so state.data is safe on the last line. This models request state better than three optional fields, because impossible combinations cannot be written. For a check that the compiler cannot follow, you write a type predicate: a function with a return type like value is User. The compiler trusts a predicate, so its body must be correct.

A strong answer covers

  • Defines narrowing as control-flow analysis after a check
  • Lists the checks: typeof, instanceof, in, equality, truthiness
  • Explains the discriminated union and its shared literal property
  • Says why it beats optional fields: impossible states cannot be written
  • Describes a type predicate and that the compiler trusts it

Likely follow-up

What is the difference between a type predicate (value is T) and an assertion function (asserts value is T)?

What are generics for, and what do the type parameters in this function do?

  • Core
function pluck<T, K extends keyof T>(items: T[], key: K): T[K][] {
  return items.map((item) => item[key]);
}

const users = [{ id: 1, name: "Ana" }];
const names = pluck(users, "name"); // string[]
pluck(users, "email"); // compile error
Show the model answer

Generics let you write a function or a type once and keep the relation between its input types and its output type. A type parameter is a variable for a type. The caller can pass it, but usually the compiler infers it from the arguments. Without generics you would use any and lose the type. A constraint, written with extends, limits what a type parameter can be. In the snippet, T is the item type, and K extends keyof T means that key must be a key of the item. The return type T[K][] is exact, so names is an array of strings and a wrong key is a compile error. A type parameter can also have a default, such as T = unknown, used when nothing is passed or inferred. In React, generics appear in useState, typed fetch helpers and reusable list components.

A strong answer covers

  • Says generics keep the link between input and output types
  • Mentions that type arguments are usually inferred
  • Explains the constraint K extends keyof T in the snippet
  • Reads the return type T[K][] correctly
  • Knows default type parameters and names a real use

Likely follow-up

How do you write a generic React component, such as a List with items and a renderItem prop?

Which built-in utility types do you use, and what is each one for?

  • Core
Show the model answer

The ones I use most are Partial, Pick, Omit, Record and ReturnType. Each builds a new type from an existing one, so a shape is written only once. Partial makes every property optional, which fits the argument of an update function. Required does the opposite. Pick keeps the listed keys, and Omit removes them, for example the id of a new record. Record builds an object type from a set of keys and one value type, such as a status-to-label lookup. ReturnType gives the type that a function returns, and Parameters gives its arguments as a tuple. Both help when a library does not export the type you need. Awaited unwraps a promise, so with ReturnType it gives the resolved value of an async function. Two warnings: Partial and Required change one level only, and Omit accepts a misspelled key without an error.

A strong answer covers

  • Explains Partial and Required, with a use such as an update payload
  • Explains Pick and Omit and gives an example
  • Uses Record for a lookup from a set of keys to one value type
  • Derives types from functions with ReturnType, Parameters and Awaited
  • Knows a limit: they are shallow, or Omit does not check its keys

Likely follow-up

How would you write Partial or Pick yourself with a mapped type?

Would you use an enum or a union of string literals, and why?

  • Core
Show the model answer

I prefer a union of string literals, and an object with as const when the values are also needed at run time. An enum is one of the few TypeScript features that produce run-time code: it compiles to a real object. A numeric enum also creates a reverse mapping from number to name, and it accepts any value of type number, so it is not fully safe. A string enum has the opposite problem: it does not accept the plain string, even with the same value, which is inconvenient for data from an API. A const enum is inlined, but it causes problems with tools that compile one file at a time. A union like 'idle' | 'loading' | 'done' has no run-time cost and matches JSON directly. To loop over the values, write an array with as const and derive the union from it with typeof.

A strong answer covers

  • Says an enum produces run-time code, unlike most TypeScript features
  • Names a numeric enum problem: reverse mapping or accepting any number
  • Notes that a string enum does not accept the plain string value
  • Offers a union of string literals when only a type is needed
  • Offers an as const object or array when run-time values are needed

Likely follow-up

How do you get a union type from the values of an array or object declared with as const?

How does TypeScript protect you from null and undefined, and why is the non-null assertion risky?

  • Core
Show the model answer

With strictNullChecks on, null and undefined are separate types. A value that can be missing must say so in its type, and the compiler makes you check it before use. Without the flag, any value could be null and the compiler would stay silent. Three tools handle the missing case. Optional chaining (?.) stops and returns undefined when the left side is null or undefined. Nullish coalescing (??) gives a default only for null and undefined. The older || gives the default for every falsy value, so it replaces a valid 0 or an empty string. This is a common bug with counts and form fields. The non-null assertion (!) is risky because it only tells the compiler to stop checking. It adds no run-time check, so if you are wrong, the code fails at run time. A real check or an early return is safer.

A strong answer covers

  • Explains strictNullChecks: null and undefined must appear in the type
  • Describes optional chaining and what it returns
  • Separates ?? (null and undefined) from || (every falsy value)
  • Gives the bug with ||: a valid 0 or empty string is replaced
  • Says the ! assertion removes the check and adds nothing at run time

Likely follow-up

document.getElementById returns HTMLElement | null. How do you handle that without the ! assertion?

How do you type a React component: its props, children, event handlers, state and refs?

  • Core
Show the model answer

I describe the props with a type or an interface and annotate the parameter of the function. React.FC is not needed, and since React 18 it no longer adds children for you. Children are declared explicitly as React.ReactNode, which covers everything React can render: elements, strings, numbers, arrays and null. To accept all the attributes of a native element, I extend React.ComponentProps<'button'>. Event handlers use the React event types with the element as a type argument, such as React.ChangeEvent<HTMLInputElement> or React.MouseEvent<HTMLButtonElement>. An inline handler needs no annotation, because its type is inferred. useState infers its type from the initial value, so it needs a type argument only when that value does not show the full type, as in useState<User | null>(null). A ref to a DOM node is useRef<HTMLDivElement>(null). Its current value is null until the element is mounted, so the code must check it before use.

A strong answer covers

  • Types props with a type or interface and declares children as React.ReactNode
  • Reuses native attributes with React.ComponentProps
  • Names an event type with its element, such as React.ChangeEvent<HTMLInputElement>
  • Gives useState a type argument only when inference is not enough
  • Types a DOM ref with the element type and handles null

Likely follow-up

What is the difference between React.ReactNode, React.ReactElement and JSX.Element?

What are mapped types and conditional types, and how do FormErrors and Unwrap in this snippet work?

  • Senior
type FormErrors<T> = { [K in keyof T]?: string };
type Unwrap<T> = T extends Promise<infer U> ? U : T;

type Login = { email: string; password: string };
type LoginErrors = FormErrors<Login>;
// { email?: string; password?: string }

type User = Unwrap<Promise<{ id: number }>>;
// { id: number }
Show the model answer

A mapped type builds a new object type by going over the keys of another type, and a conditional type chooses between two types with a test. In the snippet, FormErrors turns every key of the form values into an optional string, so the errors always follow the form fields. Partial and Pick are written the same way. A mapped type can also add or remove the readonly and optional modifiers, and rename keys with as. A conditional type is an if for types: if T extends X, the result is A, otherwise B. infer captures a part of the type that matched. Unwrap asks whether T is a promise and, if so, returns the type inside it. ReturnType and Awaited work like this. One detail: when T is a union, the condition is applied to each member separately. This is called distribution, and Exclude is built on it.

A strong answer covers

  • Describes a mapped type as a loop over keys that builds a new object type
  • Reads FormErrors: every field key becomes an optional string
  • Describes a conditional type as T extends X ? A : B
  • Explains infer as capturing a part of the matched type
  • Mentions distribution over unions, as in Exclude

Likely follow-up

How do you stop a conditional type from distributing over a union?

What is the difference between an as assertion, satisfies and as const?

  • Senior
type Theme = Record<string, string | number>;

const a = { color: "red", gap: 8 } as Theme;
const b = { color: "red", gap: 8 } satisfies Theme;
const c = { color: "red", gap: 8 } as const;

a.color.toUpperCase(); // error: string | number
b.color.toUpperCase(); // ok: color is string
b.size; // error: no such property
c.gap = 4; // error: gap is read-only
Show the model answer

An as assertion overrides the compiler, satisfies checks a value without changing its inferred type, and as const makes the inferred type as narrow as possible. With as, the compiler treats the value as the type you name. It checks only that the two types overlap, so it can hide mistakes: an empty object as User compiles. It also replaces the inferred type, which is why a.color is string | number in the snippet. satisfies reports missing or wrong properties, but the variable keeps its precise type. So b.color is still a string, and an unknown key is an error. With as const, properties become readonly and values keep their literal types. I use satisfies for config objects, and as const for fixed lists that a union type is derived from. A plain as assertion stays for the few places where I know more than the compiler.

A strong answer covers

  • Says as overrides the checker and can hide a missing property
  • Says satisfies checks the value but keeps the inferred type
  • Explains as const: readonly properties and literal types
  • Reads the snippet: which lines are errors and why
  • Gives a use for each: satisfies for config, as const for fixed lists

Likely follow-up

How is satisfies different from a type annotation on the variable?

When are function overloads the right tool, compared with a union parameter or a generic?

  • Senior
Show the model answer

Overloads are the right tool only when the return type depends on the arguments in a way that a union or a generic cannot express. A union parameter is enough when the function accepts several types and always returns the same type. A generic is right when the output type follows the input type. Overloads fit a small, fixed set of call shapes, each with its own result. The DOM types work this way: createElement has a signature for known tag names that returns the exact element type, and a general one for any string that returns HTMLElement. The costs are real. Callers see only the overload signatures, not the implementation signature. The compiler does not check that the body returns the right type for each overload. And a call with a union value often fails, because no single signature accepts it.

A strong answer covers

  • Prefers a union parameter when the return type is always the same
  • Prefers a generic when the output type follows the input type
  • Uses overloads for a few fixed call shapes with different results
  • Knows the implementation signature is hidden from callers
  • Names a cost: the body is not checked per overload, or union arguments fail

Likely follow-up

Could a generic with a conditional return type replace the overloads, and what is the problem with that?

How would you move a JavaScript codebase to strict TypeScript, and which compiler flags would you turn on?

  • Senior
Show the model answer

I would migrate step by step and keep the app ready to release, with strict as the target. First I add TypeScript with allowJs, so JavaScript and TypeScript files can live together. Then I convert files one at a time, starting with shared utilities and API types, because the other files depend on them. strict is a group of flags, such as strictNullChecks, noImplicitAny, strictFunctionTypes and useUnknownInCatchVariables. If it gives too many errors at once, I start with noImplicitAny and strictNullChecks, and mark old problems with ts-expect-error comments instead of adding any. Two useful flags are not part of strict. noUncheckedIndexedAccess adds undefined to the result of reading an array index or a record key. exactOptionalPropertyTypes makes an optional property different from one that is explicitly set to undefined. I add the first one early and the second one later, because some library types do not expect it.

A strong answer covers

  • Migrates step by step with allowJs and keeps the app ready to release
  • Starts with shared code and API types, which other files depend on
  • Names what strict includes: strictNullChecks, noImplicitAny and others
  • Explains noUncheckedIndexedAccess and exactOptionalPropertyTypes, both outside strict
  • Has a plan for old errors: a few flags at a time, ts-expect-error, no new any

Likely follow-up

With noUncheckedIndexedAccess on, how do you loop over an array without checking every item for undefined?

Your code types an API response as User. Is that safe, and how would you make it safe?

  • Senior
Show the model answer

It is not safe, because types are erased at build time and nothing checks the data at run time. The result of response.json() has the type any, so writing as User is only a claim that the compiler cannot check. If the backend renames a field or sends null, the code still compiles and fails later, far from the real cause. The fix is to validate at the boundary. I treat the parsed data as unknown and pass it through a schema from a library such as Zod. The schema checks the shape at run time and returns a typed value or a clear error. The TypeScript type is inferred from the schema with z.infer, so there is one source of truth. The same rule applies to localStorage, URL parameters and postMessage. Validation costs bundle size and time, so it belongs at the boundaries, not in every function.

A strong answer covers

  • Says types are erased, so an assertion on JSON proves nothing
  • Treats external data as unknown first
  • Validates at the boundary with a schema library such as Zod
  • Infers the type from the schema to keep one source of truth
  • Names other boundaries: localStorage, URL parameters, postMessage

Likely follow-up

What should the app do when a response fails validation in production?