在线代码运行BanShow Run

TypeScript 练习题

10 道 TypeScript 练习题,难度从打印输出到分组统计逐步递进,每题都附参考答案,可以直接在浏览器里运行验证,不需要本地环境。

打开 TypeScript 运行器 →

每道题都能直接在上面的编辑器里运行。「参考」是给卡住时看的,建议自己先写一遍再对照。本站不做类型检查,所以请重点关注编译能不能通过和运行结果对不对。

1. 用 interface 描述成绩并统计

给一组成绩,定义 Score 接口,输出平均分(保留一位小数)、最高分的科目、以及所有及格(≥60)的科目。

typescript
interface Score {
  subject: string;
  value: number;
}

const scores: Score[] = [
  { subject: "语文", value: 88 },
  { subject: "数学", value: 95 },
  { subject: "英语", value: 59 },
  { subject: "物理", value: 72 },
];

// 参考
const avg = scores.reduce((sum, s) => sum + s.value, 0) / scores.length;
const best = scores.reduce((a, b) => (b.value > a.value ? b : a));
const passed = scores.filter((s) => s.value >= 60).map((s) => s.subject);

console.log("平均分:", avg.toFixed(1));
console.log("最高分:", best.subject, best.value);
console.log("及格科目:", passed.join("、"));
console.log("不及格:", scores.filter((s) => s.value < 60).map((s) => `${s.subject}(${s.value})`).join("、") || "无");

要注意:reduce 不给初始值时,空数组会抛 TypeError。所以 reduce((sum, s) => ..., 0) 的 0 不能省。

2. 联合类型与类型收窄

写一个 format(value: string | number | boolean | null):字符串去空格后原样输出,数字保留两位小数,布尔输出「是 / 否」,null 输出「(空)」。

typescript
type Input = string | number | boolean | null;

// 参考
function format(value: Input): string {
  if (value === null) return "(空)";
  if (typeof value === "string") return value.trim();
  if (typeof value === "number") return value.toFixed(2);
  return value ? "是" : "否";        // 到这里只可能是 boolean
}

const cases: Input[] = ["  hello  ", 3.14159, true, false, null, 0, ""];
for (const c of cases) {
  console.log(JSON.stringify(c), "→", format(c));
}

要注意:收窄的顺序很重要。if (value === null) 要放在最前面,否则后面每次都要多判一次。typeof value === "number" 之后,TypeScript 就知道剩下的分支里 value 不可能是数字了。

3. 泛型函数三题

实现三个泛型函数:firstLast<T>(arr: T[]) 返回首尾元素组成的元组;countBy<T>(arr: T[], key: (item: T) => string) 按 key 计数;unique<T>(arr: T[]) 去重。

typescript
// 参考
function firstLast<T>(arr: T[]): [T | undefined, T | undefined] {
  return [arr[0], arr[arr.length - 1]];
}

function countBy<T>(arr: T[], key: (item: T) => string): Record<string, number> {
  const out: Record<string, number> = {};
  for (const item of arr) {
    const k = key(item);
    out[k] = (out[k] ?? 0) + 1;
  }
  return out;
}

function unique<T>(arr: T[]): T[] {
  return [...new Set(arr)];
}

interface User {
  name: string;
  city: string;
}
const users: User[] = [
  { name: "小明", city: "北京" },
  { name: "小红", city: "上海" },
  { name: "小刚", city: "北京" },
  { name: "小美", city: "广州" },
];

console.log(firstLast([1, 2, 3, 4]), firstLast<string>([]));
console.log(countBy(users, (u) => u.city));
console.log(countBy(users, (u) => u.name));
console.log(unique([1, 2, 2, 3, 3, 3]), unique(["a", "b", "a"]));

要注意:(item: T) => string 是「函数类型」的写法——参数叫 item、类型是 T,返回 string。Record<string, number> 表示「键是字符串、值是数字的对象」。

4. 用 enum 做一个订单状态机

订单状态有 待付款 → 已付款 → 已发货 → 已完成,另外任何状态都能进入 已取消。写一个 next() 函数,只在流转合法时返回下一个状态,否则返回 null。

typescript
enum Order {
  待付款 = "待付款",
  已付款 = "已付款",
  已发货 = "已发货",
  已完成 = "已完成",
  已取消 = "已取消",
}

// 参考:用 Record 描述"每个状态能到哪些状态"
const FLOW: Record<Order, Order[]> = {
  [Order.待付款]: [Order.已付款, Order.已取消],
  [Order.已付款]: [Order.已发货, Order.已取消],
  [Order.已发货]: [Order.已完成],
  [Order.已完成]: [],
  [Order.已取消]: [],
};

function can(from: Order, to: Order): boolean {
  return FLOW[from].includes(to);
}

const path: Order[] = [Order.待付款, Order.已付款, Order.已发货, Order.已完成];
for (let i = 0; i < path.length - 1; i++) {
  console.log(`${path[i]} → ${path[i + 1]}:`, can(path[i], path[i + 1]) ? "允许" : "禁止");
}
console.log(`已完成 → 已取消:`, can(Order.已完成, Order.已取消) ? "允许" : "禁止");
console.log("所有状态:", Object.values(FLOW).length, "个");
console.log(Object.keys(Order));

要注意:[Order.待付款]: [...] 里的方括号是「计算属性名」,不是数组。字符串枚举没有反向映射,所以 Object.keys(Order) 得到的是键名数组。

5. 抽象类:图形面积与周长

定义抽象类 Shape,要求子类实现 area() 和 perimeter(),父类提供一个 report() 方法打印两者。再实现 Rect、Circle、Triangle。

typescript
abstract class Shape {
  constructor(public name: string) {}
  abstract area(): number;
  abstract perimeter(): number;

  report(): void {
    console.log(`${this.name}:面积 ${this.area().toFixed(2)},周长 ${this.perimeter().toFixed(2)}`);
  }
}

// 参考
class Rect extends Shape {
  constructor(private w: number, private h: number) {
    super("矩形");
  }
  area(): number { return this.w * this.h; }
  perimeter(): number { return (this.w + this.h) * 2; }
}

class Circle extends Shape {
  constructor(private r: number) {
    super("圆形");
  }
  area(): number { return Math.PI * this.r ** 2; }
  perimeter(): number { return 2 * Math.PI * this.r; }
}

class Triangle extends Shape {
  constructor(private a: number, private b: number, private c: number) {
    super("三角形");
  }
  area(): number {                       // 海伦公式
    const p = this.perimeter() / 2;
    return Math.sqrt(p * (p - this.a) * (p - this.b) * (p - this.c));
  }
  perimeter(): number { return this.a + this.b + this.c; }
}

const shapes: Shape[] = [new Rect(3, 4), new Circle(1), new Triangle(3, 4, 5)];
for (const s of shapes) s.report();

console.log("总面积:", shapes.reduce((sum, s) => sum + s.area(), 0).toFixed(2));
console.log("按面积排序:", shapes.map((s) => s.name).join(" < ") === "" ? "" :
  [...shapes].sort((a, b) => a.area() - b.area()).map((s) => `${s.name}(${s.area().toFixed(1)})`).join(" < "));

要注意:子类的构造函数里必须先调用 super(...),才能用 this。abstract 在运行时会被擦掉,所以别指望它真的阻止 new Shape()。

6. 从 stdin 读入数据并解析成对象数组

「输入 (stdin)」面板里填四行:第一行是人数,后面每行是 姓名,年龄,城市。

typescript
interface Person {
  name: string;
  age: number;
  city: string;
}

// 参考
function parseLine(line: string): Person | null {
  const parts = line.split(",").map((s) => s.trim());
  if (parts.length !== 3) return null;
  const age = Number(parts[1]);
  if (Number.isNaN(age)) return null;
  return { name: parts[0], age, city: parts[2] };
}

const countLine = input();
const count = countLine === null ? 0 : Number(countLine);
console.log("声明的人数:", count);

const people: Person[] = [];
const bad: string[] = [];
for (let i = 0; i < count; i++) {
  const line = input();
  if (line === null) { console.log("输入不够,只读到", people.length, "条"); break; }
  const p = parseLine(line);
  if (p) people.push(p);
  else bad.push(line);
}

console.log("解析成功:", people.length, "条");
console.log(people);
if (bad.length) console.log("格式错误:", bad);

// 按城市分组统计
const byCity: Record<string, number> = {};
for (const p of people) byCity[p.city] = (byCity[p.city] ?? 0) + 1;
console.log("城市分布:", byCity);
console.log("平均年龄:", (people.reduce((s, p) => s + p.age, 0) / (people.length || 1)).toFixed(1));

要注意:input() 读完所有行后返回 null,所以每次都要判断。Number("") 是 0 而不是 NaN,空行要单独处理。

7. 用 keyof + Record 写一个安全的配置读取器

要求:get(key) 只能传配置对象里已有的键,取不到时返回默认值,并且能列出所有键。

typescript
const CONFIG = {
  site: "run.ban.show",
  port: 443,
  https: true,
  timeoutMs: 15000,
} as const;

type ConfigKey = keyof typeof CONFIG;

// 参考
class ConfigReader {
  constructor(private data: typeof CONFIG) {}

  get<K extends ConfigKey>(key: K): (typeof CONFIG)[K] {
    return this.data[key];
  }

  getOr<K extends ConfigKey>(key: K, fallback: (typeof CONFIG)[K]): (typeof CONFIG)[K] {
    const v = this.data[key];
    return v === undefined ? fallback : v;
  }

  keys(): ConfigKey[] {
    return Object.keys(this.data) as ConfigKey[];
  }

  dump(): string {
    return this.keys().map((k) => `${k}=${this.data[k]}`).join(", ");
  }
}

const cfg = new ConfigReader(CONFIG);
console.log(cfg.get("site"), cfg.get("port"));
console.log(cfg.get("https"), cfg.get("timeoutMs"));
console.log("所有键:", cfg.keys());
console.log(cfg.dump());

// Partial 的用法:只传需要覆盖的部分
const overrides: Partial<typeof CONFIG> = { port: 8080, https: false };
const merged = { ...CONFIG, ...overrides };
console.log("合并后:", merged);
console.log("键的数量:", Object.keys(merged).length);

要注意:Object.keys() 返回的是 string[],想当成 ConfigKey[] 用必须写 as ConfigKey[]。Partial<T> 把 T 的每个属性都变成可选。

8. 泛型 + async:带重试的模拟请求

写一个 withRetry<T>(fn: () => Promise<T>, times: number):失败就重试,最多 times 次,全失败则抛出最后一个错误。

typescript
// 参考
async function withRetry<T>(fn: () => Promise<T>, times: number, label = "任务"): Promise<T> {
  let lastError: unknown = null;
  for (let i = 1; i <= times; i++) {
    try {
      const value = await fn();
      if (i > 1) console.log(`${label} 第 ${i} 次成功`);
      return value;
    } catch (e) {
      lastError = e;
      console.log(`${label} 第 ${i} 次失败:`, e instanceof Error ? e.message : String(e));
    }
  }
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
}

interface User {
  id: number;
  name: string;
}

// 前两次失败、第三次成功的接口
let attempt = 0;
const flaky = (): Promise<User> =>
  new Promise((resolve, reject) => {
    attempt++;
    setTimeout(() => {
      if (attempt < 3) reject(new Error(`第 ${attempt} 次网络错误`));
      else resolve({ id: 1, name: "小明" });
    }, 30);
  });

const user = await withRetry(flaky, 5, "获取用户");
console.log("拿到:", user);

// 永远失败的情况:要接住异常
try {
  await withRetry(() => Promise.reject(new Error("服务不可用")), 2, "获取订单");
} catch (e) {
  console.log("最终失败:", e instanceof Error ? e.message : e);
}

// Promise.allSettled 的结果类型是 PromiseSettledResult<T>[]
const results = await Promise.allSettled([Promise.resolve(1), Promise.reject(new Error("x"))]);
for (const r of results) {
  console.log(r.status === "fulfilled" ? `成功:${r.value}` : `失败:${r.reason.message}`);
}

要注意:catch (e) 里的 e 类型是 unknown,不能直接 e.message,要先 e instanceof Error 判断。这是 TypeScript 4.4 之后的规则。

9. 用 satisfies 定义一张路由表

要求:每个路由有 path、title,可选 login;用 satisfies 保证结构正确,同时保留精确的字面量类型,最后打印所有需要登录的路由。

typescript
interface Route {
  path: string;
  title: string;
  login?: boolean;
}

// 参考
const ROUTES = {
  home: { path: "/", title: "首页" },
  python: { path: "/python/", title: "在线运行 Python", login: false },
  sql: { path: "/sql/", title: "在线运行 SQL" },
  admin: { path: "/admin/", title: "管理后台", login: true },
} satisfies Record<string, Route>;

type RouteKey = keyof typeof ROUTES;

function urlOf(key: RouteKey): string {
  return ROUTES[key].path;
}

function needLogin(key: RouteKey): boolean {
  return ROUTES[key].login ?? false;     // 可选属性要用 ?? 兜底
}

const keys = Object.keys(ROUTES) as RouteKey[];
console.log("全部路由:");
for (const k of keys) {
  console.log(`  ${k.padEnd(8)} ${urlOf(k).padEnd(10)} ${ROUTES[k].title}${needLogin(k) ? "(需登录)" : ""}`);
}
console.log("需要登录的:", keys.filter(needLogin));
console.log("标题总字数:", keys.reduce((s, k) => s + ROUTES[k].title.length, 0));

要注意:satisfies 和 : Record<string, Route> 的区别是——前者检查完之后 ROUTES.home 的类型仍然是精确的 { path: "/", title: "首页" },后者会被拓宽成 Route,就取不到具体的键了。

10. 类型守卫:把不可信的 JSON 变成 User[]

从一段 JSON 字符串解析出用户数组,但要逐条校验字段类型,非法的丢掉并记录原因。

typescript
interface User {
  id: number;
  name: string;
  age: number;
  email?: string;
}

// 参考:类型谓词函数,返回 v is User
function isUser(v: unknown): v is User {
  if (typeof v !== "object" || v === null) return false;
  const o = v as Record<string, unknown>;
  return typeof o.id === "number"
    && typeof o.name === "string"
    && typeof o.age === "number"
    && (o.email === undefined || typeof o.email === "string");
}

const raw = `[
  {"id": 1, "name": "小明", "age": 18, "email": "xm@example.com"},
  {"id": 2, "name": "小红", "age": 20},
  {"id": "3", "name": "小刚", "age": 17},
  {"id": 4, "name": "小美", "age": "十六"},
  {"id": 5, "name": "小强", "age": 19, "email": null},
  "我不是对象"
]`;

let parsed: unknown;
try {
  parsed = JSON.parse(raw);
} catch (e) {
  console.log("JSON 解析失败:", e instanceof Error ? e.message : e);
}

const users: User[] = [];
const rejected: string[] = [];
if (Array.isArray(parsed)) {
  for (const [i, item] of parsed.entries()) {
    if (isUser(item)) users.push(item);
    else rejected.push(`第 ${i + 1} 条:${JSON.stringify(item)}`);
  }
}

console.log("合法用户:", users.length, "条");
console.log(users);
console.log("被丢弃:");
for (const r of rejected) console.log("  ", r);
console.log("平均年龄:", (users.reduce((s, u) => s + u.age, 0) / (users.length || 1)).toFixed(1));

要注意:JSON.parse 返回的是 any,直接当 User[] 用是非常常见的 bug 来源。运行时判断类型只能靠 typeof / Array.isArray / instanceof,v is User 只是把判断结果告诉编译器。第 5 条 email: null 被丢弃,是因为校验要求 email 要么不存在要么是字符串。

换一门语言