Master TypeScript Migration from Koa2 to Midway: Tame any and Build a Strong Type System
This article shares practical experiences and techniques for migrating a medium‑size Koa2 project to TypeScript with Midway, covering tsconfig settings, the dangers of any, strategies using unknown, strict compiler options, type guards, assertion functions, and how to construct a concise, reusable type system for backend models.
Recently the author migrated a medium‑size Koa2 project (using MySQL, Sequelize, request) to TypeScript and shares the lessons learned.
Project Overview
The original project had about 100 interfaces and pages. After migration it runs on Midway, MySQL, sequelize‑typescript, and axios.
TypeScript Configuration
<code>"compilerOptions": {
"declaration": false,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"incremental": true,
"inlineSourceMap": true,
"module": "commonjs",
"newLine": "lf",
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"outDir": "dist",
"pretty": true,
"skipLibCheck": true,
"strict": true,
"strictPropertyInitialization": false,
"stripInternal": true,
"target": "ES2017"
}</code>The article is divided into two parts: handling
anyand building a robust type system.
Dealing with any
Using TypeScript forces us to confront the problems caused by
any. The author demonstrates why
anyis dangerous.
<code>function add(a: number, b: number): number {
return a + b;
}
var a: any = '1';
var b = 2;
console.log(add(a, b)); // '12'
</code>Because
ais typed as
any, the compiler skips type checking, resulting in string concatenation and an unexpected
'12'value that is still treated as a number downstream.
Another example shows JSON parsing returning
any:
<code>var resData = `{"a":"1","b":2}`;
function add(a: number, b: number): number { return a + b; }
var obj = JSON.parse(resData);
console.log(add(obj.a, obj.b)); // '12'
</code> JSON.parseis declared as returning
any, so the type checker cannot warn that
obj.ais a string.
To correct such mismatches, the author tries several approaches:
<code>var resData = `{"a":"1","b":2}`;
function add(a: number, b: number): number {
var c = parseInt(a); // Error: Argument of type 'number' is not assignable to parameter of type 'string'.
var d: string = a as string; // Error: Conversion of type 'number' to type 'string' may be a mistake.
var e = Number(a);
return a + b;
}
var obj = JSON.parse(resData);
console.log(add(obj.a, obj.b));
</code>Only
Number()works because its signature accepts
any. The author questions whether this defeats the purpose of static typing.
Sources of any
Enable strict compiler options (
"strict": true) to prevent implicit
any.
Inspect core libraries and third‑party typings for
anyusage.
Common culprits include
require,
JSON.parse, Koa
ctx.query, and Axios generic defaults.
<code>// Example: require returns any
interface NodeRequireFunction { (id: string): any; }
var path = require("path"); // type is any
</code> <code>// Axios generic defaults
interface AxiosInstance {
get<T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<R>;
}
interface AxiosResponse<T = any> { data: T; /* ... */ }
</code>Avoiding Explicit any
Do not write
anyyourself; use
unknownas the top type introduced in TypeScript 3.0.
TypeScript 3.0 introduces a new top type unknown . unknown is the type‑safe counterpart of any . Anything is assignable to unknown , but unknown isn’t assignable to anything else without a type assertion or control‑flow narrowing.
<code>function add(a: number, b: number): number { return a + b; }
var a: unknown = '1';
var b = 2;
console.log(add(a, b)); // Error: Argument of type 'unknown' is not assignable to parameter of type 'number'.
</code>Use type guards or assertion functions to narrow
unknown:
<code>function add(a: number, b: number): number { return a + b; }
var a: unknown = '1';
var b = 2;
if (typeof a == "number") {
console.log(add(a, b));
} else {
console.log('params error');
}
</code>Type Guards & Assertion Functions
Define custom type guards and assertion functions to validate data coming from
anysources.
<code>interface ApiCreateParams { name: string; info: string; }
function hasFieldOnBody<T extends string>(obj: unknown, names: Array<T>) : obj is { [P in T]: unknown } {
return typeof obj === "object" && obj !== null && names.every(name => name in (obj as any));
}
function assertApiCreateParams(data: unknown): asserts data is ApiCreateParams {
if (hasFieldOnBody(data, ['name', 'info']) && typeof (data as any).name === "string" && typeof (data as any).info === "string") {
console.log((data as ApiCreateParams).name, (data as ApiCreateParams).info);
} else {
throw "api create params error";
}
}
@get('/create')
async create(): Promise<void> {
let data = this.ctx.request.body; // any
assertApiCreateParams(data);
console.log(data); // now typed as ApiCreateParams
}
</code>Managing assertion functions in a dedicated file and following naming conventions makes unsafe
anyusage more controllable.
Do Not Use Type Assertions for any
Type assertions are a way to tell the compiler “trust me, I know what I’m doing.” They have no runtime impact and are not type‑safe.
<code>function add(a: number, b: number) { return a + b; }
var a: any = '1';
var b = 2;
var c = a as number;
add(c, b); // prints '12'
</code>Using
ason
anybypasses checks and can produce incorrect results.
Overriding Third‑Party any
<code>// Replace Koa Context query type
interface ParsedUrlQuery { [key: string]: string | string[]; }
interface IBody { [key: string]: unknown; }
interface RequestPlus extends Request { body: IBody }
interface ContextPlus extends Context { query: ParsedUrlQuery; request: RequestPlus }
@provide()
@controller('/api')
export class ApiController extends AbstractController {
@inject() ctx: ContextPlus;
}
</code>Building a Strong Type System
Large projects often need many similar types, which can violate the DRY principle. The article shows how to use TypeScript utilities to create concise, reusable types.
Interface Inheritance
<code>interface Paging { pageIndex: number; pageSize: number; }
interface APIQueryParams extends Paging { keyword: string; title: string; }
interface PackageQueryParams extends Paging { name: string; desc: string; }
</code>Conditional Types
<code>type Circle = { rad: number; x: number; y: number; }
type TypeName<T> = T extends { rad: number } ? Circle : unknown;
type T1 = TypeName<{ rad: number }>; // Circle
type T2 = TypeName<{ rad: string }>; // unknown
</code>Utility Types
<code>type Serializable = { toString: (data: unknown) => string };
type Loggable = { log: (data: unknown) => void };
type A = Serializable & Loggable; // intersection
type B = Serializable | Loggable; // union
</code>Intersection types require both contracts; union types require at least one.
Index & Mapped Types
<code>type Person = { name: string; age: number; };
type PersonKeys = keyof Person; // 'name' | 'age'
type PersonMap = { [K in PersonKeys]: boolean }; // { name: boolean; age: boolean }
</code>Mapped types let you transform each property uniformly.
Conditional Types with infer
<code>type Unpacked<T> =
T extends (infer U)[] ? U :
T extends (...args: any[]) => infer U ? U :
T extends Promise<infer U> ? U :
T;
type T0 = Unpacked<string>; // string
type T1 = Unpacked<string[]>; // string
type T2 = Unpacked<() => string>; // string
type T3 = Unpacked<Promise<string>>; // string
</code>Discriminated Unions
<code>interface Square { kind: "square"; size: number; }
interface Rectangle { kind: "rectangle"; width: number; height: number; }
interface Circle { kind: "circle"; radius: number; }
type Shape = Square | Rectangle | Circle;
function area(s: Shape) {
switch (s.kind) {
case "square": return s.size * s.size;
case "rectangle": return s.width * s.height;
case "circle": return Math.PI * s.radius ** 2;
}
}
</code>The
kindliteral discriminates the union, enabling safe narrowing.
Practical Example with Sequelize‑Typescript
<code>import { DataType, Model, Column, Comment, AutoIncrement, PrimaryKey } from 'sequelize-typescript';
const { STRING, TEXT, INTEGER, ENUM } = DataType;
export class ApiModel extends Model<ApiModel> {
@AutoIncrement @PrimaryKey @Comment("id")
@Column({ type: INTEGER({ length: 11 }), allowNull: false })
id!: number;
@Comment("parent") @Column({ type: INTEGER({ length: 11 }), allowNull: false })
parent!: number;
@Comment("name") @Column({ type: STRING(255), allowNull: false })
name!: string;
@Comment("url") @Column({ type: STRING(255), allowNull: false })
url!: string;
}
</code>Using
Omitand custom
Mergeutility types to derive API‑specific DTOs:
<code>type ApiObject = Omit<ApiModel, Exclude<keyof Model, "id">>; // { id:number; parent:number; name:string; url:string }
type Merge<T, S> = { [K in keyof (T & S)]: K extends keyof T ? T[K] : K extends keyof S ? S[K] : never };
type ApiCreateParams = Omit<ApiObject, "id">; // { parent:number; name:string; url:string }
type ApiQueryParams = Merge<Partial<ApiCreateParams>, Paging>; // { id?: number; parent?: number; name?: string; pageIndex:number; pageSize:number }
</code>Conclusion
TypeScript is a powerful and flexible tool whose features continue to evolve. It can be used lightly as a type‑annotation aid to catch simple errors, or more rigorously as a full type‑constraint system that requires maintaining a well‑designed type hierarchy. The author encourages developers to adopt strict compiler options, replace
anywith
unknown, and leverage TypeScript’s advanced type utilities to keep large codebases safe and maintainable.
Reference: Node.js 项目 TypeScript 改造指南(一)
WecTeam
WecTeam (维C团) is the front‑end technology team of JD.com’s Jingxi business unit, focusing on front‑end engineering, web performance optimization, mini‑program and app development, serverless, multi‑platform reuse, and visual building.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.