Full-Stack Questions

TypeScript Interview Questions

Generics, narrowing, utility types, tsconfig flags, typing React and APIs, and the errors that confuse people.

Lesson 0331 min read#typescript

On this page

  1. Words used in this lesson
  2. What changed in TypeScript 6 and 7
  3. The tsconfig flags that matter
  4. Modules: bundler vs nodenext
  5. unknown, any and never, and enums
  6. Declaration merging and declaration files
  7. Traps in narrowing and indexing
  8. Generic tools beyond the basics
  9. Type-safe API layers
  10. Typing React
  11. Speed: the type checker, tsc -b and project references
  12. Common errors and what they usually mean
  13. Common mistakes
  14. Interview questions
  15. Key takeaways
  16. What's next

In one line: at 4+ years, TypeScript questions are less about clever types and more about setup and boundaries: which tsconfig flags you trust, how modules resolve, where outside data enters (so you validate it), how you type React and your API layer, and how you keep the checker fast. This lesson adds the new angles. The core type tools are already in Frontend Depth, Lesson 04.

Lesson 04 of Frontend Depth covers unknown vs any, generics with extends and keyof, narrowing, discriminated unions, utility types, mapped and conditional types, infer, satisfies, type vs interface and branded types. Read it first. This lesson does not repeat it. It adds what interviewers ask next: the TypeScript 6 and 7 changes, tsconfig and modules, never and enums, declaration merging, narrowing traps, typed API layers, React patterns, speed and error messages.

I compiled every snippet here with the TypeScript installed in this repo (7.0.2), with Zod 4.6, Prisma 7.10 and @types/react 19.3. Error messages are copied from real runs.

Words used in this lesson

  • TypeScript is JavaScript plus a type checker: a program that reads your code before it runs and reports places where a value does not fit its type. The types are erased, which means they do not exist in the JavaScript that runs.
  • tsc is the TypeScript compiler. It checks types and can also write JavaScript.
  • tsconfig.json is the settings file for tsc and for your editor. A compiler option (or flag) is one setting in it.
  • A module is a file that has import or export. Module resolution is how the compiler turns import x from "./x" or "zod" into a real file.
  • A declaration file (.d.ts) is a file with types only and no code. It describes JavaScript that already exists.
  • An ambient declaration is a declare ... line that tells TypeScript "this exists at run time, trust me".
  • A project reference is a link from one tsconfig to another, so TypeScript can build a large repository in pieces.

What changed in TypeScript 6 and 7

TypeScript 6.0 was a transition release. TypeScript 7.0 is the new native compiler: it is a rewrite, not a patch, and it supports parallel work (--checkers, --builders, --singleThreaded). The type rules are meant to stay the same. The defaults and old options changed a lot, and much interview material is still from before this.

Old beliefWhat is true now (I ran TypeScript 7.0.2)
"strict is off unless you turn it on."strict is on by default. --help shows strict ... default: true. A new file with function f(x) {} fails with TS7006 (implicit any) without any config
"@types/* packages are picked up automatically."types now defaults to empty. Without "types": ["node"] you get TS2591: Cannot find name 'process' even with @types/node installed. "types": ["*"] brings back the old behaviour
"Use baseUrl with paths."baseUrl is removed in 7.0 (TS5102). Write the prefix in paths: "@/*": ["./src/*"]
"moduleResolution: node is the normal choice."node10 is removed (TS5108). Use bundler (apps with a bundler) or nodenext (code that Node runs)
"esModuleInterop: false is allowed."It cannot be false any more (TS5108)
"rootDir is guessed from your files."The default is the folder with tsconfig.json. With files in src/, output goes to dist/src/ or you get TS5011 until you set "rootDir": "src"
"tsc file.ts ignores tsconfig.json."It is an error now (TS5112) unless you add --ignoreConfig
"outFile, amd, umd, target: es5 are normal."Deprecated or removed. The TypeScript team says bundlers do that job

If an interviewer says "strict is false by default" or "we set baseUrl to src", say what changed. Add that the team announced these as TypeScript 6 deprecations, so many older projects still carry them with "ignoreDeprecations": "6.0", and 7.0 does not support that escape.

For a fresh project, tsc --init (I ran it) now writes a small, opinionated file: module: nodenext, target: esnext, types: [], strict, noUncheckedIndexedAccess, exactOptionalPropertyTypes, verbatimModuleSyntax, isolatedModules, noUncheckedSideEffectImports, moduleDetection: force, skipLibCheck, jsx: react-jsx. That list is a good checklist to talk through.

The tsconfig flags that matter

FlagWhat it doesWhy a senior turns it on (and the cost)
strictA group of checks: strictNullChecks, noImplicitAny, useUnknownInCatchVariables and moreDefault now. Never turn it off for new code
noUncheckedIndexedAccessarr[0] and obj[key] get `undefined`
exactOptionalPropertyTypesx?: T means "missing", not "missing or undefined"Honest types for PATCH bodies. Cost: { theme: undefined } is an error (TS2375), which annoys when you spread values
noImplicitOverrideA subclass must write overrideSafe renames in class hierarchies
verbatimModuleSyntaxImports and exports are kept as you wrote them. Type-only imports must say import typeMakes output predictable for bundlers and ESM. Error TS1484 tells you to add type
isolatedModulesForbids code that needs other files to compile (for example some const enum and re-exports of types)Needed for Babel, SWC, esbuild and Next.js, which compile one file at a time
erasableSyntaxOnlyForbids syntax that is not just erasable types: enum, namespace with code, parameter properties (TS1294)Needed to run .ts files directly in Node with type stripping
skipLibCheckDo not type-check .d.ts filesFaster, and avoids errors inside dependencies. Cost: a broken dependency type is hidden
noEmitCheck only, write nothingUse it when a bundler (Next.js, Vite) does the compiling

Two real configs that compiled for me. A Next.js style app (the paths line has no baseUrl):

JavaScript
{  "compilerOptions": {    "target": "ES2022",    "lib": ["dom", "dom.iterable", "esnext"],    "module": "esnext",    "moduleResolution": "bundler",    "jsx": "react-jsx",    "strict": true,    "noUncheckedIndexedAccess": true,    "verbatimModuleSyntax": true,    "isolatedModules": true,    "noEmit": true,    "incremental": true,    "skipLibCheck": true,    "types": ["node"],    "paths": { "@/*": ["./src/*"] }  },  "include": ["**/*.ts", "**/*.tsx"],  "exclude": ["node_modules"]}

A Node service that tsc builds and Node runs as ES modules:

JavaScript
{  "compilerOptions": {    "module": "nodenext",    "target": "es2023",    "lib": ["es2023"],    "types": ["node"],    "rootDir": "src",    "outDir": "dist",    "strict": true,    "noUncheckedIndexedAccess": true,    "exactOptionalPropertyTypes": true,    "verbatimModuleSyntax": true,    "isolatedModules": true,    "skipLibCheck": true,    "sourceMap": true  },  "include": ["src"]}

tsc still writes JavaScript when there are type errors, unless you set noEmitOnError. I saw dist/ files appear next to red errors. CI must run the type check as its own step and fail on it.

Modules: bundler vs nodenext

  • moduleResolution: "bundler" models what Vite, webpack, esbuild and Next.js do. Relative imports need no file extension, and package.json exports and imports work. Use it with module: "esnext" or "preserve" when a bundler produces the final code.
  • moduleResolution: "nodenext" (with module: "nodenext") models real Node.js. In an ES module package ("type": "module"), relative imports must include the extension, and you write .js even though the file is .ts:
JavaScript
// src/a.ts, in a "type": "module" package with module: nodenextimport { defaultUser } from "./types.js";   // OKimport { defaultUser } from "./types";      // TS2835: Relative import paths need explicit file extensions ...

The .js looks wrong at first. The reason is that TypeScript does not rewrite import paths. The file Node runs is types.js, so the import must name it. There is also --module node20, a frozen model of Node 20 rules, for people who do not want the rules to move.

"Cannot find module" checklist (TS2307):

  1. Is the package installed, and does it ship types, or do you need @types/...?
  2. Does moduleResolution match how the code really runs (bundler or nodenext)?
  3. Is the extension right for nodenext?
  4. Do paths entries start with ./ (needed since baseUrl is gone)?
  5. For non-code files (*.svg, *.css), do you have an ambient declaration? (see below)
  6. Is "types" hiding a global package such as node?

unknown, any and never, and enums

Lesson 04 explains unknown vs any. The third one, never, is a favourite follow-up.

TypeMeaningIn one line
anySkip checking"Trust me." It spreads
unknownAny value, but you must check before using it"I don't know yet." Safe top type
neverNo value is possible"This cannot happen." The empty type

never is the bottom type: it fits into every type, and nothing fits into it. Where it shows up:

JavaScript
type A = string | never;     // string: never disappears from a uniontype B = string & never;     // never: nothing can be bothfunction assertNever(x: never): never {  throw new Error(`Unexpected: ${String(x)}`);}type Status = "idle" | "loading" | "done";function label(s: Status): string {  switch (s) {    case "idle": return "Idle";    case "loading": return "Loading";    case "done": return "Done";    default: return assertNever(s);   // adding "failed" to Status makes this line a compile error  }}

A function that always throws also returns never. And when you narrow a union to nothing, TypeScript says so: after both if (s === "a") and if (s === "b") return, s.toUpperCase() gives TS2339: Property 'toUpperCase' does not exist on type 'never'. That error means your checks already covered every case.

Enums vs a union of strings

An enum is a TypeScript feature that creates a real object at run time, with named constants. It is one of the few TypeScript features that is not just types. I compiled this and read the JavaScript:

JavaScript
enum Role { Admin, Editor }// emits:// var Role; (function (Role) { Role[Role["Admin"] = 0] = "Admin"; Role[Role["Editor"] = 1] = "Editor"; })(Role || (Role = {}));

A numeric enum also builds a reverse map (Role[0] is "Admin"), so Object.keys(Role) has both numbers and names. That surprises people. The alternative is a plain object plus a union type:

JavaScript
const ROLE = { Admin: "admin", Editor: "editor" } as const;type RoleName = (typeof ROLE)[keyof typeof ROLE];   // "admin" | "editor"
enumas const object + union
Run-time codeYes (extra JavaScript)Only your plain object
Works with erasableSyntaxOnly and Node type strippingNo (TS1294)Yes
Value in JSON/DBNumber or string dependsA readable string
Strings from outside"RED" is not assignable to a string enum (TS2322)A plain string is fine, if it is in the union
const enumNeeds care with isolatedModules and with librariesNot needed

My default is the object plus union. I still use an enum when a codebase already has them. Rewriting them has no big payoff. One more safety note: in current TypeScript, assigning 5 to a variable of numeric enum type Role is an error (TS2322), because all enums are union enums.

Declaration merging and declaration files

Declaration merging means TypeScript joins several declarations that have the same name into one. Interfaces merge. Types do not.

JavaScript
interface Config { port: number }interface Config { host: string }              // merges: Config has port and hostconst c: Config = { port: 1, host: "x" };

The practical use is augmentation: adding fields to a type that someone else owns.

JavaScript
// Add your own environment variables and window globalsdeclare global {  interface Window { analytics?: { track(e: string): void } }  namespace NodeJS {    interface ProcessEnv { DATABASE_URL: string; NODE_ENV: "development" | "production" | "test" }  }}const url: string = process.env.DATABASE_URL;   // string, not string | undefined
JavaScript
// Add a field to an interface of a library moduleimport "./lib-a";declare module "./lib-a" {  interface Options { debug?: boolean }}

For files that are not code, write an ambient module in a .d.ts file:

JavaScript
// ambient.d.tsdeclare module "*.svg" { const src: string; export default src; }

Typing process.env.X as string is a promise, not a check. If the variable is missing at run time you still get undefined. Validate environment variables once at start-up with a schema (such as Zod), then export a typed env object.

A .d.ts file only needs to be part of the include list. Libraries ship their own .d.ts files (the types field or exports in package.json), and older libraries use the community @types/* packages (DefinitelyTyped).

Traps in narrowing and indexing

These are small, common and good for interviews. I reproduced each one.

1. Object.keys returns string[]. TypeScript is careful: an object may have more keys at run time than its type says.

JavaScript
const user = { id: 1, name: "Ada" };for (const k of Object.keys(user)) {  // user[k]  -> TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index ...}for (const k of Object.keys(user) as (keyof typeof user)[]) {  console.log(user[k]);       // OK, but the cast is your promise}

2. filter and type predicates. Since TypeScript 5.5, a simple callback like x => x !== null is inferred as a type guard. filter(Boolean) is not:

JavaScript
const raw: (string | null)[] = ["a", null, "b"];const a = raw.filter((x) => x !== null);   // string[]const b = raw.filter(Boolean);             // (string | null)[]  still

3. Narrowing is lost in a closure when the variable is reassigned.

JavaScript
let user = get();                           // U | nullif (user) {  setTimeout(() => console.log(user.name)); // TS18047: 'user' is possibly 'null'}user = null;                                // this later assignment is the cause// Fix: use const, or copy to a const inside the if

4. catch (e) is unknown under strict. Narrow with e instanceof Error before you read e.message.

5. noUncheckedIndexedAccess: const first = arr[0] is number | undefined. Check it, or destructure with a default.

6. Excess property checks only run on fresh object literals. const u: User = { id: "1", nme: "Ada" } fails (TS2561, with a "did you mean name?" hint). But the same object put in a variable first, then assigned, passes. That is normal structural typing.

Generic tools beyond the basics

Four newer tools interviewers like, all compiled:

JavaScript
// 1. const type parameter (5.0): keep literal types without "as const" at the call sitefunction defineRoutes<const T extends readonly { path: string }[]>(routes: T): T { return routes; }const routes = defineRoutes([{ path: "/" }, { path: "/admin" }]);type RoutePath = (typeof routes)[number]["path"];     // "/" | "/admin"// 2. NoInfer (5.4): do not let this argument widen Tfunction pick<T extends string>(options: T[], initial: NoInfer<T>): T { return initial; }pick(["red", "green"], "red");// pick(["red", "green"], "blue");   // error: "blue" is not one of the options// 3. Stop a conditional type from spreading over a union with [T]type IsNever<T> = [T] extends [never] ? true : false;     // IsNever<never> is truetype Bad<T> = T extends never ? true : false;             // Bad<never> is never (empty union, nothing to spread over)// 4. Pull route params out of a path with template literal types and infertype ParamNames<P extends string> =  P extends `${string}:${infer Name}/${infer Rest}` ? Name | ParamNames<`/${Rest}`>  : P extends `${string}:${infer Name}` ? Name  : never;type Params<P extends string> = { [K in ParamNames<P>]: string };declare function href<P extends string>(path: P, params: Params<P>): string;href("/users/:id/posts/:postId", { id: "1", postId: "2" });   // OK// href("/users/:id/posts/:postId", { id: "1" });             // error: postId is missing

The fourth one is the idea behind typed routers. Also know Prettify<T>, written { [K in keyof T]: T[K] } & {}. It flattens A & B so the editor shows one clean object. Too much type-level work has a price: very deep types give TS2589: Type instantiation is excessively deep and possibly infinite (I got it from a 2000-step recursive type).

Type-safe API layers

Zod: one schema, input and output types

Zod is a library where you write a schema (a description of valid data) and get both a run-time check and a TypeScript type from it. The part many people miss: a schema can have two different types, input and output, when it uses coerce or transform.

JavaScript
import { z } from "zod";const CreateOrder = z.object({  email: z.email(),  qty: z.coerce.number().int().min(1),                      // form data arrives as a string  createdAt: z.string().transform((s) => new Date(s)),      // string in, Date out});type OrderIn = z.input<typeof CreateOrder>;     // what the client sendstype OrderOut = z.output<typeof CreateOrder>;   // what your code gets; same as z.inferconst result = CreateOrder.safeParse({ email: "a@b.co", qty: "2", createdAt: "2026-01-01" });if (result.success) {  const d: OrderOut = result.data;              // qty: number, createdAt: Date} else {  console.log(result.error.issues.map((i) => i.path.join(".") + ": " + i.message));}

Use safeParse at the edge (a route handler, a server action) so a bad body becomes a 400, not a crash. Use z.discriminatedUnion("type", [...]) for tagged unions, and reuse z.infer for your function types.

The idea behind tRPC

tRPC is a library that lets a client call server functions with full types and no code generation. The idea fits in a few lines: the server exports only the type of its router, and the client imports only that type.

JavaScript
type Handler<I, O> = (input: I) => Promise<O>;function procedure<I, O>(fn: Handler<I, O>) { return fn; }// serverconst appRouter = {  userById: procedure(async (id: string) => ({ id, name: "Ada" })),  createPost: procedure(async (input: { title: string }) => ({ ok: true as const, title: input.title })),};export type AppRouter = typeof appRouter;             // the only export the client needs// client: maps every procedure to a function with the same input and outputtype Client<R> = { [K in keyof R]: R[K] extends Handler<infer I, infer O> ? (input: I) => Promise<O> : never };declare const client: Client<AppRouter>;const u = await client.userById("1");                 // { id: string; name: string }// await client.createPost({ title: 1 });             // error: wrong input type

The trade-offs: it is great in a TypeScript monorepo (one repository with several projects). It couples the client to the server's types, it only helps TypeScript clients, and the real library adds validation, so inputs are still checked at run time. Compare with OpenAPI, which suits public APIs and other languages.

Prisma types

Prisma (Lesson 08) generates types from your schema. Do not hand-write "user with posts". Build it from the query:

JavaScript
import { Prisma } from "../generated/client";const userWithPosts = { include: { posts: true } } satisfies Prisma.UserDefaultArgs;type UserWithPosts = Prisma.UserGetPayload<typeof userWithPosts>;   // User & { posts: Post[] }const publicUser = { select: { id: true, name: true } } satisfies Prisma.UserDefaultArgs;type PublicUser = Prisma.UserGetPayload<typeof publicUser>;          // { id: string; name: string | null }// publicUser.email -> error: email was not selected

satisfies checks the query shape without widening it, so GetPayload can compute the exact result type. The same object can then be passed to prisma.user.findMany(userWithPosts). In Prisma 7 the generated client goes into an output folder that you choose in the generator block, so the import path above depends on your setup. Never send a database model straight to the browser: map it to a public type (and drop fields such as passwordHash).

Typing React

These patterns compiled against @types/react 19.3.

JavaScript
import { useRef, useState, type ComponentProps, type ReactNode, type ChangeEvent, type MouseEvent } from "react";// 1. Props and children. ReactNode is "anything React can render".type CardProps = { title: string; children: ReactNode };function Card({ title, children }: CardProps) {  return <section><h2>{title}</h2>{children}</section>;}// 2. Wrap a native element: take every <button> prop and add your owntype ButtonProps = ComponentProps<"button"> & { variant?: "primary" | "ghost" };function Button({ variant = "primary", className, ...rest }: ButtonProps) {  return <button className={`${variant} ${className ?? ""}`} {...rest} />;}// 3. Generic component: the item type flows into the render functiontype ListProps<T> = { items: readonly T[]; getKey: (item: T) => string; render: (item: T) => ReactNode };function List<T>({ items, getKey, render }: ListProps<T>) {  return <ul>{items.map((i) => <li key={getKey(i)}>{render(i)}</li>)}</ul>;}// <List items={[{ id: 1, n: "a" }]} getKey={(i) => String(i.id)} render={(i) => i.n.toUpperCase()} />// 4. Ref as a normal prop (React 19): no forwardReffunction TextField({ ref, ...rest }: ComponentProps<"input">) { return <input ref={ref} {...rest} />; }// 5. State, refs and eventsfunction Form() {  const [user, setUser] = useState<{ id: string } | null>(null);   // say the type when the start value is null  const inputRef = useRef<HTMLInputElement>(null);                 // a DOM ref starts as null  const onChange = (e: ChangeEvent<HTMLInputElement>) => console.log(e.target.value);  const onClick = (e: MouseEvent<HTMLButtonElement>) => {    console.log(e.currentTarget.value);         // the button itself    inputRef.current?.focus();                  // current can be null    setUser({ id: "1" });  };  return <><input ref={inputRef} onChange={onChange} /><button value="go" onClick={onClick}>{user?.id ?? "go"}</button></>;}

Points to say out loud:

  • ComponentProps<"button"> is a type that gives you all the props of a native <button>. It is better than re-typing onClick, disabled and the rest. Add Omit<..., "x"> to remove one.
  • Do not use React.FC by default. Plain functions with typed props are clearer. The community cheat sheet says it is mostly a style choice today.
  • Refs. useRef<HTMLInputElement>(null) for DOM nodes. In current @types/react, useRef() with no argument is an error (TS2554: Expected 1 arguments, but got 0). Never use null! unless you accept a crash if the element is not rendered.
  • Events. e.currentTarget is the element you attached the handler to. e.target is whatever was clicked, typed only as EventTarget, so e.target.value on a click is an error. For onChange on an input, e.target.value is typed.
  • Generic components need function List<T>(...). Passing items lets TypeScript infer T, so render gets the item type.
  • Props that exclude each other use a union: ({ href: string } & AnchorProps) | ({ href?: undefined } & ButtonProps). Then <Clickable href="/x" disabled /> fails, because disabled is not an anchor prop.
  • Context needs a safe hook: create the context with null as the default, and throw a clear error in useTheme if it is still null. That keeps null out of every component.

Speed: the type checker, tsc -b and project references

Type checking slows down when the program is big, when types are heavy, or when too many files are in scope. Measure first.

  • tsc --extendedDiagnostics prints files, lines, types, memory and times for parse, bind, check and emit. A small run of mine: 333 files (almost all from lib and @types), check time 0.09 s.
  • tsc --generateTrace dir writes a trace you can open in a browser profiler (it also lists the biggest types). Use it to find one slow file or type.
  • tsc --listFilesOnly shows what is in the program. If it lists hundreds of @types/* files that you do not use, set "types". The TypeScript team says some projects built 20 to 50 percent faster after setting types well.
  • Real causes of slowness: giant unions, deep recursive types, heavy intersection chains, inferred return types for huge objects, and big generated types (for example from GraphQL). Prefer interface extends or simple object types over long & chains, and write explicit return types on exported functions. This is advice commonly given, but measure it with a trace in your own code.
  • isolatedDeclarations makes you annotate exported APIs, so other tools can create .d.ts files without full inference. For example, export function add(a: number, b: number) { return a + b } fails with TS9013 until you write : number.
  • The native TypeScript 7 compiler is much faster, and it can check in parallel with --checkers N and build projects in parallel with --builders N. I did not benchmark it. Check that your other tools (linters, code generators) work with 7 before you upgrade.

Project references split a repository into several tsconfig projects that depend on each other.

JavaScript
// packages/shared/tsconfig.json{ "compilerOptions": { "composite": true, "rootDir": "src", "outDir": "dist", "module": "esnext", "moduleResolution": "bundler" },  "include": ["src"] }// packages/api/tsconfig.json{ "compilerOptions": { "composite": true, "rootDir": "src", "outDir": "dist", "module": "esnext", "moduleResolution": "bundler" },  "include": ["src"],  "references": [{ "path": "../shared" }] }// root tsconfig.json{ "files": [], "references": [{ "path": "packages/shared" }, { "path": "packages/api" }] }

composite: true makes a project usable by others: it must have rootDir and a list of files, and it writes .d.ts files and a .tsbuildinfo file. Then tsc -b builds projects in order and skips the ones that are up to date. I ran it twice: the second run printed "Project ... is up to date because newest input ... is older than output", and when I changed one file it rebuilt only that project. Gains: faster rebuilds, less memory in the editor, and a hard rule that the lower project cannot import the upper one. Many Next.js apps do not need this. A monorepo with shared packages often does.

Common errors and what they usually mean

ErrorUsual causeFix
TS7053 Element implicitly has an any type because expression of type string can't be used to index...Indexing an object with a plain stringType the keys: Record<string, T>, keyof typeof obj, or a Map
TS2322 Type string | undefined is not assignable to type stringA possibly missing value (noUncheckedIndexedAccess, optional field, ?.)Check it, give a default (?? ""), or fix the source type
TS18046 x is of type unknownA value from catch, JSON.parse or an untyped boundaryNarrow it, or parse it with a schema
TS18047 / TS18048 x is possibly null / undefinedMissing check, or narrowing lost in a closureif, early return, const copy
TS2339 Property does not exist on type neverYou already handled every caseRemove the dead branch, or you made a wrong check
TS2345 / TS2769 Argument is not assignable / No overload matches this callWrong argument type. Read the last overload errorFix the argument, or the generic constraint
TS2307 Cannot find moduleResolution, extension, types or pathsSee the checklist above
TS1484 X is a type and must be imported using a type-only importverbatimModuleSyntax is onimport type { X } or import { type X }
TS2591 Cannot find name processtypes is emptyInstall @types/node and add "types": ["node"]
TS2589 Type instantiation is excessively deepA runaway recursive typeSimplify, add a depth counter, or stop at a base case

Common mistakes

  1. Trusting as. A cast is a promise. Use a schema at boundaries.
  2. Typing process.env and never validating it.
  3. Leaving strict family flags off "for now", or using // @ts-ignore where // @ts-expect-error (which fails when the error goes away) is safer.
  4. Copying an old tsconfig with baseUrl, moduleResolution: node or no types.
  5. Using enums out of habit and then hitting erasableSyntaxOnly or isolatedModules problems.
  6. Forgetting that tsc emits on error and not failing CI on the type check.
  7. Sending database model types to the UI, with fields that should stay private.

Interview questions

What changed in TypeScript 6 and 7 that older interview answers get wrong? (senior signal)

The big one is defaults. strict is on by default, types defaults to an empty list (so I must add "types": ["node"] myself), and rootDir is the config folder. Old options are gone: baseUrl, moduleResolution: node10, esModuleInterop: false, outFile, amd/umd. I use paths with a full prefix, and bundler or nodenext. TypeScript 7 is the native compiler, with --checkers and --builders for parallel work. Also tsc file.ts now errors when a tsconfig.json exists, unless I pass --ignoreConfig.

Which tsconfig flags do you use in a new project, and why? (must know)

strict, then noUncheckedIndexedAccess for honest array and map reads, verbatimModuleSyntax and isolatedModules for predictable ESM output and one-file-at-a-time compilers, noImplicitOverride for classes, skipLibCheck for speed, and an explicit types list. I consider exactOptionalPropertyTypes for API-shaped types, but it can be noisy with spread values. In an app with a bundler I set noEmit and moduleResolution: bundler. In a Node service I use nodenext. The point is to say what each flag protects against.

bundler or nodenext for moduleResolution? (senior signal)

It depends on who produces the final code. If a bundler (Vite, webpack, Next.js) does, I use bundler: no file extensions on relative imports, and exports and imports in package.json work. If Node runs the files tsc writes, I use nodenext, which copies Node's rules. In an ES module package that means I write ./file.js in imports even though the source is file.ts, because TypeScript does not rewrite paths. Choosing wrongly gives errors that are hard to understand, such as TS2835 or imports that pass type-check but fail at run time.

What does verbatimModuleSyntax do? What is TS1484? (must know)

It tells TypeScript to keep imports and exports exactly as written, and not to guess which imports to delete. So a type-only import must be marked import type { User } (or import { type User }). If not, I get TS1484. The benefit is that the JavaScript output is predictable, side-effect imports stay, and tools that compile one file at a time need no type information to know what to remove. It pairs with isolatedModules.

any vs unknown vs never: when do you use each? (must know)

unknown for data I do not control yet (JSON, catch, third-party input), and then I narrow it or validate it. any almost never, and when I do, only in a small spot with a comment. never for "this cannot happen": the return type of a function that always throws, and the default case of a switch where I assign the leftover to never so a new union member is a compile error. never disappears from unions (string | never is string), and a variable narrowed to never means my checks already covered every case.

Enum or union of string literals? (trap)

I use a const object with as const and a union type, because it adds no special run-time code, and it works with erasableSyntaxOnly, Node type stripping and one-file compilers. An enum creates a real object. A numeric enum also has a reverse map, so Object.keys gives both names and numbers. String enums also refuse plain strings ("RED" is not assignable to a string enum). The trap is saying "enums are just types". They are not. If a codebase already uses enums I keep them, because rewriting has little payoff, and I avoid const enum in libraries.

How do you add types for process.env, window or a library's interface? (senior signal)

With declaration merging. declare global { namespace NodeJS { interface ProcessEnv { DATABASE_URL: string } } } adds a field to process.env. declare global { interface Window { ... } } does the same for window. For a library module I use declare module "lib" { interface Options { ... } }. Interfaces merge, type aliases do not. For files like *.svg I write declare module "*.svg" { const src: string; export default src }. I also warn that typing process.env.X as string does not check anything at run time, so I validate the variables at start-up with a schema.

What is the difference between z.input and z.infer (z.output) in Zod? (senior signal)

A schema can accept one shape and produce another. With z.coerce.number() the input is a string from a form and the output is a number. With .transform(s => new Date(s)) the input is a string and the output is a Date. z.input is what callers send. z.output (same as z.infer) is what my code receives after parse. For API handlers I use input for the client function's argument type and output for the server logic. I use safeParse at the edge and return a 400 with the issue paths.

Explain the idea behind tRPC. When is it a poor fit? (senior signal)

The server exports only the type of its router (export type AppRouter = typeof appRouter). The client imports that type and a mapped type turns every procedure into a function with the same input and output, so there is no schema file and no code generation. It is a good fit in one TypeScript repository where one team owns both sides. It is a poor fit for public APIs, non-TypeScript clients or separate release cycles, where OpenAPI or GraphQL gives a language-neutral contract. Also, types are erased, so the server must still validate inputs at run time.

How do you type "a user with their posts" from Prisma without hand-writing it? (senior signal)

I define the query arguments as an object with satisfies Prisma.UserDefaultArgs, then use Prisma.UserGetPayload<typeof thatObject>. satisfies keeps the exact literal shape so the payload type is exact, and the same object can go to findMany. That way the type cannot drift from the query. For API responses I define a separate public type that leaves out private fields, and I map to it.

e.target or e.currentTarget, and how do you type a ref? (trap)

currentTarget is the element that owns the handler, so e.currentTarget.value is typed on a button. target is the element that was actually clicked, which could be a child, so it is only an EventTarget. For onChange on an input, React types e.target as the input, so e.target.value is fine. For refs I write useRef<HTMLInputElement>(null) and check ref.current before use, because it is null until the element mounts. In current React types, useRef() with no argument is a compile error.

tsc is slow in CI and in the editor. What do you do? (senior signal)

I measure first: tsc --extendedDiagnostics for the split between parse, check and emit, --listFilesOnly to see what is included, and --generateTrace to find the slow file or type. Then the usual fixes: an explicit types list (so I do not load hundreds of @types), skipLibCheck, incremental, project references with tsc -b for a monorepo, and simpler types in hot paths (no giant unions or deep recursion). I add explicit return types on exported functions, and isolatedDeclarations can enforce that. Upgrading to the native compiler (TypeScript 7) helps a lot, but I check my other tools work with it.

What are project references? When would you use them? (senior signal)

They split a repository into several tsconfig projects that depend on each other. Each referenced project has composite: true, so it writes .d.ts files and a build info file. tsc -b then builds in the right order and skips projects that are up to date. It gives faster rebuilds, lower editor memory, and a rule that a lower layer cannot import a higher one. I use it for a monorepo with shared packages. A single Next.js app usually does not need it. Imports across projects should use the package name (@acme/shared), not paths into another project's src.

You get TS2307: Cannot find module after an upgrade. How do you debug it? (trap)

I go through a checklist. Is the package installed, and does it have types (or @types/...)? Does moduleResolution match how the code runs? In nodenext is the extension there (.js)? Do paths start with ./, now that baseUrl is gone? Is a *.svg or *.css import missing its ambient declaration? Is types hiding something? I can run tsc --traceResolution to see every place the compiler looked, and that usually shows the answer in a minute.

Key takeaways

  • Defaults changed: strict is on, types is empty, baseUrl and node10 resolution are gone, and TypeScript 7 is the native compiler.
  • Pick bundler when a bundler builds your code, and nodenext when Node runs the output (with .js in imports).
  • Turn on noUncheckedIndexedAccess, verbatimModuleSyntax and isolatedModules. Know erasableSyntaxOnly and why enums fail it.
  • never means "cannot happen". Use it for exhaustive checks. Prefer an as const object over an enum.
  • Declaration merging lets you type process.env, window and library options. Validate environment variables at run time anyway.
  • Learn the traps: Object.keys, lost narrowing in closures, filter(Boolean), catch as unknown.
  • Keep API types honest with Zod (z.input vs z.output), typed RPC and Prisma GetPayload.
  • Type React with ComponentProps, typed refs and events, generic components and a safe context hook.
  • Fix speed by measuring: --extendedDiagnostics, --generateTrace, types, project references and tsc -b.

What's next

Lesson 04: React Interview Questions. With types set up, we turn to React itself: components, hooks, rendering cost, forms, data fetching and React 19.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series