TypeScript · lesson 21 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2TypeScript Interview Prep
30 TypeScript interview questions on types, generics, narrowing, utility, conditional and mapped types, each answered with real tsc and tsx output.
By SkillByExample · Updated
Every answer below is backed by a file that was actually compiled and run, with TypeScript 6.0.3 and Node 22, in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default. Where a question asks what a type resolves to, the file assigns a deliberately wrong value so the compiler error prints the answer.
Q1: What is TypeScript, and what happens to the types at runtime?
TypeScript is JavaScript plus a static type system. The compiler checks the types, then erases them: the emitted JavaScript contains none of them.
interface User { name: string }
const user: User = { name: "Ada" };
const id = <T,>(x: T): T => x;
console.log(id(user).name);
$ npx tsc --module nodenext --outDir js q1.ts
$ cat js/q1.js
const user = { name: "Ada" };
const id = (x) => x;
console.log(id(user).name);
export {};
$ node js/q1.js
Ada
The interface, the annotation and the type parameter are gone. That’s why you can’t instanceof an interface, and why data from outside the program needs runtime checks.
Q2: What is the difference between any and unknown?
Both accept any value. any switches checking off; unknown makes you narrow before use.
const a: any = "hello";
const u: unknown = "hello";
a.toFixed(2);
u.toUpperCase();
if (typeof u === "string") console.log(u.toUpperCase());
$ npx tsc --noEmit --module nodenext q2.ts
q2.ts(5,1): error TS18046: 'u' is of type 'unknown'.
Only the unknown line is flagged — even though a.toFixed(2) would crash at runtime, because a string has no toFixed. Inside the typeof check, u is a string.
Q3: What does strict: true enable?
A family of checks: strictNullChecks, noImplicitAny, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables, alwaysStrict and strictBuiltinIteratorReturn. In TypeScript 6 it’s on by default.
function greet(name) {
return "hi " + name;
}
let title: string = null;
class Box { size: number }
try {} catch (e) { console.log(e.message); }
greet.call(undefined, 1, 2);
$ npx tsc --noEmit --module nodenext q3.ts
q3.ts(1,16): error TS7006: Parameter 'name' implicitly has an 'any' type.
q3.ts(4,5): error TS2322: Type 'null' is not assignable to type 'string'.
q3.ts(5,13): error TS2564: Property 'size' has no initializer and is not definitely assigned in the constructor.
q3.ts(6,32): error TS18046: 'e' is of type 'unknown'.
q3.ts(7,26): error TS2554: Expected 2 arguments, but got 3.
$ npx tsc --noEmit --module nodenext --strict false q3.ts
Five errors with the default, none with --strict false. Every one of them is a latent runtime bug.
Q4: What is the difference between interface and type?
Both describe object shapes. Interfaces can be re-opened (declaration merging) and use extends; type aliases can name any type — unions, tuples, mapped and conditional types — but can’t be merged.
interface Point { x: number }
interface Point { y: number }
const p: Point = { x: 1 };
type Status = "on" | "off";
type Status = "unknown";
$ npx tsc --noEmit --module nodenext q4.ts
q4.ts(3,7): error TS2741: Property 'y' is missing in type '{ x: number; }' but required in type 'Point'.
q4.ts(5,6): error TS2300: Duplicate identifier 'Status'.
q4.ts(6,6): error TS2300: Duplicate identifier 'Status'.
The two Point declarations merged, so y is now required. The second Status is simply a duplicate.
Q5: What are generics? Give a practical example.
A type parameter links types across a signature, so the output type follows the input type without any.
function first<T>(items: T[]): T | undefined {
return items[0];
}
const n = first([1, 2, 3]);
const s = first(["a", "b"]);
console.log(n?.toFixed(1), s?.toUpperCase());
const wrong: string = first([1, 2, 3]);
$ npx tsc --noEmit --module nodenext q5.ts
q5.ts(9,7): error TS2322: Type 'number | undefined' is not assignable to type 'string'.
Type 'undefined' is not assignable to type 'string'.
T was inferred per call. | undefined is there because the array might be empty.
Q6: What is a type guard?
An expression or function that narrows a type inside a branch. A user-defined guard returns a type predicate, value is T.
function isError(value: unknown): value is Error {
return value instanceof Error;
}
function describe(value: unknown): string {
if (isError(value)) return `Error: ${value.message}`;
if (typeof value === "string") return `string: ${value}`;
return `other: ${JSON.stringify(value)}`;
}
console.log(describe(new RangeError("too big")));
console.log(describe("plain"));
console.log(describe({ code: 7 }));
$ npx tsc --noEmit --module nodenext q6.ts
$ npx tsx q6.ts
Error: too big
string: plain
other: {"code":7}
The compiler trusts the predicate — it doesn’t check that the function body actually proves it.
Q7: Explain discriminated unions.
A union whose members share a literal property. Checking that property narrows the whole object, and a switch over it can be exhaustive.
type Result<T> =
| { status: "success"; data: T }
| { status: "error"; message: string }
| { status: "loading" };
function render<T>(r: Result<T>): string {
switch (r.status) {
case "success": return `data: ${JSON.stringify(r.data)}`;
case "error": return `error: ${r.message}`;
case "loading": return "loading...";
}
}
console.log(render({ status: "success", data: [1, 2] }));
console.log(render({ status: "error", message: "timeout" }));
console.log(render({ status: "error", data: 1 }));
$ npx tsc --noEmit --module nodenext q7.ts
q7.ts(16,39): error TS2353: Object literal may only specify known properties, and 'data' does not exist in type '{ status: "error"; message: string; }'.
An "error" object can’t carry data. With the bad line removed:
$ npx tsx q7.ts
data: [1,2]
error: timeout
Q8: What does keyof do?
keyof T is the union of T’s keys. With an indexed access T[K] it types property lookups exactly.
interface User { id: number; name: string; email: string }
const user: User = { id: 1, name: "Ada", email: "ada@example.com" };
function get<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const id: number = get(user, "id");
const name: string = get(user, "name");
console.log(id, name);
const key: keyof User = "age";
$ npx tsc --noEmit --module nodenext q8.ts
q8.ts(11,7): error TS2322: Type '"age"' is not assignable to type 'keyof User'.
Q9: What is typeof in a type position?
It turns a value into its type. The runtime typeof returns a string like "object"; the type-level one returns the full TypeScript type.
const config = { host: "localhost", port: 3000 };
type Config = typeof config;
function createUser() {
return { id: 1, name: "Ada" };
}
type User = ReturnType<typeof createUser>;
console.log(typeof config);
const c: Config = { host: "x" };
const u: User = { id: "1", name: "Ada" };
$ npx tsc --noEmit --module nodenext q9.ts
q9.ts(10,7): error TS2741: Property 'port' is missing in type '{ host: string; }' but required in type '{ host: string; port: number; }'.
q9.ts(11,19): error TS2322: Type 'string' is not assignable to type 'number'.
$ npx tsx q9.ts
object
Q10: What are mapped types?
{ [K in keyof T]: ... } builds a new object type by transforming each property of T.
type Nullable<T> = { [K in keyof T]: T[K] | null };
type Getters<T> = { [K in keyof T & string as `get${Capitalize<K>}`]: () => T[K] };
interface User { name: string; age: number }
const u: Nullable<User> = { name: null, age: 36 };
const g: Getters<User> = {};
console.log(u);
$ npx tsc --noEmit --module nodenext q10.ts
q10.ts(7,7): error TS2739: Type '{}' is missing the following properties from type 'Getters<User>': getName, getAge
The as clause renames keys — here producing getName and getAge. Partial, Readonly and Record are mapped types too.
Q11: What is infer?
Inside a conditional type, infer X captures part of the type being matched.
type Unpack<T> = T extends (infer U)[] ? U : T;
type Ret<T> = T extends (...args: any[]) => infer R ? R : never;
const a: Unpack<string[]> = 1;
const b: Unpack<number> = "x";
const c: Ret<() => boolean> = "x";
$ npx tsc --noEmit --module nodenext q11.ts
q11.ts(4,7): error TS2322: Type 'number' is not assignable to type 'string'.
q11.ts(5,7): error TS2322: Type 'string' is not assignable to type 'number'.
q11.ts(6,7): error TS2322: Type 'string' is not assignable to type 'boolean'.
The errors name the resolved types: string, number (non-arrays pass through), and boolean.
Q12: Implement Partial<T> from scratch.
type MyPartial<T> = { [K in keyof T]?: T[K] };
interface User { id: number; name: string; email: string }
const patch: MyPartial<User> = { name: "Ada" };
const empty: MyPartial<User> = {};
const bad: MyPartial<User> = { id: "7" };
const extra: MyPartial<User> = { nickname: "A" };
console.log(patch, empty);
$ npx tsc --noEmit --module nodenext q12.ts
q12.ts(7,32): error TS2322: Type 'string' is not assignable to type 'number'.
q12.ts(8,34): error TS2353: Object literal may only specify known properties, and 'nickname' does not exist in type 'MyPartial<User>'.
Every property becomes optional, but still keeps its type — and unknown properties are still rejected. A follow-up is often Required<T>, which uses -? to remove the modifier.
Q13: What is never used for?
The type with no values: the return type of functions that never return, and what’s left after every case of a union has been handled.
function assertNever(value: never): never {
throw new Error(`unhandled: ${JSON.stringify(value)}`);
}
type Direction = "left" | "right" | "up";
function move(d: Direction): number {
if (d === "left") return -1;
if (d === "right") return 1;
return assertNever(d);
}
console.log(move("left"));
$ npx tsc --noEmit --module nodenext q13.ts
q13.ts(10,22): error TS2345: Argument of type '"up"' is not assignable to parameter of type 'never'.
"up" wasn’t handled, so d is still "up" at the end — not never — and the call is rejected.
Q14: What is declaration merging?
Multiple declarations of the same interface (or namespace) combine into one. It’s how libraries’ types get augmented — for example, adding user to Express’s Request.
interface Animal { name: string }
interface Animal { age: number }
const rex: Animal = { name: "Rex", age: 3 };
const tom: Animal = { name: "Tom" };
console.log(rex);
$ npx tsc --noEmit --module nodenext q14.ts
q14.ts(5,7): error TS2741: Property 'age' is missing in type '{ name: string; }' but required in type 'Animal'.
Q15: What does as const do?
It infers the narrowest possible type: literal types everywhere, readonly properties, and readonly tuples instead of arrays.
export const loose = { env: "prod", port: 3000 };
export const exact = { env: "prod", port: 3000 } as const;
export const ROLES = ["admin", "user", "viewer"] as const;
export type Role = (typeof ROLES)[number];
$ npx tsc --declaration --emitDeclarationOnly --outDir types q15.ts
$ cat types/q15.d.ts
export declare const loose: {
env: string;
port: number;
};
export declare const exact: {
readonly env: "prod";
readonly port: 3000;
};
export declare const ROLES: readonly ["admin", "user", "viewer"];
export type Role = (typeof ROLES)[number];
(typeof ROLES)[number] indexes the tuple with number, producing "admin" | "user" | "viewer" — a union that stays in sync with the array.
Q16: What is the satisfies operator?
It checks a value against a type without replacing the value’s inferred type (TypeScript 4.9+).
type Colors = Record<string, string | [number, number, number]>;
const annotated: Colors = { red: [255, 0, 0], green: "#00ff00" };
const checked = { red: [255, 0, 0], green: "#00ff00" } satisfies Colors;
console.log(checked.green.toUpperCase(), checked.red[0]);
annotated.green.toUpperCase();
const invalid = { blue: [0, 0] } satisfies Colors;
$ npx tsc --noEmit --module nodenext q16.ts
q16.ts(7,17): error TS2339: Property 'toUpperCase' does not exist on type 'string | [number, number, number]'.
Property 'toUpperCase' does not exist on type '[number, number, number]'.
q16.ts(8,19): error TS2322: Type '[number, number]' is not assignable to type 'string | [number, number, number]'.
Type '[number, number]' is not assignable to type '[number, number, number]'.
Source has 2 element(s) but target requires 3.
Through the annotation, green is only string | [...]; through satisfies, it’s known to be a string — and the value is still validated.
Q17: What is a conditional type, and what is distribution?
T extends U ? X : Y — a type-level ternary. When T is a bare type parameter and receives a union, the condition is applied to each member separately.
type IsString<T> = T extends string ? "yes" : "no";
type ToArray<T> = T extends unknown ? T[] : never;
type ToArrayNoDist<T> = [T] extends [unknown] ? T[] : never;
const a: IsString<"hi"> = "no";
const b: ToArray<string | number> = [1, "a"];
const c: ToArrayNoDist<string | number> = [1, "a"];
$ npx tsc --noEmit --module nodenext q17.ts
q17.ts(5,7): error TS2322: Type '"no"' is not assignable to type '"yes"'.
q17.ts(6,7): error TS2322: Type '(string | number)[]' is not assignable to type 'string[] | number[]'.
Type '(string | number)[]' is not assignable to type 'string[]'.
Type 'string | number' is not assignable to type 'string'.
Type 'number' is not assignable to type 'string'.
ToArray<string | number> is string[] | number[], so a mixed array fails; wrapping in [T] stops distribution and c is accepted.
Q18: What is the difference between Exclude and Omit?
Exclude removes members from a union. Omit removes properties from an object type.
type Status = "active" | "inactive" | "banned";
interface User { id: number; name: string; password: string }
const s: Exclude<Status, "banned"> = "banned";
const u: Omit<User, "password"> = { id: 1, name: "Ada", password: "x" };
$ npx tsc --noEmit --module nodenext q18.ts
q18.ts(4,7): error TS2322: Type '"banned"' is not assignable to type '"active" | "inactive"'.
q18.ts(5,57): error TS2353: Object literal may only specify known properties, and 'password' does not exist in type 'Omit<User, "password">'.
Q19: What are covariance and contravariance?
Return types are covariant: a function returning Dog works where one returning Animal is expected. Parameters are contravariant: a function accepting any Animal works where one accepting Dog is expected — not the other way round.
interface Animal { name: string }
interface Dog extends Animal { bark(): string }
const makeDog = (): Dog => ({ name: "Rex", bark: () => "Woof" });
const handleAnimal = (a: Animal) => console.log(a.name);
const handleDog = (d: Dog) => console.log(d.bark());
const f1: () => Animal = makeDog;
const f2: (d: Dog) => void = handleAnimal;
const f3: (a: Animal) => void = handleDog;
$ npx tsc --noEmit --module nodenext q19.ts
q19.ts(10,7): error TS2322: Type '(d: Dog) => void' is not assignable to type '(a: Animal) => void'.
Types of parameters 'd' and 'a' are incompatible.
Property 'bark' is missing in type 'Animal' but required in type 'Dog'.
f3 would let a caller pass a cat to a function that calls bark(). (Method-syntax signatures are checked bivariantly and would let f3 through — a known soundness trade-off.)
Q20: How do you write function overloads?
List the call signatures, then one implementation that covers them all. Callers see only the overloads.
function parse(input: string): number;
function parse(input: string[]): number[];
function parse(input: string | string[]): number | number[] {
return Array.isArray(input) ? input.map(Number) : Number(input);
}
const one: number = parse("42");
const many: number[] = parse(["1", "2"]);
console.log(one + 1, many);
const bad: number = parse(["1"]);
$ npx tsc --noEmit --module nodenext q20.ts
q20.ts(10,7): error TS2322: Type 'number[]' is not assignable to type 'number'.
The return type follows the argument type, which a single union signature (number | number[]) couldn’t express.
Q21: What is the difference between Pick and Extract?
The same split as Q18: Pick keeps properties of an object type; Extract keeps members of a union.
interface User { id: number; name: string; email: string }
type Status = "active" | "inactive" | "pending";
const p: Pick<User, "name" | "email"> = { name: "Ada" };
const e: Extract<Status, "active" | "inactive" | "archived"> = "pending";
$ npx tsc --noEmit --module nodenext q21.ts
q21.ts(4,7): error TS2741: Property 'email' is missing in type '{ name: string; }' but required in type 'Pick<User, "name" | "email">'.
q21.ts(5,7): error TS2322: Type '"pending"' is not assignable to type '"active" | "inactive"'.
"archived" isn’t in Status, so Extract simply ignores it.
Q22: How do you type a class constructor, e.g. for a mixin?
new (...args: any[]) => T is the type of a constructor. Mixins take one and return a subclass.
type Constructor<T = {}> = new (...args: any[]) => T;
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
createdAt = new Date(Date.UTC(2026, 0, 1));
};
}
class User {
constructor(public name: string) {}
}
const TimestampedUser = Timestamped(User);
const user = new TimestampedUser("Ada");
console.log(user.name, user.createdAt.toISOString());
$ npx tsc --noEmit --module nodenext q22.ts
$ npx tsx q22.ts
Ada 2026-01-01T00:00:00.000Z
The result type has both name from User and createdAt from the mixin.
Q23: What is NonNullable<T>, and how is it implemented?
It removes null and undefined. The built-in is T & {}; a conditional-type version works too.
type MyNonNullable<T> = T extends null | undefined ? never : T;
type MaybeString = string | null | undefined;
const a: NonNullable<MaybeString> = null;
const b: MyNonNullable<MaybeString> = undefined;
$ npx tsc --noEmit --module nodenext q23.ts
q23.ts(4,7): error TS2322: Type 'null' is not assignable to type 'string'.
q23.ts(5,7): error TS2322: Type 'undefined' is not assignable to type 'string'.
The conditional version distributes over the union and maps null and undefined to never, which vanishes from the result.
Q24: What are template literal types?
Template literal syntax at the type level. Unions inside expand to every combination.
type Side = "top" | "right" | "bottom" | "left";
type Padding = `padding${Capitalize<Side>}`;
const ok: Padding = "paddingTop";
const bad: Padding = "paddingtop";
$ npx tsc --noEmit --module nodenext q24.ts
q24.ts(5,7): error TS2820: Type '"paddingtop"' is not assignable to type '"paddingBottom" | "paddingLeft" | "paddingRight" | "paddingTop"'. Did you mean '"paddingTop"'?
Q25: What does Awaited<T> do?
It unwraps a promise type, recursively. Awaited<ReturnType<typeof fn>> is the standard way to name what an async function resolves to.
async function getUser() {
return { id: 1, name: "Ada" };
}
const a: Awaited<Promise<Promise<number>>> = "x";
const b: Awaited<string> = 1;
const c: Awaited<ReturnType<typeof getUser>> = { id: 1 };
$ npx tsc --noEmit --module nodenext q25.ts
q25.ts(5,7): error TS2322: Type 'string' is not assignable to type 'number'.
q25.ts(6,7): error TS2322: Type 'number' is not assignable to type 'string'.
q25.ts(7,7): error TS2741: Property 'name' is missing in type '{ id: number; }' but required in type '{ id: number; name: string; }'.
Q26: How do interface extends and type & differ on conflicts?
extends reports a conflicting property as an error at the declaration. An intersection silently produces an impossible type.
interface A { x: number }
interface B { x: string }
interface C extends A, B {}
type D = A & B;
const d: D = { x: 1 };
$ npx tsc --noEmit --module nodenext q26.ts
q26.ts(4,11): error TS2320: Interface 'C' cannot simultaneously extend types 'A' and 'B'.
Named property 'x' of types 'A' and 'B' are not identical.
q26.ts(7,16): error TS2322: Type 'number' is not assignable to type 'never'.
D compiles on its own, and the problem only appears when you try to create one: x is number & string, which is never. That early, local error is why extends is usually preferred for composing object types.
Q27: How do you handle errors in catch blocks?
Under strict, the catch variable is unknown because JavaScript can throw anything. Narrow it, or normalise it once.
function getErrorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
for (const thrower of [() => { throw new TypeError("bad type"); }, () => { throw "just a string"; }]) {
try {
thrower();
} catch (err) {
console.log(getErrorMessage(err));
}
}
$ npx tsc --noEmit --module nodenext q27.ts
$ npx tsx q27.ts
bad type
just a string
Q28: What is ReturnType, and when is it useful?
It reads the return type off a function type, so a factory function can be the single source of truth for a shape.
function createUser(name: string) {
return { id: crypto.randomUUID(), name, createdAt: new Date() };
}
type User = ReturnType<typeof createUser>;
const probe: User = { name: "Ada" };
$ npx tsc --noEmit --module nodenext q28.ts
q28.ts(7,7): error TS2739: Type '{ name: string; }' is missing the following properties from type '{ id: `${string}-${string}-${string}-${string}-${string}`; name: string; createdAt: Date; }': id, createdAt
Add a field to createUser and User gains it automatically. Note that id isn’t just string: TypeScript’s DOM library (lib.dom.d.ts) declares crypto.randomUUID() as returning a UUID-shaped template literal type.
Q29: What is Parameters<T>?
It extracts a function’s parameter list as a tuple — the tool for wrappers that must keep the original signature.
function withLogging<F extends (...args: any[]) => any>(fn: F) {
return (...args: Parameters<F>): ReturnType<F> => {
console.log(`${fn.name}(${args.join(", ")})`);
return fn(...args);
};
}
function connect(host: string, port: number) {
return `${host}:${port}`;
}
const logged = withLogging(connect);
console.log(logged("localhost", 5432));
logged("localhost", "5432");
$ npx tsc --noEmit --module nodenext q29.ts
q29.ts(14,21): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
$ npx tsx q29.ts
connect(localhost, 5432)
localhost:5432
Q30: What is a branded type, and why use it?
Structural typing makes every number interchangeable. A brand — an intersection with a phantom property — makes, say, user IDs and post IDs incompatible, at zero runtime cost.
type UserId = number & { readonly __brand: "UserId" };
type PostId = number & { readonly __brand: "PostId" };
const userId = (n: number) => n as UserId;
const postId = (n: number) => n as PostId;
function getPost(id: PostId): string {
return `post ${id}`;
}
console.log(getPost(postId(42)));
getPost(userId(1));
getPost(42);
$ npx tsc --noEmit --module nodenext q30.ts
q30.ts(12,9): error TS2345: Argument of type 'UserId' is not assignable to parameter of type 'PostId'.
Type 'UserId' is not assignable to type '{ readonly __brand: "PostId"; }'.
Types of property '__brand' are incompatible.
Type '"UserId"' is not assignable to type '"PostId"'.
q30.ts(13,9): error TS2345: Argument of type 'number' is not assignable to parameter of type 'PostId'.
Type 'number' is not assignable to type '{ readonly __brand: "PostId"; }'.
The brand exists only in the type system: at runtime the IDs are plain numbers, and the functions that apply the brand are the one place to put validation.
Practice
1. Without running it, predict what Result resolves to, then check with a probe: type Result = Exclude<"a" | "b" | 1 | 2, string>.
Solution
type Result = Exclude<"a" | "b" | 1 | 2, string>;
const probe: Record<Result, true> = {};
$ npx tsc --noEmit --module nodenext practice-1.ts
practice-1.ts(2,7): error TS2739: Type '{}' is missing the following properties from type 'Record<Result, true>': 2, 1
1 | 2: Exclude distributes over the union and drops every member assignable to string. A Record keyed by the union forces tsc to list the members.
2. Implement MyReadonly<T> and MyRequired<T>, and show both enforcing their rules.
Solution
type MyReadonly<T> = { readonly [K in keyof T]: T[K] };
type MyRequired<T> = { [K in keyof T]-?: T[K] };
interface Settings { theme?: string; fontSize?: number }
const r: MyReadonly<Settings> = { theme: "dark" };
r.theme = "light";
const full: MyRequired<Settings> = { theme: "dark" };
$ npx tsc --noEmit --module nodenext practice-2.ts
practice-2.ts(7,3): error TS2540: Cannot assign to 'theme' because it is a read-only property.
practice-2.ts(9,7): error TS2741: Property 'fontSize' is missing in type '{ theme: string; }' but required in type 'MyRequired<Settings>'.
readonly adds the modifier; -? removes the optional one. +readonly and +? also exist, as the explicit forms of adding.
3. Write type DeepReadonly<T> and show that a nested property can’t be assigned.
Solution
type DeepReadonly<T> = T extends (...args: any[]) => any ? T : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> } : T;
interface Config { db: { host: string; ports: number[] }; log: (m: string) => void }
const config: DeepReadonly<Config> = { db: { host: "localhost", ports: [5432] }, log: console.log };
config.log("still callable");
config.db.host = "remote";
config.db.ports.push(5433);
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(7,11): error TS2540: Cannot assign to 'host' because it is a read-only property.
practice-3.ts(8,17): error TS2339: Property 'push' does not exist on type 'readonly number[]'.
Functions are left alone so they stay callable; objects and arrays are made readonly at every level, so even push disappears from the nested array.