TypeScript NotesRohit’s interview study guide
Chapter 13

TypeScript in Practice

TypeScript with React and Node, validating data at runtime, and a couple of typed utilities from the notes.

5 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#TypeScript with React

A React component is a function that takes one argument — the props object — and returns JSX. So typing a component is just typing a function's parameter. The props type is the component's public API: it says which attributes callers must pass, which are optional, and what values each one accepts. When you write <Button variant="primary">, the compiler checks that JSX exactly as if you'd called Button({ variant: "primary", … }).

Two sources of types meet in a .tsx file. Your own components are checked against their parameter types. Built-in elements like <button> and <input> are checked against React.JSX.IntrinsicElements from @types/react, which lists every HTML element and its attributes. Hooks such as useState are generic functions, and events are typed objects (ChangeEvent<HTMLInputElement>) — all ordinary TypeScript.

Props and children

React + TypeScript
import type { ReactNode } from "react";

type ButtonProps = {
  variant?: "primary" | "secondary";
  onClick: () => void;
  children: ReactNode;
};

export function Button({ variant = "primary", onClick, children }: ButtonProps) {
  return (
    <button className={`btn btn-${variant}`} onClick={onClick}>
      {children}
    </button>
  );
}

const save = <Button onClick={() => console.log("saved")}>Save</Button>;
const bad = <Button variant="danger" onClick={() => {}}>Delete</Button>; // ❌ TS2322: Type '"danger"' is not assignable to type '"primary" | "secondary" | undefined'.
What’s happening
  1. ButtonProps is a plain object type. variant? is optional and limited to two string literals; onClick is required; children: ReactNode accepts anything React can render — strings, numbers, elements, arrays, null, undefined.
  2. The parameter is destructured with a default: variant = "primary". Inside the function, variant is "primary" | "secondary" — the default removed undefined — so the template literal builds btn btn-primary or btn btn-secondary.
  3. For <Button onClick={…}>Save</Button>, the compiler builds the props object { onClick: …, children: "Save" } — the text between the tags becomes the children prop — and checks it against ButtonProps. Leaving out onClick would fail because it's required.
  4. variant="danger" isn't one of the allowed literals, so the JSX fails with TS2322, pointing at the attribute. The caller sees the mistake in the editor, before anything renders.
  5. No React.FC, no special wrapper — a typed parameter is all a component needs. The return type is inferred as React.JSX.Element.
AdvancedSide note: typing components with React.FC
AdvancedTyping state, refs and events

State, refs and events

React + TypeScript
import { useRef, useState, type ChangeEvent, type SubmitEvent } from "react";

type User = { id: number; name: string };

export function SearchForm({ onSearch }: { onSearch: (query: string) => void }) {
  const [query, setQuery] = useState("");                 // string — inferred from ""
  const [user, setUser] = useState<User | null>(null);    // explicit: null alone would be useState<null>
  const [tags, setTags] = useState<string[]>([]);         // explicit: [] alone would be never[]
  const inputRef = useRef<HTMLInputElement>(null);        // RefObject<HTMLInputElement | null>

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    setQuery(e.target.value);
  }

  function handleSubmit(e: SubmitEvent<HTMLFormElement>) {
    e.preventDefault();
    onSearch(query.trim());
    setTags((prev) => [...prev, query]);
    inputRef.current?.focus();
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={inputRef} value={query} onChange={handleChange} />
      <button onClick={(e) => console.log(e.clientX)}>Search</button>
      {user && <p>Signed in as {user.name}</p>}
      <p>{tags.length} searches</p>
    </form>
  );
}
What’s happening
  1. useState is a generic function: useState<S>(initialState: S | (() => S)). With "", S is inferred as string, and setQuery only accepts strings. Inference is enough when the initial value already has the full type.
  2. With null, inference would give S = null, so setUser({ id: 1, name: "Ann" }) later fails with TS2353 "Object literal may only specify known properties, and 'id' does not exist in type '(prevState: null) => null'". Passing <User | null> explicitly fixes the state's type up front. Same with []: on its own it infers never[], and setTags(["a"]) fails with TS2322 "Type 'string' is not assignable to type 'never'".
  3. useRef<HTMLInputElement>(null) returns RefObject<HTMLInputElement | null> in React 19 types. .current is null until React attaches the element, so inputRef.current?.focus() needs the ?..
  4. ChangeEvent<HTMLInputElement> types e.target as the input element, so e.target.value is a string. SubmitEvent<HTMLFormElement> is the form-submit event; e.preventDefault() stops the browser's full-page submit. (Older code uses FormEvent; current @types/react marks it deprecated because there's no such DOM event.)
  5. The inline onClick={(e) => …} needs no annotation: onClick on a <button> is declared as MouseEventHandler<HTMLButtonElement>, so e is contextually typed as a MouseEvent and e.clientX is a number (see Contextual typing). Annotate handlers only when you define them separately, like handleChange.
  6. {user && <p>…</p>} narrows user to User inside the expression, so user.name is safe.
AdvancedReusing element props and generic components
React + TypeScript
import type { ComponentProps, ReactNode } from "react";

type LabeledInputProps = ComponentProps<"input"> & { label: string };

export function LabeledInput({ label, id, ...rest }: LabeledInputProps) {
  return (
    <label htmlFor={id}>
      {label}
      <input id={id} {...rest} />
    </label>
  );
}

type ListProps<T> = {
  items: T[];
  getKey: (item: T) => string | number;
  render: (item: T) => ReactNode;
};

export function List<T>({ items, getKey, render }: ListProps<T>) {
  return <ul>{items.map((item) => <li key={getKey(item)}>{render(item)}</li>)}</ul>;
}

const email = <LabeledInput label="Email" id="email" type="email" required />;
const people = (
  <List
    items={[{ id: 1, name: "Ann" }, { id: 2, name: "Raj" }]}
    getKey={(p) => p.id}
    render={(p) => <strong>{p.name}</strong>}
  />
);
What’s happening
  1. ComponentProps<"input"> looks up "input" in React.JSX.IntrinsicElements and returns every prop a native <input> accepts: type, value, onChange, required, aria-*, even ref (a normal prop in React 19). Intersecting with { label: string } adds yours. Without it, you'd copy dozens of attribute types by hand.
  2. { label, id, ...rest } pulls out the two props this component uses itself; rest holds everything else, typed as the remaining input props, and {...rest} forwards them to the real <input>. So type="email" and required reach the DOM and are type-checked on the way.
  3. ComponentProps also works on components: ComponentProps<typeof Button> gives ButtonProps — handy when the props type isn't exported.
  4. List<T> is a generic component. At <List items={[…]} …>, the compiler infers T from items as { id: number; name: string }, exactly as it would for a generic function call.
  5. Because T is fixed by items, the callbacks are contextually typed: p in getKey and render is { id: number; name: string }, so p.name autocompletes and p.nmae would fail. This is the case React.FC can't express.

#TypeScript with Node.js

Node runs JavaScript, so TypeScript on the server is the same language with two extra concerns: where Node's own types come from, and how your .ts files get turned into something Node runs.

Node's APIs are typed by @types/node. Its version follows Node's major (@types/node@24 for Node 24), so match it to the Node version you deploy on, or the editor will offer APIs your runtime doesn't have. It's a global type package — it declares process, Buffer and every node:* module — so it must be listed in compilerOptions.types (since TypeScript 6.0 nothing is loaded automatically, see @types packages).

For running the code there are now three options:

  • Compile, then run: tsc writes .js into dist/, and production runs node dist/server.js. Slowest feedback, but the output runs anywhere.
  • Run .ts directly with Node: Node strips the type annotations as it loads each file. It arrived behind a flag in Node 22.6, has been on by default since Node 23.6 (and 22.18), and on Node 26 node server.ts just works. It only handles erasable syntax: no enum, no value namespace, no parameter properties, and type imports must use import type (Node 26 rejects the old --experimental-transform-types flag outright). It never type-checks — run tsc --noEmit for that.
  • A runner like tsx: compiles each file with esbuild on the fly, supports all syntax including enums, and also doesn't type-check. See Running TypeScript: tsc, ts-node and tsx.

A tsconfig.json for running TypeScript directly on Node:

tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "types": ["node"],
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true
  },
  "include": ["src"]
}
What’s happening
  1. "module": "nodenext" makes the compiler follow Node's real rules: "type": "module" in package.json means .ts files are ESM, relative imports need extensions, and package.json "exports" are respected (see Module systems, .mts and .cts).
  2. "types": ["node"] loads @types/node, so process, Buffer and import … from "node:http" are typed.
  3. "noEmit": true — Node runs the .ts files itself, so tsc is only the type checker here (tsc in CI, node src/server.ts to run).
  4. "allowImportingTsExtensions": true lets you write import { greet } from "./user.ts" — the extension Node needs at runtime, since it loads the .ts file directly. (If you later compile with tsc, use rewriteRelativeImportExtensions instead, which turns .ts into .js in the output.)
  5. "verbatimModuleSyntax" and "erasableSyntaxOnly" make tsc reject exactly what Node's type stripping can't handle. Without the first, import { User } from "./user.ts" compiles but Node crashes with SyntaxError: The requested module './user.ts' does not provide an export named 'User' — the interface was erased and the import wasn't. With it, tsc reports TS1484 first (see Type-only imports and exports).
AdvancedTyped server with node:http

A small typed server, using only Node's built-in http module:

src/server.ts
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";

type User = { id: number; name: string };
const users = new Map<number, User>([[1, { id: 1, name: "Ann" }]]);

function sendJson(res: ServerResponse, status: number, body: unknown): void {
  res.writeHead(status, { "content-type": "application/json" });
  res.end(JSON.stringify(body));
}

function handle(req: IncomingMessage, res: ServerResponse): void {
  const url = new URL(req.url ?? "/", "http://localhost");
  const id = Number(url.searchParams.get("id"));
  const user = users.get(id);
  if (!user) {
    sendJson(res, 404, { error: "not found" });
    return;
  }
  sendJson(res, 200, { message: `Hi ${user.name}` });
}

createServer(handle).listen(3000, () => console.log("listening on http://localhost:3000"));
What’s happening
  1. Every import from node:http resolves to declarations in @types/node. createServer is a value; IncomingMessage and ServerResponse are only used as types, so they carry the inline type modifier and are erased.
  2. req.url is typed string | undefined (it really can be missing on some request objects), so ?? "/" is required before new URL(…) accepts it. searchParams.get("id") returns string | null; Number(null) is 0, which simply finds no user.
  3. users.get(id) returns User | undefined. The if (!user) branch handles the missing case and returns, so afterwards user is narrowed to User.
  4. sendJson takes body: unknown — it accepts anything, because JSON.stringify does, and it never needs to look inside.
  5. Run it with node src/server.ts on Node 26: curl "localhost:3000/?id=1" prints {"message":"Hi Ann"} and ?id=2 prints {"error":"not found"}. Node strips the type imports and annotations and runs the rest unchanged.
AdvancedTyping process.env

@types/node types every environment variable as string | undefined, because any of them can be missing. You can tell the compiler about yours by augmenting the global NodeJS.ProcessEnv interface (the same technique as Global declarations and extending window):

src/env.d.ts
export {};

declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV?: "development" | "production" | "test";
      DATABASE_URL?: string;
    }
  }
}
TypeScript
function requireEnv(name: "DATABASE_URL"): string {
  const value = process.env[name];
  if (!value) throw new Error(`Missing environment variable ${name}`);
  return value;
}

const dbUrl = requireEnv("DATABASE_URL"); // string
const isProd = process.env.NODE_ENV === "production";
const isStaging = process.env.NODE_ENV === "staging"; // ❌ TS2367: This comparison appears to be unintentional because the types '"development" | "production" | "test" | undefined' and '"staging"' have no overlap.
What’s happening
  1. @types/node declares process.env as NodeJS.ProcessEnv, an interface inside a global namespace. declare global { namespace NodeJS { interface ProcessEnv { … } } } merges two named properties into it; the original index signature for all other variables stays.
  2. DATABASE_URL?: string is optional on purpose. Declaring it as required string is a popular trick, but it's a lie: if the variable isn't set, the program still compiles and crashes later with a confusing error far from the cause.
  3. requireEnv does the honest thing: read the value (string | undefined), fail at startup with a clear message if it's missing, and return a real string from then on.
  4. NODE_ENV is a union of literals, so comparing with a value outside it — "staging" — is TS2367: the compiler knows the comparison can never be true. That catches typos like "prod" too.
  5. This still only describes the environment — nothing checks the values at runtime except requireEnv. For a lot of variables, validate them all at startup with a schema (see Validating runtime data).
AdvancedTyping Express handlers
src/routes/users.ts — Express 5 + @types/express 5.x
import express, { type Request, type Response, type NextFunction } from "express";

type UserDto = { id: string; name: string; email: string };
type CreateUserBody = { name: string; email: string };
type ErrorBody = { error: string };

const users = new Map<string, UserDto>();
const app = express();
app.use(express.json());

app.get("/users/:id", (req, res: Response<UserDto | ErrorBody>) => {
  const user = users.get(req.params.id); // req.params is { id: string }, inferred from the path
  if (!user) {
    res.status(404).json({ error: "Not found" });
    return;
  }
  res.json(user);
});

app.post("/users", async (req: Request<{}, UserDto, CreateUserBody>, res: Response<UserDto>) => {
  const { name, email } = req.body; // typed, but NOT validated
  const user: UserDto = { id: crypto.randomUUID(), name, email };
  users.set(user.id, user);
  res.status(201).json(user);
});

app.use((err: unknown, req: Request, res: Response<ErrorBody>, next: NextFunction) => {
  console.error(err);
  res.status(500).json({ error: "Internal error" });
});
What’s happening
  1. Request has generic parameters in a fixed order: Request<Params, ResBody, ReqBody, ReqQuery, Locals>. Response<ResBody> types what res.json() accepts, so a route can't accidentally send the wrong shape.
  2. In the first route, req isn't annotated. app.get is overloaded on the path string, and the Express types parse "/users/:id" with template literal types into { id: string }, so req.params.id is a string without any annotation.
  3. The res.status(404).json({ error: … }) call must match UserDto | ErrorBody; res.json(user) matches UserDto. return after sending ends the handler — the types don't stop you from sending twice, but the early return does.
  4. The POST route gives ReqBody as CreateUserBody, so req.body.name is string. But that annotation is a claim about JSON a stranger sent you, and express.json() doesn't check it. A request with { "name": 42 } sails through as if it were a string. That's the gap the next topic fills.
  5. Express 5 forwards a rejected promise from an async handler to the error middleware (Express 4 needed a wrapper). The error middleware is recognised by having four parameters; err is typed unknown here because anything can be thrown.

#Validating runtime data

Types are erased at compile time. Once the program runs, nothing checks that a value actually has the type the code says it has. Inside your program that's fine — the compiler already checked every assignment. The problem is the boundary: JSON from an API, a request body, a file, localStorage, environment variables. That data was never seen by the compiler, and APIs that produce it are typed as any (JSON.parse, res.json()). Writing as User on it doesn't check anything; it just tells the compiler to stop asking.

The mental model is a border crossing. Inside the country (your typed code), everyone's papers were checked when they were created. At the border, someone has to actually inspect what's coming in — and TypeScript can't stand at the border, because it doesn't exist at runtime. You need runtime code that checks the shape and, only if it passes, hands the value to the rest of the program as a properly typed value.

TypeScript
type User = { id: number; email: string };

const raw = '{"id":"42","email":null}';
const user = JSON.parse(raw) as User; // compiles: JSON.parse returns any, and `as` asserts

console.log(typeof user.id); // "string" — the type says number
user.email.toLowerCase();    // compiles, then: TypeError: Cannot read properties of null (reading 'toLowerCase')
What’s happening
  1. JSON.parse is declared as returning any, so whatever it returns can be asserted to anything. as User is accepted without a single check.
  2. At runtime, user is { id: "42", email: null }. The compiler believes id is a number and email a string; neither is true.
  3. typeof user.id prints "string". Any arithmetic on it would now concatenate instead of add — user.id + 1 is "421".
  4. user.email.toLowerCase() type-checks perfectly and throws TypeError: Cannot read properties of null (reading 'toLowerCase'). The crash happens far from the real bug, which was trusting the JSON.
AdvancedOption 1: parse unknown with a type guard

The no-dependency way: treat incoming data as unknown (see unknown) and narrow it with a hand-written check.

TypeScript
type User = { id: number; email: string };

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    typeof value.id === "number" &&
    "email" in value &&
    typeof value.email === "string"
  );
}

function parseUser(json: string): User {
  const data: unknown = JSON.parse(json);
  if (!isUser(data)) throw new Error("Invalid user payload");
  return data; // User
}

parseUser('{"id":42,"email":"ann@example.com"}'); // { id: 42, email: "ann@example.com" }
parseUser('{"id":"42","email":null}');            // throws Error: Invalid user payload
What’s happening
  1. const data: unknown = JSON.parse(json) takes the any and immediately puts it behind unknown, so nothing can use it without checking first.
  2. isUser narrows step by step: typeof value === "object" && value !== null makes it object; "id" in value makes it object & Record<"id", unknown>, so typeof value.id === "number" is allowed; the same for email. Every check is real runtime code.
  3. The return type value is User is a custom type guard: when it returns true, the caller's data is narrowed to User, so return data matches parseUser's return type with no assertion.
  4. With good JSON, all checks pass and the user is returned. With {"id":"42","email":null}, typeof value.id === "number" is false, the guard returns false, and parseUser throws at the border with a clear message.
  5. The weakness: isUser and type User are written separately. Add a field to User and forget the guard, and the guard lies — it says "User" about objects missing the new field. Schema libraries fix that.
AdvancedOption 2: a schema library (zod)

A schema library flips the relationship: you write the runtime validator once, and the library derives the TypeScript type from it. The two can't drift apart because there's only one source.

user-schema.ts — zod 4.x
import { z } from "zod";

const UserSchema = z.object({
  id: z.number().int().positive(),
  email: z.email(),
  name: z.string().min(1),
  role: z.enum(["admin", "member"]).default("member"),
});

type User = z.infer<typeof UserSchema>;
// { id: number; email: string; name: string; role: "admin" | "member" }

const result = UserSchema.safeParse(JSON.parse('{"id":42,"email":"nope","name":""}'));
if (result.success) {
  console.log(result.data.role); // "member" if the input had no role: the default is applied
} else {
  console.log(z.prettifyError(result.error));
  // ✖ Invalid email address
  //   → at email
  // ✖ Too small: expected string to have >=1 characters
  //   → at name
}
What’s happening
  1. z.object({ … }) builds a schema object — a runtime value that knows how to check data. Each field is itself a schema: z.number().int().positive(), z.email() (zod 4's top-level string format), z.string().min(1), and an enum with a default.
  2. z.infer<typeof UserSchema> is a type-level function that reads the schema's type and produces the matching object type. typeof is needed because UserSchema is a value; z.infer turns it into the output type. Change the schema and User changes with it.
  3. safeParse runs every check and returns a discriminated union: { success: true, data: User } or { success: false, error: ZodError }. Checking result.success narrows it, so result.data is only reachable when validation passed (see Discriminated unions).
  4. Here the email is invalid and the name is empty, so success is false and the error lists both problems with their paths. parse (instead of safeParse) throws the ZodError instead of returning it.
  5. Where a field has a default or a transform, the input and output types differ: z.input<typeof UserSchema> has role?: … (optional on the way in), while z.infer (the output) has it required. Valibot and ArkType follow the same "schema first, type inferred" idea.
AdvancedOption 3: class-validator and class-transformer

From my notes: the typestack packages class-transformer and class-validator. They take the class-based approach, popular in NestJS: a DTO (data transfer object) class with decorators describing the rules. class-transformer turns a plain object into an instance of the class; class-validator then reads the decorators and validates it.

create-user.dto.ts — class-validator 0.14 / class-transformer 0.5, needs "experimentalDecorators"
import "reflect-metadata";
import { plainToInstance, Type } from "class-transformer";
import { IsEmail, IsInt, IsOptional, IsString, Min, MinLength, ValidateNested, validate } from "class-validator";

class AddressDto {
  @IsString() city!: string;
}

export class CreateUserDto {
  @IsString() @MinLength(1) name!: string;
  @IsEmail() email!: string;
  @IsOptional() @IsInt() @Min(0) age?: number;

  @ValidateNested() @Type(() => AddressDto)
  address!: AddressDto;
}

const dto = plainToInstance(CreateUserDto, { name: "", email: "nope", address: { city: 7 } });
const errors = await validate(dto);
console.log(errors.map((e) => e.property)); // [ 'name', 'email', 'address' ]
What’s happening
  1. Each decorator (@IsEmail(), @MinLength(1), …) runs once, when the class is defined, and records a rule in a metadata registry keyed by the class and property name. The properties use ! because nothing assigns them in a constructor — instances are filled in by plainToInstance.
  2. plainToInstance(CreateUserDto, plain) creates a real CreateUserDto and copies the plain object's properties onto it. @Type(() => AddressDto) tells class-transformer to turn the nested address object into an AddressDto too — without it, address stays a plain object and its decorators never apply.
  3. validate(dto) looks up the rules for CreateUserDto and checks each property, returning a promise of ValidationError[] — one per failing property, each with constraints like { isEmail: "email must be an email" }. @ValidateNested() makes it recurse into address.
  4. name: "" fails MinLength, email: "nope" fails IsEmail, and address.city: 7 fails IsString inside the nested object, so three properties are reported. age is missing, which @IsOptional() allows.
  5. These packages use legacy (experimental) decorators: the project needs "experimentalDecorators": true (and usually "emitDecoratorMetadata": true plus the reflect-metadata import). Without it, TypeScript applies the standard ES decorators, whose signature is different, and the compiler rejects them with TS1240 "Unable to resolve signature of property decorator when called as an expression".
Type guardzod (schema)class-validator
Source of truthtype and check written separatelythe schema; type inferredthe class; decorators add rules
Dependenciesnoneone small librarytwo libraries + reflect-metadata
Decorators / special tsconfignonoexperimentalDecorators
Fits besta few small shapesmost apps, front end and back endNestJS and class-based code

#A typed deep clone

From my notes, a generic deep clone:

TypeScript
function deepClone<T>(obj: T): T {
  if (typeof obj !== "object") {
    return obj;
  }
  if (Array.isArray(obj)) {
    return obj.map((element) => {
      return deepClone(element);
    }) as T;
  }
  return Object.fromEntries(
    Object.entries(obj as any).map(([key, value]) => {
      return [key, deepClone(value)];
    }),
  ) as T;
}

deepClone({ name: "Ann", manager: null }); // TypeError: Cannot convert undefined or null to object
What’s happening
  1. deepClone({ name: "Ann", manager: null }): the outer value is an object and not an array, so it goes to Object.entries, which produces [["name", "Ann"], ["manager", null]].
  2. deepClone("Ann"): typeof "Ann" is "string", so it's returned as is. Fine.
  3. deepClone(null): typeof null is "object" — the famous JavaScript quirk — so the first if does not return. Array.isArray(null) is false, so it reaches Object.entries(null), which throws TypeError: Cannot convert undefined or null to object.
  4. The compiler didn't catch it because of obj as any: Object.entries normally rejects a value that could be null, and the any switched that check off. The three as T assertions also mean the compiler never verified that the copies have the type T.
  5. Two more quiet bugs: a Date has no own enumerable properties, so it's copied as {}; a class instance is copied into a plain object, losing its prototype and methods — while the return type still claims T.
AdvancedCorrected deepClone with null and dates

The corrected version handles null, keeps Dates, and confines the unavoidable assertion to one place:

TypeScript
function deepClone<T>(value: T): T {
  if (value === null || typeof value !== "object") {
    return value; // primitives, null, undefined and functions are returned as is
  }
  if (value instanceof Date) {
    return new Date(value.getTime()) as T;
  }
  if (Array.isArray(value)) {
    return value.map((item: unknown) => deepClone(item)) as T;
  }
  const copy: Record<string, unknown> = {};
  for (const [key, item] of Object.entries(value)) {
    copy[key] = deepClone(item);
  }
  return copy as T;
}

const original = { name: "Ann", manager: null, joined: new Date(2024, 0, 15), tags: ["admin"] };
const copy = deepClone(original);
copy.tags.push("billing");
console.log(original.tags, copy.tags);   // [ 'admin' ] [ 'admin', 'billing' ]
console.log(copy.joined instanceof Date); // true
console.log(copy.manager);                // null
What’s happening
  1. value === null || typeof value !== "object" is the fix: null is caught before anything treats it as an object, so manager: null is returned as null. Functions have typeof "function" and are returned as they are (shared, not copied).
  2. After that check, the compiler has narrowed value to T & object, so Object.entries(value) type-checks without as any — the compiler now agrees it can't be null.
  3. value instanceof Date copies a Date by its timestamp, so copy.joined is a new Date with the same time, not {}.
  4. The array branch copies each element recursively; copy.tags is a new array, so pushing "billing" leaves original.tags as ['admin'].
  5. The as T assertions remain because the compiler can't prove that "a new object with the same keys and cloned values" is a T — that's a fact about this algorithm, not something the type system can express for an arbitrary T. Keep assertions like these small and inside a well-tested helper.
  6. It still doesn't handle Map, Set, class instances or circular references (a cycle recurses until RangeError: Maximum call stack size exceeded). That's what the built-in is for.
AdvancedstructuredClone for real code

For real code, use structuredClone (browsers and Node 17+). It's typed in the standard library as structuredClone<T = any>(value: T, options?: StructuredSerializeOptions): T, so the result has the same type as the input:

TypeScript
const settings = { theme: "dark", recent: new Map([["a.txt", new Date(2024, 0, 1)]]) };
settings.recent.set("self", new Date());

const cloned = structuredClone(settings); // { theme: string; recent: Map<string, Date> }
cloned.recent.delete("a.txt");
console.log(settings.recent.size, cloned.recent.size); // 2 1
What’s happening
  1. structuredClone uses the structured clone algorithm, which understands Map, Set, Date, RegExp, typed arrays, and circular references.
  2. Its generic signature returns T, so cloned has exactly the type of settings — no assertion needed in your code.
  3. Deleting from cloned.recent doesn't touch settings.recent: sizes are 2 and 1, proving the Map was copied, not shared.
  4. Its limits are also invisible to the types: functions throw a DataCloneError, and class instances come back as plain objects without their prototype — while the return type still says T. The type is a convenience, not a guarantee.
AdvancedCorrecting my notes: deepClone and null

#Typing Object.entries and Object.fromEntries

From my notes: a nice example on fromEntries and entries. Both are very common — entries to turn an object into [key, value] pairs, map or filter them, and fromEntries to turn them back into an object. Their types surprise people, and the surprise is a lesson in structural typing.

The starting point is that Object.keys(user) is typed string[], not ("name" | "age")[]. That looks like a missed opportunity, but it's deliberate. A value of type { name: string; age: number } is any object that has at least those properties — it can have more. The compiler can't promise that the keys you get at runtime are only the ones in the type.

TypeScript
type Point = { x: number; y: number };

const point3d = { x: 1, y: 2, z: 3 };
const point: Point = point3d; // fine: extra properties are allowed

console.log(Object.keys(point)); // [ 'x', 'y', 'z' ]

for (const key of Object.keys(point)) {
  console.log(point[key]); // ❌ TS7053: Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'Point'.
}

const entries = Object.entries({ name: "Ann", age: 36 }); // [string, string | number][]
const fromPairs = Object.fromEntries([["a", 1], ["b", 2]]); // { [k: string]: number }
What’s happening
  1. const point: Point = point3d compiles. Excess-property checks only apply to object literals written directly in the assignment; a variable with extra properties is fine because it has x and y (Excess property checking).
  2. At runtime, point is the same object as point3d, so Object.keys(point) returns ['x', 'y', 'z']. If the type were ("x" | "y")[], it would be lying about "z".
  3. Because key is just string, point[key] fails with TS7053: Point has no index signature for arbitrary strings.
  4. Object.entries has an overload entries<T>(o: { [s: string]: T }): [string, T][]. The keys are string for the same reason, and the values become the union of all property types: string | number.
  5. Object.fromEntries is typed fromEntries<T>(entries: Iterable<readonly [PropertyKey, T]>): { [k: string]: T }. It knows the value type (number) but not which keys exist, so you get an index signature, and fromPairs.anything is number (or number | undefined with noUncheckedIndexedAccess).
AdvancedA typed Object.entries helper

When you know an object has no extra keys — you created it yourself, as a literal — a typed helper gives you precise pairs:

TypeScript
type Entries<T> = { [K in keyof T]-?: [K, T[K]] }[keyof T][];

function typedEntries<T extends object>(obj: T): Entries<T> {
  return Object.entries(obj) as Entries<T>;
}

function mapValues<T extends object, R>(obj: T, fn: (value: T[keyof T], key: keyof T) => R): { [K in keyof T]: R } {
  const pairs = typedEntries(obj).map(([key, value]) => [key, fn(value, key)]);
  return Object.fromEntries(pairs) as { [K in keyof T]: R };
}

const prices = { apple: 1.25, pear: 0.8 };
const pairs = typedEntries(prices);              // (["apple", number] | ["pear", number])[]
const labels = mapValues(prices, (p) => `$${p.toFixed(2)}`);
console.log(labels);                             // { apple: '$1.25', pear: '$0.80' }
labels.apple.toUpperCase();                      // labels is { apple: string; pear: string }
labels.banana;                                   // ❌ TS2339: Property 'banana' does not exist on type '{ apple: string; pear: string; }'.
What’s happening
  1. Entries<T> is a mapped type indexed by its own keys. For { apple: number; pear: number }, the mapping produces { apple: ["apple", number]; pear: ["pear", number] }; indexing with [keyof T] turns that into the union ["apple", number] | ["pear", number]; the trailing [] makes it an array of those pairs (see Getting every value type). -? stops optional properties from adding undefined to the union.
  2. typedEntries just calls Object.entries and asserts the precise type. That assertion is the caveat: if someone passes an object with extra runtime keys, the pairs include keys the type doesn't mention. It's safe only for objects whose exact shape you control.
  3. In mapValues, key is "apple" | "pear" and value is number, so fn(value, key) type-checks without any. fn is called for each pair: 1.25 → "$1.25", 0.8 → "$0.80".
  4. Object.fromEntries(pairs) gives back { [k: string]: string } — it can't know the keys — so the result is asserted to { [K in keyof T]: R }, a mapped type that keeps T's keys and swaps every value type to R.
  5. That's why labels is { apple: string; pear: string }: labels.apple.toUpperCase() is fine, and labels.banana fails with TS2339. The runtime result is { apple: '$1.25', pear: '$0.80' }.

Built from Rohit’s “Typescript Interview” study doc. Examples are checked with the TypeScript compiler in strict mode.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.