37 questions

TypeScript interview questions and answers

Junior questions

  1. 1. What does TypeScript add to JavaScript?

    Answer

    TypeScript adds static types for variables, functions and objects. Checking happens before the program runs, at compile time or in the editor, and then plain JavaScript without types runs in the browser or Node. This catches typos and wrong arguments early and gives autocomplete. Types also work as living documentation for the code.

    Follow-up question: What is the interface keyword for?

  2. 2. Will TypeScript protect you from invalid data that a client sent in a request? Why?

    Answer

    No. Types exist only during development and compilation, and are erased at runtime. So JSON.parse returns any, and a type annotation on its result is just a promise nobody verifies. Data from outside (requests, files, queues, environment variables) must be checked at runtime, that is, validated, for example with a schema or a DTO with a validator.

    Follow-up question: Where do you describe such a check in NestJS?

  3. 3. Why does TypeScript complain about an extra field in a fresh object literal, but not when the same object is first put into a variable? Does the type trim the extra field at runtime?

    Answer

    The excess property check applies only to a fresh literal that is assigned directly to a typed place. For a variable, ordinary structural compatibility applies: if the required fields are present, extra ones are allowed. At runtime the type trims nothing, so a field that is not in the type still goes, for example, into the API's JSON response. To make sure toResponse(user) cannot return passwordHash, you must build the result explicitly from allowed fields instead of relying on the type.

    Follow-up question: How do you make sure a function physically cannot return passwordHash, even if the entity has it?

  4. 4. Why can let role = 'admin' not be passed to a function that accepts 'admin' | 'user', while const role = 'admin' can? What does as const add?

    Answer

    let widens the literal to string, because the variable can be reassigned, while const keeps the literal type 'admin'. as const makes an array a readonly tuple of literals and an object one with readonly literal fields. From it (typeof ROLES)[number] gives the union 'admin' | 'user'. Node runs .ts directly through type stripping and does not check types, so checking is done with tsc --noEmit.

    Follow-up question: How do you get a union of keys and a union of values from an object STATUS = { NEW: 'new', PAID: 'paid' } as const, and why is it better than enum?

  5. 5. A parameter has the type string | string[] | null. Why does if (typeof input === 'object') not remove null, and how do you narrow the type correctly?

    Answer

    typeof null returns 'object', so such a check does not exclude null, and TypeScript rightly says input may be null. At runtime this would give a TypeError. Correct options: an explicit input !== null, Array.isArray(input) for an array, or input?.length. In general narrowing must match the real behavior of JavaScript, not intuition.

    Follow-up question: How does 'amount' in e differ from e.amount !== undefined for narrowing a union, and when does in give a false result?

  6. 6. How does Record<Status, string> differ from { [key: string]: string }? What happens if a new value is added to the Status union?

    Answer

    Record with a union of keys requires all keys: if a new value is added to Status, the compiler shows an error in every map where it is missing. This is an exhaustiveness check for maps. An index signature says that any string key exists, and that is untrue: reading loose.refunded.length compiles and then fails at runtime with a TypeError because the value is undefined. For a closed set of keys Record is more honest.

    Follow-up question: How do you type a map of allowed order status transitions so that tsc catches both a forgotten status and a transition to a nonexistent one?

  7. 7. What should the return type of an async function be, and why does users.map(async (u) => fetchUser(u.id)) not give an array of users?

    Answer

    The return type of an async function is always Promise<T>, for example Promise<User>, otherwise the compiler reports an error. map with an async callback returns an array of promises, not an array of values, so you cannot access fields of the result. The right way is to wait with await Promise.all(...) before further processing, such as filter. For a tuple of promises Promise.all returns a tuple of values, while allSettled returns a tuple of objects with a status.

    Follow-up question: How does the result type of Promise.all differ from Promise.allSettled for the tuple [Promise<User>, Promise<Order[]>]?

Middle questions

  1. 1. How does extending through interface extends differ from & for types? When do you choose interface and when type?

    Answer

    extends checks compatibility immediately at declaration: if a field conflicts, there is an error in the interface itself. & silently gives never for a conflicting field, and the error appears only when you try to create a value. An interface merges when declared again (declaration merging), while type gives Duplicate identifier. A practical rule: interface for object shapes and extending external types, type for union, mapped and conditional types.

    Follow-up question: How do you add a user field to Request in a Node project through declaration merging, and where do you put the file so that tsc picks it up?

  2. 2. JSON.parse returns any. How do any, unknown and never differ? What type should a function that reads a payload from a queue return?

    Answer

    any turns off checking and infects everything it touches. unknown accepts anything but does not let you do anything without narrowing. never is a value that does not exist: the bottom type that appears in exhaustive switch statements and in functions that only throw. A payload from a queue is typed as unknown and narrowed through validation, not through as.

    Follow-up question: A project has 400 explicit any. How do you migrate them to unknown gradually without stopping development?

  3. 3. refunded was added to the events union, but the handler was not updated. How do you make the compiler catch this, and why is it better than default: return 'unknown'?

    Answer

    For a discriminated union, a switch on the type field narrows the type in each branch. In default only the unhandled variant remains, and with full coverage that is never. If you call assertNever(e) there, a new event becomes a compile error (the value is not assignable to never). A silent default hides the new event instead, and the bug is found in production.

    Follow-up question: The producer already sends refunded, but the consumer is deployed with the old union. What happens at runtime and how do you handle it without losing messages?

  4. 4. A custom type guard x is User lets through an object without email, the code compiles without errors, and in production it fails. Who is to blame and how do you fix it?

    Answer

    A type predicate is the function author's promise, the compiler does not check its body. If the guard checks only id, it lets through an object without email, and then user.email.toLowerCase() throws a TypeError. Fix: check every required field or use a schema (zod safeParse, class-validator). A separate test for the guard catches the case when it became outdated after adding a new required field.

    Follow-up question: How do you write a test that catches a guard that became outdated after a new required field was added to User?

  5. 5. How do you type pluck(items, key) so that the key is checked and the result has the right type? What are K extends keyof T and T[K]?

    Answer

    The signature is function pluck<T, K extends keyof T>(items: T[], key: K): T[K][]. T is inferred from the array, K from the key literal, and T[K] is an indexed access type: the type of the field. The constraint K extends keyof T gives both autocomplete and an error if the field name is misspelled. For pluck(users, 'age') the result is number[].

    Follow-up question: How do you type pick(obj, ['id', 'email']) so that the result has the type Pick<User, 'id' | 'email'>?

  6. 6. A function get<T>(key): T reads from process.env and returns <number>. Everything compiles, but at runtime it is a string. Why does the generic guarantee nothing here, and how is it fixed?

    Answer

    process.env always holds strings. A generic only in the return type is a hidden as: the compiler trusts <number>, and there is no runtime check. The right way is to validate and convert environment variables at application start, for example with a schema, and access them through a typed config. Then PORT=abc breaks startup immediately, not on the first request.

    Follow-up question: The app started in staging with PORT=abc and crashed only on the first request. Where exactly in bootstrap do you put the check?

  7. 7. Why does Omit<User, 'pasword'> with a typo compile while Pick<User, 'pasword'> does not? What follows for a response DTO when the field passwordHash is renamed?

    Answer

    The second parameter of Omit is constrained by keyof any, not keyof T, so a typo is not an error. Pick requires K extends keyof T and catches it. If passwordHash is renamed, Omit<User, 'passwordHash'> silently stops excluding anything, and the hash goes into the response. Fix: your own StrictOmit<T, K extends keyof T> or Pick with an explicit list of allowed fields (a whitelist instead of a blacklist).

    Follow-up question: Why did the TypeScript authors deliberately make Omit non-strict, and in which generic cases does a strict variant get in the way?

  8. 8. How do you get the result type of an async service method without duplication? Why are Awaited and NonNullable needed here?

    Answer

    ReturnType<typeof service.find> for an async method gives Promise<User | null>, not User. Awaited unwraps the Promise, and NonNullable removes null and undefined, so NonNullable<Awaited<ReturnType<...>>> is User. This way the type comes straight from the service signature and cannot drift from it. For overloaded functions ReturnType takes only the last signature.

    Follow-up question: What does ReturnType give for an overloaded function with three signatures?

  9. 9. How do you get, using a mapped type with as, a type Getters<User> with the keys getName and getAge?

    Answer

    A mapped type with key remapping: { [K in keyof T as get${Capitalize<string & K>}]: () => T[K] }. The as part transforms the key, and string & K drops keys of type symbol and number so that Capitalize works. For User { name: string; age: number } you get getName: () => string and getAge: () => number. This pattern is used for getters, events and filtering fields by type.

    Follow-up question: How do you get from an entity class, through key remapping, a type with only data fields, without methods?

  10. 10. What does infer do in a conditional type? How do you get the parameter type and return type of a handler without writing them by hand?

    Answer

    infer introduces a type variable inside extends and fills it when the pattern matches. For example, T extends (p: infer P) => infer R ? [P, R] : never extracts the parameter type and the return type. The built-in Parameters, ReturnType and Awaited are made the same way. This lets you derive types from existing functions instead of duplicating them, and keeps them consistent when things change.

    Follow-up question: How do you get the element type of an array and the value type of a Map<string, Order> through infer?

  11. 11. How does private in TypeScript differ from #field in JavaScript? What will JSON.stringify show for each?

    Answer

    private is checked only by the compiler and erased, so at runtime the field is ordinary: JSON.stringify, Object.keys and logs show it. #pin is a truly private JavaScript field, inaccessible from outside at runtime, and serialization does not see it. Consequence: a private apiKey easily ends up in an API response or a log, so for secrets #field is safer. With private fields Proxy and spies can throw a TypeError.

    Follow-up question: Why can a Proxy on an object with a #private field throw a TypeError when a method is called, and how do you avoid it?

  12. 12. Why does the variable e in catch (e) have the type unknown under strict, and how do you correctly get the error message?

    Answer

    Under strict, useUnknownInCatchVariables applies: throw can throw anything, even a string or a number, so unknown is the honest type. Accessing e.message without a check gives a compile error, and at runtime for a thrown string it would be undefined. The right way: e instanceof Error ? e.message : String(e). catch (e: any) only hides the problem.

    Follow-up question: How do you type ORM errors (for example, a unique violation) in a service so that you convert them into a domain error without pulling the ORM into the controller?

  13. 13. Does strict: true catch access to a nonexistent array element or map key? What does noUncheckedIndexedAccess change?

    Answer

    No. With strict, arr[0] has the element type and byId['u1'] the value type, although both can be undefined at runtime, so there are zero compile errors. The noUncheckedIndexedAccess flag adds | undefined to such reads, and then the compiler forces checks. It is not part of strict and is enabled separately. The cost: noise in index loops, which is cured with for...of, at() with a check, or an explicit branch.

    Follow-up question: How do you enable noUncheckedIndexedAccess gradually in a large monorepo without stopping development?

  14. 14. tsconfig has paths: { '@app/*': ['src/*'] }, tsc passes, but node dist/main.js fails in Docker. What is the error and why?

    Answer

    The error is Cannot find module '@app/...' (MODULE_NOT_FOUND). paths is only a hint for resolving types: tsc does not rewrite the specifier in the generated JS. Locally it often works through ts-node with tsconfig-paths or a dev build server, while plain tsc in Docker does not. Fix: subpath imports (#app/*) in package.json, tsconfig-paths/register, tsc-alias or a bundler. In CI it is worth checking that the built dist really starts.

    Follow-up question: How do you configure Jest so that tests see the same aliases?

  15. 15. How does a numeric enum differ from a string one at runtime? Why does enum break when running .ts through type stripping?

    Answer

    A numeric enum creates a reverse mapping: Object.keys(Status) gives ['0', '1', 'Active', 'Blocked'], and Status[0] returns 'Active'. A string enum has no reverse mapping. An enum generates real runtime code, so with type stripping Node cannot simply erase it and reports an unsupported syntax error. Alternative: a union of literals with an as const object. Also a numeric enum is dangerous if numbers are stored in the DB and a new value is inserted in the middle.

    Follow-up question: A status is stored in the DB as a number from a numeric enum. Someone inserted a new status in the middle. What happens to the existing data?

  16. 16. Why does Object.keys(user) return string[] and not (keyof User)[]? Why is the fix through as (keyof User)[] dangerous?

    Answer

    TypeScript types structurally, so at runtime an object may have extra keys that the type does not describe. string[] is the honest answer. The cast as (keyof User)[] compiles but may let through, for example, loginCount, and later indexed access will give undefined. Safer: an explicit list of keys, Object.entries with a check, or a Map.

    Follow-up question: How do you write a typed function entries<T>(obj: T), and why will its type also be partly a lie?

  17. 17. The code compiles under strict, review passed, and in prod it fails for some users. Which two places with as and ! most often 'lie', and what do you do about it?

    Answer

    as User does not check the data, it only turns off type checking: if the real object is different, the error comes at runtime. ! removes undefined from the type with no runtime check, so user.profile!.email fails if there is no profile. Fix: validate unknown with a schema, use ?. and an explicit branch for the missing value. A useful lint rule: no-non-null-assertion and limiting as to system boundaries.

    Follow-up question: How do as const and satisfies differ from a plain as in terms of type safety?

  18. 18. An import endpoint responds 200 before the records are saved, and save errors are not visible anywhere. Inside is items.forEach(async (i) => await save(i)). Why did TypeScript not catch this?

    Answer

    forEach does not wait for the promises from an async callback and returns undefined, so the code moves on and the response is sent earlier. TypeScript allows a callback that returns a Promise where void is expected. A rejected promise is handled by nobody: it is an unhandledRejection, which in Node 15+ can crash the process. Fix: for...of with await or Promise.all (with limited concurrency), plus the lint rules no-floating-promises and no-misused-promises.

    Follow-up question: For an import of 50 thousand rows: how do you limit concurrency to 10 and not keep the HTTP request open for minutes?

Senior questions

  1. 1. Why does an assertion function asserts v is ... work when declared with function, but not when the same signature is in a const with an arrow?

    Answer

    For narrowing through asserts, TypeScript requires the call name to have an explicit type annotation. A function declaration has an explicit signature, while const x = (v) => ... gets an inferred type, and the compiler reports that an explicit annotation is needed. So the narrowing does not apply and later field access is still considered unsafe. Fix: function or an explicit annotation const assertOk: <T>(v: T) => asserts v is NonNullable<T>.

    Follow-up question: When would you choose an assertion function that throws in a service, and when would you return null?

  2. 2. A value has already been checked in if, but inside a forEach callback the compiler again considers it possibly undefined. Why, and what changed in TypeScript 5.4?

    Answer

    Narrowing of a property (ctx.user) does not survive a callback, because the compiler cannot guarantee the property will not be changed between the check and the call. For let the problem is that the variable may be reassigned after the closure is created. Since TypeScript 5.4, narrowing is preserved in closures if there are no assignments to the variable after it. For properties the reliable fix is const user = ctx.user before the if.

    Follow-up question: Is narrowing preserved inside an async callback after await?

  3. 3. How does TypeScript infer T from several arguments? Why does merge(a, b) compile for two objects but merge(1, '1') does not? How do you require b to be the same type as a?

    Answer

    The compiler collects candidates for T from the arguments. For objects it can combine the shapes into a union, so the call compiles. For primitives T is fixed by the first candidate (1), and the second argument '1' does not fit. NoInfer<T> (TypeScript 5.4) removes a position from the inference candidates, so b: NoInfer<T> must match the T already inferred from a.

    Follow-up question: How does a const type parameter (function f<const T>(x: T)) work and where is it useful?

  4. 4. A PATCH accepts Partial<User> and does { ...user, ...patch }. The types pass, but the user's name disappears. Why, and which tsconfig flag catches it?

    Answer

    Partial makes a field optional, and an optional field without an extra flag allows an explicit undefined value. Spread with undefined overwrites the existing value, while the result type is still User. The exactOptionalPropertyTypes flag forbids an explicit undefined for optional fields and gives an error at the call. In PATCH you should strip undefined before merging or use a whitelist of fields and explicit mapping.

    Follow-up question: How do you distinguish three states of a field in PATCH: not provided, provided as null, provided with a value?

  5. 5. What is a distributive conditional type? Why does ToArray<string | number> give string[] | number[], while a version with the [T] wrapper gives (string | number)[]?

    Answer

    A conditional type with a 'naked' parameter T is distributive over a union: it is applied to each member separately, and the results are combined. So T extends unknown ? T[] : never for string | number gives string[] | number[]. The wrapper [T] extends [unknown] turns off distribution and treats the union as a whole, hence (string | number)[]. Exclude and Extract are built on distribution.

    Follow-up question: Write a type that extracts from a union of events only those that have an amount field, and explain where distribution works.

  6. 6. How do you use template literal types to type route parameters like /orgs/:orgId/users/:userId? What is the cost of such recursion?

    Answer

    A recursive conditional type splits the string into segments through infer and collects the parameter names into a union: 'orgId' | 'userId'. Then the function requires an object with these keys, and omitting userId gives an error. But these are only types: at runtime node checks nothing. The cost: deep recursion can hit a limit (TS2589) and slow down the IDE, so such types are kept simple.

    Follow-up question: How do you extend the type so that the parameter :id? becomes an optional key in the resulting object?

  7. 7. package.json has "type": "module", and in tsconfig both module and moduleResolution are nodenext. Why does import './users' give an error, and why do you write ./users.js in a .ts file?

    Answer

    nodenext models the real Node resolver: in ESM a relative import needs an extension, so tsc gives an error with the hint ./users.js. Node itself does not guess extensions and would throw ERR_MODULE_NOT_FOUND. You write .js in .ts because the import points to the file after compilation. The bundler mode allows imports without extensions, but only when the code goes through a bundler. For running through type stripping you write .ts in imports and enable rewriteRelativeImportExtensions.

    Follow-up question: A CommonJS project has a dependency that became ESM only. What options are there?

  8. 8. What are branded types and why are they used for identifiers like UserId and OrderId? Does the brand exist at runtime?

    Answer

    A branded type is a primitive with a 'tag': string & { __brand: 'OrderId' }. This way a UserId cannot be passed where an OrderId is expected, and a bare string does not fit either, which catches mixed up arguments in an order service. The brand exists only in the type: at runtime it is an ordinary string. The tag is applied in one place, after a check (parsing), not through as in every call.

    Follow-up question: How do you carry a branded id from a route parameter through a pipe into a service and back into a response without as in controllers?

  9. 9. Why does TypeScript under strictFunctionTypes catch an unsafe assignment of a handler declared as a function property, but let the same pass for a method?

    Answer

    A function property (handler: (e: Event) => void) is checked contravariantly on parameters, so a narrower handler cannot be assigned. Method parameters (handler(e: Event): void) stay bivariant even with strictFunctionTypes, so the unsafe assignment compiles, and at runtime the handler receives an event without the needed field and fails. Fix: declare callbacks as function properties and register narrow handlers by event type. It is a similar story with Array<Dog> being assignable to Array<Animal>.

    Follow-up question: Why is Array<Dog> assignable to Array<Animal>, and how does this break code that does push into such an array?

  10. 10. In Nest with emitDecoratorMetadata, a dependency imported through import type breaks DI. What exactly does tsc generate in design:paramtypes for an interface and for import type?

    Answer

    For an interface the compiler writes Object, because interfaces are erased. For a class imported through import type the reference to the class disappears, and the metadata will hold Function or Object. The DI container reads design:paramtypes through reflect-metadata and looks for a provider by that value, so Object and Function are not tokens, and startup fails with an unresolved dependencies error. Fix: a regular import of the class, @Inject with an explicit token for an interface, forwardRef for a cycle.

    Follow-up question: How do you configure the consistent-type-imports rule so that it does not break DI?

  11. 11. A payment service has expected failures (card declined, insufficient funds) and unexpected ones (network, a bug). How do you type them: exceptions or Result? How do they reach the HTTP response?

    Answer

    I separate expected business failures from unexpected faults. Expected ones I return as a Result, a discriminated union: the compiler forces handling of every variant. Unexpected ones stay as exceptions: a global exception filter returns 500 and logs. I map a domain error to an HTTP status at the boundary (a controller or a filter), and the domain layer does not throw HTTP exceptions. Expected failures should also be counted in metrics separately from 5xx, so that they do not wake the on-call engineer.

    Follow-up question: How do you log and count expected failures in metrics so that they do not mix with 5xx?

  12. 12. A team wants a base generic repository Repository<T extends Entity> for all services. What is good about it in terms of types, what does tsc catch, and where do you stop generalizing?

    Answer

    The constraint T extends Entity guarantees the presence of id, and a default type parameter allows Repository without an argument. A method findBy<K extends keyof T>(key: K, value: T[K]) catches invalid keys and values, and a type without id fails the constraint. The limit of generalization: specific queries live in concrete repositories, and the base class is not bloated. Also, do not duplicate the ORM's own Repository<T> and do not leak ORM types into the domain layer.

    Follow-up question: How do you register several instances of a generic repository for different entities in Nest and inject them without token collisions?

Train these questions

Study the cards, answer by text or voice and get an AI grade. Needs a signed in account.

Answer out loud and get graded

Here you get the questions with model answers. In the ShortLesson app you answer yourself, by text or voice, and AI grades the answer against the rubric and asks a follow-up, like a real interviewer.

The app is coming to the stores soon.

Other topics