TypeScript 练习题(15 题)

TypeScript 学了类型系统不动手写等于白学。这一页给 15 道精选练习题,按难度分三级——基础 6 题(变量 / 运算 / 控制流 / 函数)、进阶 5 题(重点攻克泛型与工具类型)、实战 4 题(综合应用)。每题都含题目描述、提示、可折叠的参考答案。

使用建议:先别急着展开答案。读题后自己打开 VS Code、TypeScript Playground 或 官方 Playgound 试着写,卡住了再看提示,实在写不出再对照参考答案。TypeScript 答案不止一种,只要能通过 tsc 严格检查、可读性强就是好代码。

基础(6 题):变量 / 运算 / 控制流 / 函数

这一组覆盖 TypeScript 最基础的语言元素,目标是用最短的代码解决一个小问题,同时让 tsc 不报错——这才是 TS 工程师的日常。

第 1 题:翻转字符串

题目:写一个函数 reverseString(s: string): string,把字符串翻转过来。比如 'hello' 翻转成 'olleh'。

提示:经典三连 split('').reverse().join('');也可以转数组后用 ES6 解构交换 [arr[i], arr[j]] = [arr[j], arr[i]] 双指针翻转。

参考答案
// 方法 1:split / reverse / join 三连(最经典)
function reverseString(s: string): string {
  return s.split('').reverse().join('');
}

// 方法 2:手动双指针 + ES6 解构交换(理解原理)
function reverseString2(s: string): string {
  const arr = s.split('');
  let i = 0, j = arr.length - 1;
  while (i < j) {
    [arr[i], arr[j]] = [arr[j], arr[i]];
    i++; j--;
  }
  return arr.join('');
}

console.log(reverseString('hello'));       // olleh
console.log(reverseString('TypeScript'));   // pitSepyT
console.log(reverseString2('你好世界'));     // 界世好你
// 输出: olleh

第 2 题:判断素数

题目:写一个函数 isPrime(n: number): boolean,判断正整数 n 是否为素数。

提示:小于 2 的都不是素数;只需检查从 2 到 Math.sqrt(n)——超过平方根的因子必然配对一个已经检查过的更小因子。=== 是 TS 的严格相等。

参考答案
function isPrime(n: number): boolean {
  if (n < 2) return false;
  // 只需检查到 sqrt(n):超过的因子必然配对一个更小的、已检查过的因子
  const limit = Math.floor(Math.sqrt(n));
  for (let i = 2; i <= limit; i++) {
    if (n % i === 0) return false;
  }
  return true;
}

console.log(isPrime(2));   // true
console.log(isPrime(7));   // true
console.log(isPrime(10));  // false
console.log(isPrime(1));   // false
// 输出: true

第 3 题:FizzBuzz

题目:打印 1 到 15。3 的倍数打印 Fizz,5 的倍数打印 Buzz,同时是 3 和 5 的倍数打印 FizzBuzz,其余打印数字本身。

提示:if / else if / else 的判断顺序很关键——必须先判断"同时是 3 和 5 的倍数"(即 15 的倍数)。用 String(i) 把数字转字符串。

参考答案
for (let i = 1; i <= 15; i++) {
  let out: string;
  if (i % 15 === 0) out = 'FizzBuzz';       // 必须先判断 15(否则会被 3 或 5 截胡)
  else if (i % 3 === 0) out = 'Fizz';
  else if (i % 5 === 0) out = 'Buzz';
  else out = String(i);
  console.log(out);
}
// 输出(节选):1, 2, Fizz, 4, Buzz, Fizz, 7, ..., 14, FizzBuzz

第 4 题:数组最大值

题目:写一个函数 findMax(arr: number[]): number | null,返回数组中的最大值;空数组返回 null。

提示:Math.max(...arr) 配合展开运算符最简洁;但超大数组会栈溢出,需用手写循环。空数组单独判,返回 null 让调用方处理。

参考答案
// 方法 1:Math.max + 展开运算符(空数组返回 -Infinity,需要单独处理)
function findMax(arr: number[]): number | null {
  if (arr.length === 0) return null;
  return Math.max(...arr);
}

// 方法 2:手写遍历(适合超大数组,避免展开运算符栈溢出)
function findMax2(arr: number[]): number | null {
  if (arr.length === 0) return null;
  let m = arr[0];
  for (let i = 1; i < arr.length; i++) {
    if (arr[i] > m) m = arr[i];
  }
  return m;
}

console.log(findMax([3, 1, 4, 1, 5, 9, 2, 6]));  // 9
console.log(findMax([]));                          // null
// 输出: 9

第 5 题:数组求和

题目:给定一个数字数组,用 reduce 返回所有元素的和。

提示:arr.reduce((a, b) => a + b, 0)——第二个参数初始值 0 必须传,否则空数组会抛 TypeError: Reduce of empty array with no initial value。

参考答案
const nums: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

// 方法 1:reduce 最常用;初始值 0 必须传(否则空数组会抛 TypeError)
const total = nums.reduce((a, b) => a + b, 0);

// 方法 2:经典 for-of 循环
let sum = 0;
for (const x of nums) sum += x;

console.log(total);  // 55
console.log(sum);    // 55
// 输出: 55

第 6 题:计算阶乘

题目:写一个函数 factorial(n: number): number,返回 n!。要求 factorial(0) === 1,且对负数抛 Error。

提示:循环写法用累乘变量;递归写法更贴近数学定义 n! = n * (n-1)!,但 JS 递归深度有限,大数阶乘优先用循环。

参考答案
// 方法 1:循环(推荐,无栈溢出风险)
function factorial(n: number): number {
  if (n < 0) throw new Error('阶乘不接受负数');
  let result = 1;
  for (let i = 2; i <= n; i++) result *= i;
  return result;
}

// 方法 2:递归(更贴近数学定义,但 n 大时有栈溢出风险)
function factorialRec(n: number): number {
  if (n < 0) throw new Error('阶乘不接受负数');
  return n < 2 ? 1 : n * factorialRec(n - 1);
}

console.log(factorial(5));    // 120
console.log(factorial(0));    // 1
console.log(factorialRec(6)); // 720
// 输出: 120

进阶(5 题):泛型 / 工具类型

这一组聚焦 TypeScript 最强大的部分——泛型(让类型像变量一样参数化)与工具类型(在类型层面做"计算")。掌握泛型是从会用 TS 到驾驭 TS 的分水岭。

第 7 题:泛型函数 identity

题目:写一个泛型函数 identity<T>(x: T): T,返回输入本身。验证它能保留传入值的精确类型。

提示:<T> 是类型参数,调用时通常可省略(编译器根据实参推断);箭头函数泛型在 .tsx 文件里写 <T,> 避免与 JSX 标签歧义。

参考答案
// 泛型让函数类型保持"输入与输出同类型"
function identity<T>(x: T): T {
  return x;
}

const s: string = identity('hello');    // T 推断为 string
const n: number = identity(42);         // T 推断为 number
const arr: number[] = identity([1, 2, 3]);

// 箭头函数写法;.tsx 文件里要写 <T,> 避免与 JSX 标签混淆
const identity2 = <T>(x: T): T => x;

console.log(s, n, arr);   // hello 42 [ 1, 2, 3 ]
// 输出: hello 42 [ 1, 2, 3 ]

第 8 题:泛型约束 extends / keyof

题目:写两个函数:(a) lengthOf<T extends { length: number }>(x: T) 接受任何带 length 的值;(b) prop<T, K extends keyof T>(obj: T, key: K): T[K] 类型安全地取对象字段。

提示:extends 限定上界;keyof T 得到 T 的所有 key 联合;T[K] 是索引访问类型,根据 key 返回对应字段类型。

参考答案
// extends 约束:要求 T 必须含 length 属性
function lengthOf<T extends { length: number }>(x: T): number {
  return x.length;
}

console.log(lengthOf('hello'));    // 5(string 有 length)
console.log(lengthOf([1, 2, 3]));  // 3(array 有 length)
// console.log(lengthOf(123));     // 编译报错:number 没有 length

// keyof 取对象 key 联合,实现类型安全的取值
function prop<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { name: 'Alice', age: 30 };
const nameVal = prop(user, 'name');  // 类型推断为 string
const ageVal = prop(user, 'age');    // 类型推断为 number
// prop(user, 'email');               // 编译报错:'email' 不是 user 的 key

console.log(nameVal, ageVal);  // Alice 30
// 输出: Alice 30

第 9 题:泛型类 Stack

题目:实现泛型类 Stack<T>,有 push、pop、size(getter)三个成员。验证 number 栈和 string 栈互相隔离。

提示:private items: T[] = [] 内部数组;getter 用 get size(): number { ... } 语法,调用时不加括号。

参考答案
class Stack<T> {
  private items: T[] = [];

  push(x: T): void {
    this.items.push(x);
  }
  pop(): T | undefined {
    return this.items.pop();
  }
  get size(): number {       // getter 用 .size 访问,不加括号
    return this.items.length;
  }
}

const numStack = new Stack<number>();
numStack.push(1);
numStack.push(2);
numStack.push(3);
console.log(numStack.pop());   // 3
console.log(numStack.size);    // 2

const strStack = new Stack<string>();
strStack.push('a');
console.log(strStack.pop());   // a
// 输出: 3

第 10 题:联合类型与可辨识联合

题目:定义 type Shape 表示圆形、正方形、矩形三种形状,用 kind 字段做标签。写一个 area 函数,根据形状计算面积。

提示:可辨识联合(discriminated union)用字面量类型字段(如 kind: 'circle')做标签;switch (s.kind) 时编译器自动收窄每个 case 里的类型,无需手动断言。

参考答案
// 用字面量类型 kind 做"标签",switch 时编译器自动收窄类型
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; size: number }
  | { kind: 'rect'; width: number; height: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;       // s 自动收窄为 circle
    case 'square': return s.size ** 2;
    case 'rect':   return s.width * s.height;
  }
}

console.log(area({ kind: 'circle', radius: 2 }));         // 12.566...
console.log(area({ kind: 'square', size: 3 }));           // 9
console.log(area({ kind: 'rect', width: 2, height: 4 })); // 8
// 输出: 12.566370614359172

第 11 题:工具类型 Partial / Readonly / Pick

题目:给定 interface User { id; name; email },用三个内置工具类型:(a) Partial<User> 表示 patch 更新;(b) Readonly<User> 表示冻结对象;(c) Pick<User, 'id' | 'name'> 表示只挑两个字段。

提示:Partial<T> 所有字段变可选;Readonly<T> 所有字段变只读;Pick<T, K> 从 T 里挑 K 指定的字段——这三个是 TS 内置工具类型,无需自己实现。

参考答案
interface User {
  id: number;
  name: string;
  email: string;
}

// Partial<T>:所有字段变可选,常用于 patch 更新
function updateUser(id: number, patch: Partial<User>): void {
  console.log('更新用户 ' + id + ':', patch);
}
updateUser(1, { name: 'New Name' });   // 只传 name 也合法

// Readonly<T>:所有字段只读,防止意外修改
const frozen: Readonly<User> = { id: 1, name: 'A', email: 'a@x.com' };
// frozen.id = 2;   // 编译报错:只读属性

// Pick<T, K>:从 T 里挑几个字段
type UserPreview = Pick<User, 'id' | 'name'>;
const preview: UserPreview = { id: 1, name: 'A' };   // 不含 email 也行
console.log(preview);
// 输出: 更新用户 1: { name: 'New Name' }

实战(4 题):综合应用

这一组每题综合用到多个知识点——interface、异步、泛型、策略模式——做出接近真实场景的小程序。建议每题完整跑一遍,再想想怎么扩展。

第 12 题:学生管理(interface + filter/sort)

题目:定义 interface Student(姓名 + scores 数组 + 只读 average),写一个工厂函数,把三个学生按均分降序输出。

提示:用工厂函数 makeStudent 在构造时计算 average;[...students].sort((a, b) => b.average - a.average) 浅拷贝避免原地排序污染原数组。

参考答案
interface Student {
  name: string;
  scores: number[];
  readonly average: number;   // 计算属性(构造时确定)
}

function makeStudent(name: string, scores: number[]): Student {
  const sum = scores.reduce((a, b) => a + b, 0);
  const avg = scores.length === 0 ? 0 : sum / scores.length;
  // 保留一位小数
  return { name, scores, average: Math.round(avg * 10) / 10 };
}

const students: Student[] = [
  makeStudent('小明', [90, 80]),
  makeStudent('小红', [95, 88]),
  makeStudent('小刚', [70, 100]),
];

// [...arr] 浅拷贝避免原地排序污染原数组;按均分降序
const ranked = [...students].sort((a, b) => b.average - a.average);
ranked.forEach((s, i) => {
  console.log((i + 1) + '. ' + s.name + ' 均分 ' + s.average);
});
// 输出:
// 1. 小红 均分 91.5
// 2. 小明 均分 85
// 3. 小刚 均分 85

第 13 题:Promise + async/await

题目:模拟一个 fetchUser(id): Promise<User> 异步函数(用 setTimeout 延迟 100ms)。在 main 里用 async/await 顺序取一个用户、并发取两个用户。

提示:new Promise(resolve => setTimeout(() => resolve(...), 100)) 包装延时;async 函数里 await 暂停;Promise.all([p1, p2]) 并发,顺序无关时比逐个 await 更快。

参考答案
// 模拟异步 API:延迟 100ms 返回数据
function fetchUser(id: number): Promise<{ id: number; name: string }> {
  return new Promise(resolve => {
    setTimeout(() => resolve({ id, name: 'User ' + id }), 100);
  });
}

async function main() {
  // await 暂停函数直到 Promise resolve,写法像同步
  const u1 = await fetchUser(1);
  console.log(u1.name);   // User 1

  // 并发用 Promise.all,顺序无关时比逐个 await 更快
  const [a, b] = await Promise.all([fetchUser(2), fetchUser(3)]);
  console.log(a.name + ', ' + b.name);   // User 2, User 3
}

main();
// 输出(节选):User 1, User 2, User 3

第 14 题:泛型 EventEmitter<K, T>

题目:实现一个类型安全的 EventEmitter,K 是事件名字符串字面量联合(如 'login' | 'logout'),T 是事件数据类型。要求 emit 非法事件名时编译报错。

提示:class EventEmitter<K extends string, T> 两个类型参数;内部用 Record<string, Array<(data: T) => void>> 存 handler;??= 是逻辑空赋值,左侧为 null/undefined 时才赋值。

参考答案
// K 限定事件名(字符串字面量联合),T 是事件数据类型
class EventEmitter<K extends string, T> {
  private handlers: Record<string, Array<(data: T) => void>> = {};

  on(event: K, fn: (data: T) => void): void {
    // ??= 左侧为 null/undefined 时才赋值
    (this.handlers[event] ??= []).push(fn);
  }

  emit(event: K, data: T): void {
    (this.handlers[event] ?? []).forEach(fn => fn(data));
  }
}

// 事件名只能是 'login' | 'logout',数据形如 { userId }
const bus = new EventEmitter<'login' | 'logout', { userId: number }>();
bus.on('login', d => console.log('User logged in: ' + d.userId));
bus.emit('login', { userId: 42 });
// bus.emit('unknown', {});   // 编译报错:事件名不在联合里
// 输出: User logged in: 42

第 15 题:简易计算器(策略模式 + 联合类型)

题目:实现一个计算器,支持 + - * / 四则运算。要求:除数为 0 返回错误字符串;用策略模式(对象字面量映射运算符到函数)替代 switch-case。

提示:定义 type Op = '+' | '-' | '*' | '/';Record<Op, (a, b) => number> 把每个运算符映射到实现函数;除零特殊处理返回错误字符串。

参考答案
// 用对象字面量实现策略模式:每个 key 是一个运算策略
type Op = '+' | '-' | '*' | '/';
const strategies: Record<Op, (a: number, b: number) => number> = {
  '+': (a, b) => a + b,
  '-': (a, b) => a - b,
  '*': (a, b) => a * b,
  '/': (a, b) => a / b,
};

function calc(a: number, op: Op, b: number): number | string {
  if (op === '/' && b === 0) return 'Error: 除零';
  return strategies[op](a, b);
}

console.log(calc(3, '+', 5));    // 8
console.log(calc(10, '/', 4));   // 2.5
console.log(calc(2, '*', 9));    // 18
console.log(calc(1, '/', 0));    // Error: 除零
// 输出: 8

练习建议

不要只看答案。把每道题在 VS Code 或 TypeScript Playground 亲手敲一遍,开 "strict": true,改一改输入试试边界情况(空数组、负数、超长字符串、undefined)。tsc 报错本身就是最好的老师。

试着扩展:做完第 12 题学生管理,能不能加"按单科排名"?做完第 13 题异步,能不能加错误重试?做完第 14 题 EventEmitter,能不能加 once(只触发一次)?每个扩展都是一次小型的真实开发。

更多练习资源:刷算法去 LeetCode / Codewars;读官方文档 typescriptlang.org;TypeScript 类型体操去 Type Challenges。

← 上一篇 TypeScript 实战项目

返回 TypeScript 教程目录

✈️💬