FullStackOpen Part9-c Typing an Express app メモ
Setting up the project
TypeScriptを使って本格的にコードを書く。
今回のプロジェクトはFlight-diary。
必要なものを準備する
これは他の一般的なプロジェクトにも流用可能
npm initする
npm installする
npm install expressnpm install --save-devする
npm install --save-dev typescript eslint @types/express @typescript-eslint/eslint-plugin @typescript-eslint/parser ts-node-devpackage.json scriptを追加
.eslingrcを追加
.eslintignoreを追加し、buildを無視する
"npm run tsc -- --init"で初期化し、tsconfig.jsonを設定
package.jsonはこんな感じ
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"tsc": "tsc",
"dev": "ts-node-dev index.ts",
"lint": "eslint --ext .ts .",
"start": "node build/index.js"
},
.eslingrcはこんな感じ
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"plugins": [
"@typescript-eslint"
],
"env": {
"browser": true,
"es6": true,
"node": true
},
"rules": {
"@typescript-eslint/semi": [
"error"
],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/restrict-template-expressions": "off",
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/no-unsafe-member-access": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_"
}
],
"no-case-declarations": "off"
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
}
}
tsconfig.jsonはこんな感じ
{
"compilerOptions": {
"target": "ES6",
"outDir": "./build/",
"module": "commonjs",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true
}
}
Let there be code
index.tsを書く
import express from 'express';
const app = express();
app.use(express.json());
const PORT = 3000;
app.get('/ping', (_req, res) => {
console.log('someone pinged here');
res.send('pong');
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
npm run tscを実行するとjavascriptにコンパイルされる
npm startで開始
Implementing the functionality
ルーティングとデータ処理をするサービスに分けて実装
//diaries.ts
import express from 'express';
const router = express.Router();
import diaryService from '../services/diaryService';
router.get('/', (_req, res) => {
const diaries = diaryService.getEntries();
res.send(diaries);
});
router.post('/', (_req, res) => {
res.send('Saving a diary!');
});
export default router;
//diaryService.ts
import diaryEntries from '../../data/entries';
import { DiaryEntry } from '../types';
const getEntries = (): Array<DiaryEntry> => {
return diaryEntries;
};
const addDiary = () => {
return null;
};
export default {
getEntries,
addDiary
};
types.tsを作成し、ユーザー定義の型を登録しておくとよい
export type Weather = 'sunny' | 'rainy' | 'cloudy' | 'windy' | 'stormy';
export type Visibility = 'great' | 'good' | 'ok' | 'poor';
export interface DiaryEntry {
id: number;
date: string;
weather: Weather;
visibility: Visibility;
comment?: string;//?マークを付けるとオプションになる
}
日記データもJSONではなくtsとしてひとまず定義
import { DiaryEntry } from '../src/types';
const diaryEntries: Array<DiaryEntry> =
[
{
"id": 1,
"date": "2017-01-01",
"weather": "rainy",
"visibility": "poor",
"comment": "Pretty scary flight, I'm glad I'm alive"
},
{
"id": 2,
"date": "2017-04-01",
"weather": "sunny",
"visibility": "good",
"comment": "Everything went better than expected, I'm learning much"
},
{
"id": 3,
"date": "2017-04-15",
"weather": "windy",
"visibility": "good",
"comment": "I'm getting pretty confident although I hit a flock of birds"
},
{
"id": 4,
"date": "2017-05-11",
"weather": "cloudy",
"visibility": "good",
"comment": "I almost failed the landing but I survived"
}
];
export default diaryEntries;
Node and JSON modules
tsconfigでresolveJsonModuleを有効にしていると、tsファイルよりjsonが優先されてインポートされる。
不要な混乱を避けるため、同じフォルダレベルでは同名のファイルは避けるようにしよう。
Utility Types
Interfaceを使って作成したオブジェクトの一部のプロパティのみを使いたい場合、PickとOmitという
例えばDiaryEntryのcommentプロパティのみ取り除きたい場合は以下の方法になる
const getNonSensitiveEntries =
(): Pick<DiaryEntry, 'id' | 'date' | 'weather' | 'visibility'>[] => {
// ...
}
const getNonSensitiveEntries = (): Omit<DiaryEntry, 'comment'>[] => {
// ...
}
ただしあくまでこれはTypescriptの型の話なので、実際にcommentを取り除くためには、getNonSensitiveEntriesでmapを使用する
import diaries from '../../data/entries.ts'
import { NonSensitiveDiaryEntry, DiaryEntry } from '../types'
const getEntries = () : DiaryEntry[] => {
return diaries
}
const getNonSensitiveEntries = (): NonSensitiveDiaryEntry[] => {
return diaries.map(({ id, date, weather, visibility }) => ({
id,
date,
weather,
visibility,
}));
};
const addDiary = () => {
return null;
}
export default {
getEntries,
getNonSensitiveEntries,
addDiary
}
types.tsでも型を追加しておく
export type Weather = 'sunny' | 'rainy' | 'cloudy' | 'windy' | 'stormy';
export type Visibility = 'great' | 'good' | 'ok' | 'poor';
export interface DiaryEntry {
id: number;
date: string;
weather: Weather;
visibility: Visibility;
comment?: string;
}
export type NonSensitiveDiaryEntry = Omit<DiaryEntry, 'comment'>;
Preventing an accidental undefined result
/api/diaries/:idのルートを実装する
以下のようにfindByIdをサービスに追加
const findById = (id: number): DiaryEntry | undefined => {
const entry = diaries.find(d => d.id === id);
return entry;
}
find関数でidで検索すると、一致するエントリーが存在しない場合はundefinedになるため、返り値の型をDiaryEntry | undefinedとしておく
undefinedが返された時の挙動はルート側で設定する
import express from 'express';
import diaryService from '../services/diaryService'
router.get('/:id', (req, res) => {
const diary = diaryService.findById(Number(req.params.id));
if (diary) {
res.send(diary);
} else {
res.sendStatus(404);
}
});
// ...
export default router;
Adding a new diary
新しいエントリーを追加する時点ではまだidは振られていないため、既存のDiaryEntryでは型として使えない。
そのため新しいNewDiaryEntryという型をOmitで作る.
export type NewDiaryEntry = Omit<DiaryEntry, 'id'>;
これで引数を型ありで持ってこれる
const addDiary = (entry: NewDiaryEntry): DiaryEntry => {
const newDiaryEntry = {
id: Math.max(...diaryEntries.map(d => d.id)) + 1,
...entry
};
diaryEntries.push(newDiaryEntry);
return newDiaryEntry;
};
Proofing requests
リクエストのbodyを中身をチェックする関数を作る。
./utils.tsに以下のコードを追加
import { NewDiaryEntry } from './types';
const toNewDiaryEntry = (object: unknown): NewDiaryEntry => {
const newDiaryEntry: NewDiaryEntry = {
date: object.date,
}
};
export default toNewDiaryEntry;
中身がよくわからないものをチェックするときはunknown型を使うとよい。
any型と似ているが違いはChatGPTが教えてくれた。
any
any型は最も柔軟な型で、どんな種類の値でも受け入れることができます。any型の変数には、任意のメソッドやプロパティを呼び出すことが可能で、型チェックは行われません。これにより、ランタイムエラーのリスクが高まる可能性があります。
let anything: any = "hello";
console.log(anything.foo()); // エラーにならないが、実行するとランタイムエラーになる
unknown
一方、unknown型も任意の値を保持できますが、その値を直接操作することはできません。それを使用する前に、値がどの型であるかをTypeScriptに伝える型チェックを行う必要があります。これは、unknown型がより安全であることを意味します。
let unknownValue: unknown = "hello";
console.log(unknownValue.foo()); // コンパイルエラー
if (typeof unknownValue === "string") {
console.log(unknownValue.toUpperCase()); // これは問題ない
}
Type guards
型ガードのやり方はいくつかある。
こちらもChatGPTがわかりやすく解説してくれた
typeof型ガード:
基本的な型に対するチェックを行います。 "string", "number", "boolean", "object", "undefined", "function", "symbol"といった型を判別することができます。
if (typeof myVar === "string") {
console.log(myVar.substr(1)); // myVarはこのスコープ内でstringとして扱われます。
}
instanceof型ガード:
クラスのインスタンスをチェックするために使用します。
class MyClass {
myMethod() {
// ...
}
}
const myInstance = new MyClass();
if (myInstance instanceof MyClass) {
myInstance.myMethod(); // myInstanceはこのスコープ内でMyClassのインスタンスとして扱われます。
}
ユーザ定義型ガード:
booleanを返す関数を作り、その関数内で特定の型の確認を行います。関数の戻り値型をarg is Typeのように指定することで、TypeScriptに型ガードとしてこの関数を使用するように指示します。
function isString(test: any): test is string {
return typeof test === "string";
}
if (isString(myVar)) {
console.log(myVar.substr(1)); // myVarはこのスコープ内でstringとして扱われます。
}
Enum
TypeScriptのenum定義は以下のようにする
export enum Weather {
Sunny = 'sunny',
Rainy = 'rainy',
Cloudy = 'cloudy',
Windy = 'windy',
Stormy = 'stormy'
}
export enum Visibility {
Great = 'great',
Good = 'good',
Ok = 'ok',
Poor = 'poor',
}
全体のコードはこんな感じ
各種型ガードをunknownとつくものには実装する
unknown型を型ガードをして値を使えるスコープに注意。
//utils.ts
import { NewDiaryEntry, Visibility, Weather } from './types';
const isString = (text: unknown): text is string => {
return typeof text === 'string' || text instanceof String;
};
const parseComment = (comment: unknown): string => {
if (!isString(comment)) {
throw new Error('Incorrect or missing comment');
}
return comment;
};
const isDate = (date: string): boolean => {
return Boolean(Date.parse(date));
};
const parseDate = (date: unknown): string => {
if (!isString(date) || !isDate(date)) {
throw new Error('Incorrect or missing date: ' + date);
}
return date;
};
const isWeather = (weather: string): weather is Weather => {
return Object.values(Weather).map(v => v.toString()).includes(weather);
};
const parseWeather = (weather: unknown): Weather => {
if (!isString(weather) || !isWeather(weather)) {
throw new Error('Incorrect or missing weather: ' + weather);
}
return weather;
};
const isVisibility = (visibility: string): visibility is Visibility => {
return Object.values(Visibility).map(v => v.toString()).includes(visibility);
};
const parseVisibility = (visibility: unknown): Visibility => {
if (!isString(visibility) || !isVisibility(visibility)) {
throw new Error('Incorrect or missing visibility: ' + visibility);
}
return visibility;
};
const toNewDiaryEntry = (object: unknown): NewDiaryEntry => {
if (!object || typeof object !== 'object') {
throw new Error('Incorrect or missing data');
}
if ('comment' in object && 'date' in object && 'weather' in object && 'visibility' in object) {
const newDiaryEntry: NewDiaryEntry = {
comment: parseComment(object.comment),
date: parseDate(object.date),
weather: parseWeather(object.weather),
visibility: parseVisibility(object.visibility)
};
return newDiaryEntry;
}
throw new Error('Incorrect data: some fields are missing!');
};
export default toNewDiaryEntry;