Full-Stack Questions
TypeScript Interview Questions
Generics, narrowing, utility types, tsconfig flags, typing React and APIs, and the errors that confuse people.
On this page
- Words used in this lesson
- What changed in TypeScript 6 and 7
- The tsconfig flags that matter
- Modules: bundler vs nodenext
- unknown, any and never, and enums
- Declaration merging and declaration files
- Traps in narrowing and indexing
- Generic tools beyond the basics
- Type-safe API layers
- Typing React
- Speed: the type checker, tsc -b and project references
- Common errors and what they usually mean
- Common mistakes
- Interview questions
- Key takeaways
- 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.
tscis the TypeScript compiler. It checks types and can also write JavaScript.tsconfig.jsonis the settings file fortscand for your editor. A compiler option (or flag) is one setting in it.- A module is a file that has
importorexport. Module resolution is how the compiler turnsimport 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
tsconfigto 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 belief | What 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
| Flag | What it does | Why a senior turns it on (and the cost) |
|---|---|---|
strict | A group of checks: strictNullChecks, noImplicitAny, useUnknownInCatchVariables and more | Default now. Never turn it off for new code |
noUncheckedIndexedAccess | arr[0] and obj[key] get ` | undefined` |
exactOptionalPropertyTypes | x?: 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 |
noImplicitOverride | A subclass must write override | Safe renames in class hierarchies |
verbatimModuleSyntax | Imports and exports are kept as you wrote them. Type-only imports must say import type | Makes output predictable for bundlers and ESM. Error TS1484 tells you to add type |
isolatedModules | Forbids 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 |
erasableSyntaxOnly | Forbids 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 |
skipLibCheck | Do not type-check .d.ts files | Faster, and avoids errors inside dependencies. Cost: a broken dependency type is hidden |
noEmit | Check only, write nothing | Use 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):
{ "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:
{ "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, andpackage.jsonexportsandimportswork. Use it withmodule: "esnext"or"preserve"when a bundler produces the final code.moduleResolution: "nodenext"(withmodule: "nodenext") models real Node.js. In an ES module package ("type": "module"), relative imports must include the extension, and you write.jseven though the file is.ts:
// 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):
- Is the package installed, and does it ship types, or do you need
@types/...? - Does
moduleResolutionmatch how the code really runs (bundlerornodenext)? - Is the extension right for
nodenext? - Do
pathsentries start with./(needed sincebaseUrlis gone)? - For non-code files (
*.svg,*.css), do you have an ambient declaration? (see below) - Is
"types"hiding a global package such asnode?
unknown, any and never, and enums
Lesson 04 explains unknown vs any. The third one, never, is a favourite follow-up.
| Type | Meaning | In one line |
|---|---|---|
any | Skip checking | "Trust me." It spreads |
unknown | Any value, but you must check before using it | "I don't know yet." Safe top type |
never | No 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:
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:
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:
const ROLE = { Admin: "admin", Editor: "editor" } as const;type RoleName = (typeof ROLE)[keyof typeof ROLE]; // "admin" | "editor"enum | as const object + union | |
|---|---|---|
| Run-time code | Yes (extra JavaScript) | Only your plain object |
Works with erasableSyntaxOnly and Node type stripping | No (TS1294) | Yes |
| Value in JSON/DB | Number or string depends | A 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 enum | Needs care with isolatedModules and with libraries | Not 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.
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.
// 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// 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:
// 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.
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:
const raw: (string | null)[] = ["a", null, "b"];const a = raw.filter((x) => x !== null); // string[]const b = raw.filter(Boolean); // (string | null)[] still3. Narrowing is lost in a closure when the variable is reassigned.
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 if4. 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:
// 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 missingThe 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.
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.
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 typeThe 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:
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 selectedsatisfies 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.
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-typingonClick,disabledand the rest. AddOmit<..., "x">to remove one.- Do not use
React.FCby 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 usenull!unless you accept a crash if the element is not rendered. - Events.
e.currentTargetis the element you attached the handler to.e.targetis whatever was clicked, typed only asEventTarget, soe.target.valueon a click is an error. ForonChangeon an input,e.target.valueis typed. - Generic components need
function List<T>(...). Passingitemslets TypeScript inferT, sorendergets the item type. - Props that exclude each other use a union:
({ href: string } & AnchorProps) | ({ href?: undefined } & ButtonProps). Then<Clickable href="/x" disabled />fails, becausedisabledis not an anchor prop. - Context needs a safe hook: create the context with
nullas the default, and throw a clear error inuseThemeif it is stillnull. That keepsnullout 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 --extendedDiagnosticsprints files, lines, types, memory and times for parse, bind, check and emit. A small run of mine: 333 files (almost all fromliband@types), check time 0.09 s.tsc --generateTrace dirwrites 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 --listFilesOnlyshows 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 settingtypeswell.- 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 extendsor 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. isolatedDeclarationsmakes you annotate exported APIs, so other tools can create.d.tsfiles without full inference. For example,export function add(a: number, b: number) { return a + b }fails withTS9013until you write: number.- The native TypeScript 7 compiler is much faster, and it can check in parallel with
--checkers Nand 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.
// 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
| Error | Usual cause | Fix |
|---|---|---|
TS7053 Element implicitly has an any type because expression of type string can't be used to index... | Indexing an object with a plain string | Type the keys: Record<string, T>, keyof typeof obj, or a Map |
TS2322 Type string | undefined is not assignable to type string | A possibly missing value (noUncheckedIndexedAccess, optional field, ?.) | Check it, give a default (?? ""), or fix the source type |
TS18046 x is of type unknown | A value from catch, JSON.parse or an untyped boundary | Narrow it, or parse it with a schema |
TS18047 / TS18048 x is possibly null / undefined | Missing check, or narrowing lost in a closure | if, early return, const copy |
TS2339 Property does not exist on type never | You already handled every case | Remove the dead branch, or you made a wrong check |
TS2345 / TS2769 Argument is not assignable / No overload matches this call | Wrong argument type. Read the last overload error | Fix the argument, or the generic constraint |
TS2307 Cannot find module | Resolution, extension, types or paths | See the checklist above |
TS1484 X is a type and must be imported using a type-only import | verbatimModuleSyntax is on | import type { X } or import { type X } |
TS2591 Cannot find name process | types is empty | Install @types/node and add "types": ["node"] |
TS2589 Type instantiation is excessively deep | A runaway recursive type | Simplify, add a depth counter, or stop at a base case |
Common mistakes
- Trusting
as. A cast is a promise. Use a schema at boundaries. - Typing
process.envand never validating it. - Leaving
strictfamily flags off "for now", or using// @ts-ignorewhere// @ts-expect-error(which fails when the error goes away) is safer. - Copying an old
tsconfigwithbaseUrl,moduleResolution: nodeor notypes. - Using enums out of habit and then hitting
erasableSyntaxOnlyorisolatedModulesproblems. - Forgetting that
tscemits on error and not failing CI on the type check. - 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:
strictis on,typesis empty,baseUrlandnode10resolution are gone, and TypeScript 7 is the native compiler. - Pick
bundlerwhen a bundler builds your code, andnodenextwhen Node runs the output (with.jsin imports). - Turn on
noUncheckedIndexedAccess,verbatimModuleSyntaxandisolatedModules. KnowerasableSyntaxOnlyand why enums fail it. nevermeans "cannot happen". Use it for exhaustive checks. Prefer anas constobject over an enum.- Declaration merging lets you type
process.env,windowand library options. Validate environment variables at run time anyway. - Learn the traps:
Object.keys, lost narrowing in closures,filter(Boolean),catchasunknown. - Keep API types honest with Zod (
z.inputvsz.output), typed RPC and PrismaGetPayload. - Type React with
ComponentProps, typed refs and events, generic components and a safe context hook. - Fix speed by measuring:
--extendedDiagnostics,--generateTrace,types, project references andtsc -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
- Validation at the Edge: Schemas, DTOs and Trusting Nothing · Backend in Depth
- Advanced TypeScript for Interviews: Generics, Narrowing, Utility and Conditional Types · Senior Frontend Depth
- Why TypeScript, and Your First Project · TypeScript From Zero
Part of the study guide
Related series