Typescript 101

By 增廣建文

Agenda

  • Environemnt Setup
  • Basic Syntax
  • Fastify + Typescript
  • React + Typescript
  • Cypress + Typescript
  • Docusaurus + Typescript
  • Next.js + Typescript

Why use Typescript?

可以變免Javascript很常發生的一些怪異現象,透過限制type可以盡量使output符合期待

What is Typescript?

Typescript是由微軟打造擁有型別系統(Type System)與介面(Interface)設計的語言

目前已經問世十周年了,主要是在原生 JS 上包裝上一層新的語法

Typescript可以找到JS中潛在的bug並提出警告

Environemnt Setup

Part 01

VScode Plugins

  • Prettier(也可以用npm裝)

Intellij/ Webstorm Setting

已經有內建typescript的輔助工具來自動編譯typescript

但是prettier的插件需要額外安裝

NPM Packages

  • tsc, npm install typescript
  • ts-node, npm install ts-node
  • prettier, npm install prettier

Project Setting

使用tsc --init可以初始化一個typescript的專案
而typescript相關的設定可以使用tsconfig.json來調整

// index.ts
function say(words: string): void {
  console.log(words)
}
say('hi')

透過tsc就會自動產生編譯好的js檔,就能和寫js時一樣用node index.js執行
不想編譯後再去執行js檔的話也可以使用ts-node index.ts

Setting for Debug

如果想要對typescript code做debug的話,在tsconfig.json中的sourceMap必須是true

// tsconfig.json
"compilerOptions": {
    "sourceMap": true
  }

這樣ts檔在經過編譯後就會多出.js.map的檔案來讓debugger做使用

Prettier

要先安裝套件npm i -D prettier並設定.prettierrc

{
  "semi": false,
  "singleQuote": true,
  "printWidth": 120,
  "trailingComma": "none",
  "arrowParens": "always"
}

格式可以到prettier playground去找自己最喜歡的

Execute Prettier

// package.json
"scripts": {
    "fix-prettier": "prettier --write "./{src,test,examples,scripts}/**/*.ts""
},

這樣一來只要跑npm run fix-prettier就能自動整理排版了

Update nodeman Setting

透過concurrently這個套件可以一次同時執行多個指令(npm i -D concurrently)

// package.json
"scripts": {
    "dev": "concurrently "tsc -w " "nodemon out/index.js""
},

quokkajs + Typescript

quokka已經內建安裝typescriptts-node套件,所以可以不設定專案直接使用

Basic Syntax

Part 02

Types

  • 原始型別 Primitive Types:number, string, boolean, undefined, null
  • 物件型別 Object Types:陣列(Array<T>或T[]), Enum 與 Tuple
  • 其他:any, never, unknown

Simple Examples of Primitive Types

Typescript預設會做型別推論(Type Inference),就算assign變數時沒有指定type

系統仍然會嘗試去判斷type,如果判斷不出來就會報錯

let age: number = 20;
let hasPet: boolean = false;
let canBeNullableString: string;
let absolutelyEitherNullOrString: string | null = null;

Object Types

就算定義了一個object但沒有給type,typescript也能根據裡面的item去獨自推論

let info = {
  name: 'John',  // name: string
  age: 20, // age: number
  hasPet: false, // hasPet: boolean
};

如果強制指定type會在後面的interface提到

Function Types

let addition = function (param1: number, param2: number) {
  return param1 + param2;
};

let sum = (x: number, y: number): number => {
    return x + y;
}

arrow function在typescript中也是支援的

(Advanced) Function Types

type alias可以自訂type,type <custom-type-name> = <your-type>

const aJSONString = '{"Hello": "World", "luckyNumber": 14}';

let parsedJSON1 = JSON.parse(aJSONString) as { hello: string, luckyNumber: number };
let parsedJSON2 = <{ hello: string, luckyNumber: number }>JSON.parse(aJSONString);
let parsedJSON3: { hello: string, luckyNumber: number } = JSON.parse(aJSONString);

Array Types

let a: string[] = []
let b: number[] = []
let justAnObject: (string | number)[] = [1, '2', 3, '4', 5]

Array + Object Types

問號表示該欄位可有可無

let objectsArray1: ({ message: string })[] = [
  { message: 'Hello' },
  { message: 'Hi' },
  { message: 'Goodbye' }
];

let objectsArray2: ({ message: string, revolt?: boolean })[] = [
  { message: 'Hello' },
  { message: 'Hi', revolt: undefined },
  { message: 'Goodbye', revolt: true }
];

Tuples

JS原生只有Array,而Tuple是TS才有提供的

type Vehicle = [string, string, Date]  // define tuple type
type VehicleArray = (string | Date)[]

let BMWMotor: Vehicle = ['BMW', 'motorcycle', new Date(2019, 2, 17)];
let JaguarOffRoad = <Vehicle>['Jaguar', 'off-road', new Date(2019, 1, 9)];
let ToyotaRV = ['Toyota', 'recreational', new Date(2019, 3, 15)] as Vehicle;

tuple比array更有侷限性,除了元素的個數必須固定外,格式必須完全吻合

Enum 列舉

Enum也是TS特有的功能,裡面出現的內容不可重複且無順序性

enum WeekDay {
  Sunday,
  Monday,
  Tuesday,
  // etc...
}
enum requestStatusCodes {
  error = 0,
  success = 1,
}

裡面定義好的值是常數,無法再被改變,如果沒assign值會從0開始算index

Enum Usage

搭配前一頁定義的enum可以去判斷input是否符合

const handleResponseStatus = (status: number): void => {
  switch (status) {
    case requestStatusCodes.success:
      // Do something...
      break;
    case requestStatusCodes.error:
      // Do something...
      break;
    default:
      throw (new Error('No have status code!'));
  }
};

Literal Types

只要是表達廣義物件的格式或者是任意型別(any)複合組合(union與intersection)都算

type MathOperator = (n1: number, n2: number) => number

let powerOp: MathOperator = function (n1: number, n2: number) {
  return n1 ** n2
};

Optional Properties

Optional Properties可以讓type的檢查機制變得稍微較寬鬆一點

enum Gender { Male, Female, Other }

type TestAccountInfo = {
  account: string,
  password: string,
  nickname: string | undefined,
  birth: Date | undefined,
  gender?: Gender,
  subscribed: boolean
}

type VehicleInfoWithOptionalElements = [string, string, string?, Date?]

特殊型別 - Never

never會在兩種期況下發生,第一是遇到無限迴圈時,第二則是有例外狀況

// 狀況一
let executesForever = function forever() {
  while(true) {
    /* Stuck in here forever... */
  }
}
// 狀況二
let mustThrowError = function () {
  throw new Error('Throw new error!')
}
let mustAcceptsNever: never = mustThrowError()

特殊型別 - any & unknown

any表示任何型態都可以,所以在實務上應該盡量避免使用到,不然就失去type的意義

unknown是一個更安全版的any,他只能被assign到any或是unkown的variable

let isAny: any
let isUnknown: unknown

isAny     = isUnknown
isUnknown = isUnknown

// wrap原本不安全的function, JSON.parse的return預設type是any
function safelyParseJSON(jsonString: string): unknown {
  return JSON.parse(jsonString);
}

Interface

除了透過type去指定客製化的型別外,interface也具有類似的特性

interface Person {
  name: string
  age: number
  hasPet: boolean
}

type PersonalInfo = {
  name: string,
  age: number,
  hasPet: boolean
}

Interface for Function Type

interface UserInfo {
  id: number
  name: string
  birth: Date
  interests: string[]
}

interface UpdateRecord {
  (id: number, newRecord: UserInfo): void
}

function change(id, newRecord): UpdateRecord {
  console.log(id)
}

Type Extension

能夠擴展的優勢就是interface獨有的(type不能這樣使用)

interface AccountSystem {
  email: string;
  password: string;
  subscribed: boolean;
}

interface AccountPersonalInfo {
  nickname?: string;
  birth?: Date;
}

// UserAccount 是 AccountSystem 與 AccountPersonalInfo 的結合
interface UserAccount extends AccountSystem, AccountPersonalInfo {}

Interface vs Type

  • Interface: 可以擴充設計、組裝出更複雜的Type
  • Type: 靜態的資料型別,一旦建立就不能修改
    • 但是可以透過intersection與union組出新的Type

Fastify + Typescript

Part 03

Setup

npm i fastify pino-pretty dotenv  # only needed when creating a new project
npm i -D typescript @types/node
npx tsc --init --sourceMap --rootDir src --outDir out

Setup for Typescript

透過前面的tsc指令去做設定後就會得到類似的結果

// tsconfig.json
"include": ["src/**/*.ts"],
"exclude": ["node_modules"],

// "compilerOptions": {
//   "outDir": "./out",
//   "rootDir": "./src",
// }

Create .env for Environment Variables

.env的檔案內去設定會用到的環境變數,會搭配dotenv套件做使用

FASTIFY_PORT=8888
FASTIFY_ENABLE_LOGGING=true
ENV=dev

Sample Code for Fastify Server with Typescript 1/2

要先把會用到的type都給先import進來

// server.ts
import fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { Server, IncomingMessage, ServerResponse } from 'http'

const server: FastifyInstance<Server, IncomingMessage, ServerResponse> = fastify({
    logger: {
        level: 'info',
    }
})

Sample Code for Fastify Server with Typescript 2/2

// server.ts
const startFastify: (port: number) => FastifyInstance<Server, IncomingMessage, 
ServerResponse> = (port) => {
    server.listen(port, '0.0.0.0', (err, _) => {
        if (err) {
            console.error(err)
        }
    })
    server.get('/ping', async (request: FastifyRequest, reply: FastifyReply) => {
        return reply.status(200).send({ msg: 'pong' })
    })

    return server
}

export { startFastify }

Start Fastify Server

// index.ts
import { startFastify } from './server'
import * as dotenv from 'dotenv'

dotenv.config()
const port = process.env.FASTIFY_PORT || 4000

// Start your server
const server = startFastify(Number(port))

export { server }

要先使用tsc來build整個專案後才可以用node out/index.js來啟動

Add run and build Script

// package.json
"scripts": {
  "build": "tsc",
  "start": "node out/index.js"
},

這樣可以簡化每次都要下一長串指令的麻煩

Mongoose Setup

mongoose相關的code也會需要用到type,所以要安裝額外的套件

npm i -D @types/mongoose mongodb @types/mongodb

在.env中要補上以下的設定

MONGO_HOST=localhost
MONGO_PORT=27017
MONGO_DATABASE=myMERN

Refactor Mongoose Schema - Add Types

// types/cat.ts
interface ICat {
    name: string
    weight: number
}
export { ICat }

Assign Type for Schema

// model/cat.ts
import { model, Schema } from 'mongoose'
import { ICat } from '../types/cat'
const catSchema: Schema = new Schema(
    {
        name: {
            type: String,
            required: true
        },
        weight: {
            type: Number,
            default: 0
        }
    },
)
export default model<ICat>('Cat', catSchema)

Remove Vesion Key in Document

如果要把包含底線的__V欄位給移除並拷貝一份_id成id的話需要做以下設定

// models/cat.ts
catSchema.set('toJSON', {
    virtuals: true,
    versionKey: false,
})

Refactor Repository Pattern - GET & POST

// repo/cat-repo.ts
import { ICat } from './types/cat'
import Cat from './models/cat'
interface CatRepo {
  getCats(): Promise<Array<ICat>>
  addCat(catBody: ICat): Promise<ICat>
}
class CatRepoImpl implements CatRepo {
    private constructor() {}
    async getCats(): Promise<Array<ICat>> {
        return Cat.find()
    }
    async addCat(catBody: ICat): Promise<ICat> {
        return Cat.create(catBody)
    }
}
export { CatRepoImpl }

Refactor GET endpoints with Parameter and Query

// server.ts
interface IdParams {
    id: string
}

server.get<{ Params: IdParams }>('/cats/:id', async (request, reply) => {
  const id = request.params.id
  // ...
})

server.get<{ Querystring: IdParams }>('/cats2', async (request, reply) => {
  const id = request.query.id
  // ...
})

Refactor POST endpoints

// server.ts
server.post('/cats', async (request, reply) => {
  const catRepo = new CatRepoImpl()
  try {
    const catBody = request.body as ICat
    const cat = await catRepo.addCat(catBody)
    return reply.status(201).send({ cat })
  } catch (error) {
    return reply.status(500).send({ msg: `Internal Server Error: ${error}` })
  }
})

自行練習PUT & DELETE

Custom Response Schema

可以確保回傳的格式有符合我們的要求

// server.ts
import { Type, Static } from '@sinclair/typebox'

const CatsResponse = Type.Object({
  cats: Type.Array(
    Type.Object({
      id: Type.String(),
      name: Type.String(),
      weight: Type.Number()
    })
  )
})
type CatsResponse = Static<typeof CatsResponse>

Use Custom Schema in endpoints

// server.ts
opts = { ...opts, schema: { response: { 200: CatsResponse, 201: CatResponse } } }
// put opts at the second parameter
server.get('/cats', opts, async (request, reply) => {
  const catRepo = CatRepoImpl.of()
  try {
    const cats = await catRepo.getCats()
    return reply.status(200).send({ cats })
  } catch (error) {
    return reply.status(500).send({ msg: 'Internal Server Error' })
  }
})

Endpoints as Plugin 1/2

// routes/cat.ts
const CatRouter = (server: FastifyInstance, opts: RouteShorthandOptions, done: 
(error?: Error) => void) => {
    server.get('/cats', async (request, reply) => {
        const catRepo = new CatRepoImpl()
        try {
            const cats = await catRepo.getCats()
            return reply.status(200).send({ cats })
        } catch (error) {
            return reply.status(500).send({ msg: `Internal Server Error: ${error}` })
        }
    })
    done()
}
export { CatRouter }

Endpoints as Plugin 2/2

把部分的endpoints都移到上一頁的router去

// server.ts
server.register(CatRouter, { prefix: '/v1' })

在原本的server.ts中改成註冊router就好,endpoints就會變成localhost:4000/v1/cats

Refactor Jest testcases

要先安裝一下typescript會用到的套件npm i -D ts-jest @types/jest

// jest.config.js
module.exports = {
  preset: "ts-jest",
  testEnvironment: "node",
  moduleFileExtensions: [
    "ts",
    "js",
  ],
  testPathIgnorePatterns: [
    "/node_modules/",
    "/out/"
  ]
}

Sciprt to Run tests

為了方便產testcase的report可以把script改成底下

// package.json
"scripts": {
  "test": "npx jest --verbose --coverage --runInBand",

GET testcase

多數的地方都和舊的相同

// tests/server.spec.ts
import { FastifyInstance } from 'fastify'
import { startFastify } from '../server'
import { Server, IncomingMessage, ServerResponse } from 'http'
 
describe('API test', () => {
    let server: FastifyInstance<Server, IncomingMessage, ServerResponse>
    // ...
})

DB Connection for Jest

// tests/db.ts
export const connect = async (): Promise<void> => {
    // ...
}

export const closeDatabase: () => Promise<void> = async () => {
    // ...
}

export const clearDatabase: () => Promise<void> = async () => {
    // ...
}

POST testcase

import * as dbHandler from './db'
import { ICat } from '../types/cat'
describe('Cat API test', () => {
    // ...
    it('should successfully post a cat to mongodb', async () => {
        const response = await server.inject({
        method: 'POST',
        url: '/api/cats',
        payload: { name: 'fat cat', weight: 6.8 }
        })
        const cat: ICat = JSON.parse(response.body)['cat']
        expect(cat.name).toBe('fat cat')
        expect(cat.weight).toBe(6.8)
    })
})

React + Typescript

Part 04

Create React Project with Typescript Support

$ create-react-app hello-world --typescript

Migrate from Existing Projects

要手動補裝一些套件npm install --save @types/react @types/react-dom @types/jest

還要跑npx tsc --init來產生ts的設定檔tsconfig.json

再來就是要把所有.js檔改成.ts or tsx並逐一debug

如果部分不會改的也可以參考create-react-app產生的

Props with Type

interface Props {
  text: string;
}

const Banner = ({ text }: Props) => {
  return <h1>Hello, {text}</h1>;
};

Event Type

通常會藉由IDE的提示來知道該用什麼Type

interface Props {
  onClick(event: React.MouseEvent<HTMLButtonElement>): void;
}

useState + Type

代type的方式就和平常使用function時一樣

// useState<StateType>();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [data, setData] = useState<DateType | null>(null);

Redux Toolkit + Typescript - Store

// store.ts
import { configureStore } from '@reduxjs/toolkit'
const store = configureStore({
  reducer: {
    one: oneSlice.reducer,
    two: twoSlice.reducer,
  },
})
export type RootState = ReturnType<typeof store.getState>

export default store

useDispatch & useSelector

import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { store, RootState } from './store';

type AppDispatch = typeof store.dispatch;

// 之後在元件中,使用帶有型別資訊的 useDispatch 和 useSelector
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Reducer

const increment = createAction<number, 'increment'>('increment')
const decrement = createAction<number, 'decrement'>('decrement')
createReducer(0, (builder) =>
  builder
    .addCase(increment, (state, action) => {
      // action is inferred correctly here
    })
    .addCase(decrement, (state, action: PayloadAction<string>) => {
      // this would error out
    })
)

createSlice

type SliceState = { state: 'loading' } | { state: 'finished'; data: string }

const initialState: SliceState = { state: 'loading' }
const slice = createSlice({
  name: 'test',
  initialState: initialState,
  // initialState: { state: 'loading' } as SliceState,
  reducers: {
    increment: (state, action: PayloadAction<number>) => state + action.payload,
  },
})
// now available:
slice.actions.increment(2)
// also available:
slice.caseReducers.increment(0, { type: 'increment', payload: 5 })

createAsyncThunk

interface MyData {
  // ...
}

const fetchUserById = createAsyncThunk(
  'users/fetchById',
  // Declare the type your function argument here:
  async (userId: number) => {
    const response = await fetch(`https://reqres.in/api/users/${userId}`)
    // Inferred return type: Promise<MyData>
    return (await response.json()) as MyData
  }
)

Cypress + Typescript

Part 05

Docusaurus + Typescript

Part 06

Next.js + Typescript

Part 07

Reference

Typescript:

Fastify:

ANS

Refactor Repository Pattern - PUT & DELETE

// repo/cat-repo.ts
interface CatRepo {
    // ...
    updateCat(id: String, catBody: ICat): Promise<ICat | null>
    deleteCat(id: string): Promise<ICat | null> 
}
class CatRepoImpl implements CatRepo {
    // ...
    async updateCat(id: String, catBody: ICat): Promise<ICat | null> {
        return Cat.findByIdAndUpdate(id, catBody, { new: true })
    }
    async deleteCat(id: string): Promise<ICat | null> {
        return Cat.findByIdAndDelete(id)
    }
}

Refactor PUT endpoints

// server.ts
import { Types } from 'mongoose'
server.put<{ Params: IdParams; Body: ICat }>('/cats/:id', async (request, reply) => {
  const catRepo = new CatRepoImpl()
  try {
    const catBody = request.body
    const id = request.params.id
    if (!Types.ObjectId.isValid(id)) {
        return reply.status(400).send({msg: `Invalid id`})
    }
    const cat = await catRepo.updateCat(id, catBody)
    if (cat) return reply.status(200).send({ cat })
    else return reply.status(404).send({msg: `Cat #${id} Not Found`})
  } catch (error) {
    return reply.status(500).send({ msg: error })
  }
})

Refactor DELETE endpoints

// server.ts
server.delete<{ Params: IdParams }>('/cats/:id', async (request, reply) => {
  const catRepo = CatRepoImpl.of()
  try {
    const id = request.params.id
    if (!Types.ObjectId.isValid(id)) {
      return reply.status(400).send({ msg: `Invalid id` })
    }
    const cat = await catRepo.deleteCat(id)
    if (cat) return reply.status(204).send()
    else return reply.status(404).send({ msg: `Cat #${id} Not Found` })
  } catch (error) {
    return reply.status(500).send({ msg: error })
  }
})