🦔

【TypeScript】Expressの型定義から読み解くジェネリクス

に公開

はじめに

個人開発ではExpressを採用することが多く、以下のような簡易サーバーをまずは立てます。

簡易サーバー
import express, { Request, Response } from 'express';

const app = express();
const port = 3000;

app.use(express.json())

app.get('/', (req: Request, res: Response) => {
    res.status(200).json({result: "success"});
});

app.listen(port, () => {
    console.log(`Server is running at http://localhost:${port}`);
});

この時、RequestResponseという型をexpressからインポートしています。

なんとなくRequestの型ってどんな感じなのかな?と覗いたらゴリゴリにジェネリクスが使われてました。
(よく分からずPCを閉じました😌)

Express.Requestの型
interface Request<
    P = core.ParamsDictionary,
    ResBody = any,
    ReqBody = any,
    ReqQuery = core.Query,
    Locals extends Record<string, any> = Record<string, any>,
> extends core.Request<P, ResBody, ReqBody, ReqQuery, Locals> {}

調べてみたところ、ReactやZodなど様々なパッケージで汎用性を持たせるためにジェネリクスを使用していたので、改めてジェネリクスについて学び直すことにしました。

この記事で学べること

  • ジェネリクスの基本(型制約、デフォルト型引数)
  • Express.Result型の解説
  • ジェネリクスの実践例

ジェネリクスとは

ジェネリクスとは、型をパラメータ化して、再利用可能にする機能です。
1つの実装で複数の型に対応でき、anyの使用を避けて型安全性と柔軟性を両立させることができます。

ジェネリクスを使うメリット

ジェネリクスを使用しない場合、以下のようにString型の関数とNumber型の関数をそれぞれ作成する必要があリます。

ジェネリクスを使わない場合❌
// number型用の関数
const wrapNumberInArray = (value: number): number[] => {
    return [value]
}
// string型用の関数
const wrapStringInArray = (value: string): string[] => {
    return [value]
}
wrapNumberInArray(42)        // number[]
wrapStringInArray('hello')   // string[]

ジェネリクスを使うことで、型をパラメータ化して一つの関数にまとめることができます🎉

ジェネリクスを使う場合✅
// 汎用的な関数
const wrapInArray = <T>(value: T):T[] => {
    return [value]
}

wrapInArray(42)        // number[]
wrapInArray('hello')   // string[]

ジェネリクスの基本

ジェネリクスを使いこなすためには型制約デフォルト型引数という2つの機能の理解が必須です。

型制約

型制約とは、extendsを使用して、型に制約をつける機能です。
型パラメータが満たすべき条件を指定できます。

型制約
// KはTのキーであるという制約を指定
const getProperty = <T, K extends keyof T>(obj:T, key: K):T[K] => {
    return obj[key];
}

const user = {name: 'John', age: 30}
console.log(getProperty(user, 'name')) // John

// ❌ extendsの制約により、userに存在しないプロパティはエラーになる
// 型 '"not-property"' の引数を型 '"name" | "age"' のパラメーターに割り当てることはできません。ts(2345)
console.log(getProperty(user, 'not-property'))

複数の制約

&で複数の型を組み合わせて制約を追加することができます。

複数の制約
type Id = {id: number}
type Name = {name: string}

const findById = <T extends Id & Name>(items: T[], id: number): T | undefined => {
    return items.find(item => item.id === id)
}

// ✅ // {id: 1, name: 'John'}
console.log(findById([{id: 1, name: 'John'}, {id: 2, name: 'Jane'}], 1)) 

// ❌ extendsの制約によりnameがないのでエラーになる
// 型 '{ id: number; }' を型 'Id & Name' に割り当てることはできません。
//  プロパティ 'name' は型 '{ id: number; }' にありませんが、型 'Name' では必須です。ts(2322)
console.log(findById([{id: 1}], 1))

デフォルト型引数

デフォルト型引数とは、型を省略したときに使われる型です。
unknwonをデフォルト型引数に指定すると安全です。

デフォルト型引数
type User = { id: number, name: string }
type ApiResponse<T = unknown> = {
    data: T
    status: number
}

// dataはunknwon型として扱われる。
const response1: ApiResponse = {
    data: {},
    status: 200
}

// dataはUser型として扱われる。
const response2: ApiResponse<User> = {
    data: { id: 2, name: 'Alice' }, 
    status: 200
}

Express.Request型の解説

ジェネリクスの基本を理解できたところで、Express.Requestの型を再度見てみます。

Express.Requestの型
interface Request<
    P = ParamsDictionary,           // URLパラメータ
    ResBody = any,                  // レスポンスボディ
    ReqBody = any,                  // リクエストボディ
    ReqQuery = ParsedQs,            // クエリパラメータ
    Locals extends Record<string, any> = Record<string, any>  // res.locals(制約付き)
> extends core.Request<P, ResBody, ReqBody, ReqQuery, Locals> {}

少し複雑に見えるかもですが、
ジェネリクスのextendsと、interfaceのextendsが使われているので注意してください。

interfaceのextendsはinterfaceを継承する際に使用します。

interfaceの継承の例
// interface - extends で継承
interface Animal {
    name: string
}
interface Dog extends Animal {
    bark(): void
}

Requestcore.Requestの型を継承している外部公開用の型であることがわかります。

Express.Requestの使用例

以下のケースをExpress.Requestを使って記述します。

GET: /users:/id はURLパラメータでuserIdを受け取るAPIです。userId'user-'のプレフィックを持つ文字列であり、itemIdなど他のIDと混合しないようにUserId型を使用します。

型パラメータを指定しない場合、ParamsDictionaryがデフォルト型引数に設定されているため、req.params.idstring型を返します

型パラメータを指定しない場合
app.get('/users/:id', (req, res) => {
    const userId = req.params.id    //  string 型
    res.json(
        {
            id: Number(userId), 
            name: 'Alice'
        }
    )
})

型パラメータにUserId型を指定することで、req.params.idUserId型を渡すことができます。
また、ジェネリクスの第2引数にUserResponse型を指定することで、GET: /users/:id独自のレスポンスの型としてresを扱えるようになります。

型パラメータを指定する場合
type UserId = `user-${string}`

type UserResponse = {
    id: number
    name: string
}

app.get<{ id: UserId }, UserResponse>('/users/:id', (req, res) => {
    const userId = req.params.id    // ✅ UserId 型
    res.json({id: Number(userId), name: 'Alice' })
})

このようにExpressではジェネリクスを活用することで、APIごとに型安全にリクエスト・レスポンスをハンドリングできます。

ジェネリクスの実践例

自分が個人開発で使っているジェネリクスについても紹介します。気になる方は読んでみてください!

Result型

Result型

Result型とは、成功事の値とエラーを型安全に扱うためのパターンです。

T(成功時の値)とE(エラー)を型パラメータに持つことで、1つのResult型 で様々なAPI・関数に再利用できます。

エラーをunknown型ではなく具体的な型(ApiErrorなど)で扱えるため、型安全なエラーハンドリングが実現できます。

result型のジェネリクス
// Result型の定義
type Success<T> = { success: true, value: T }
type Failure<E> = { success: false, error: E}
type Result<T, E = Error> = Success<T> | Failure<E>

// 使用例
const getUserById = async (id: number): Promise<Result<User, ApiError>> => {
    try {
        const response = await fetch(`/api/users/${id}`)
        if (!response.ok) {
            const error: ApiError = await response.json()
            return { success: false, error}
        }
        const user = await response.json()
        return { success: true, value: user}
    } catch {
        return { success: false, error: {code: 'NETWORK_ERROR'}}
    }
}

// 呼び出し側
const result = await getUserById(1)
if (result.success) {
    console.log(result.value)
} else {
    console.error(result.error)
}

Repositoryパターン

Repositoryパターン

Repositoryパターンとは、データアクセス層を抽象化することで、DB・ORMへの依存を弱めるデザインパータンです。

問題:ジェネリクスを使わない場合、各エンティティごとに重複したCRUD操作を定義する必要があります。

ジェネリクスを使わないRepositoryパターン
// ❌ エンティティごとにCRUDが重複している
class UserRepository {
    private users = new Map<number, User>()

    save(user: User) {...}
    findById(id: number) {...}
    findAll() {..}
    delete(id: number) {...}
}

class ProductRepostiroy {
    private products = new Map<number, Product>()

    save(product: Product) {...}
    findById(id: number) {...}
    findAll() {...}
    delete(id: number) {...}
}

解決策:ジェネリクスを使うことでRepositoryクラスを共通化できます。

ジェネリクスを使ったRepositoryパターン
interface Entity {
    id: number
}

// ✅ 共通のRepositoryクラス
class Repository<T extends Entity> {
    private items: Map<number, T> = new Map()

    save(item: T): void {
        this.item.set(item.id, item)
    }
    findById(id: number): T | undefind {
        return this.item.get(id)
    }
    findAll(): T[] {
        return Array.from(this.item.values())
    }
    delete(id: number): boolean {
        return this.items.delete(id)
    }
}

// エンティティの定義
interface User Extends Entity {
    emai: string
    name: string
}
interface Product extends Entity {
    name: string
    price: number
}

// 使用例
const userRepo = new Repository<User>()
userRepo.save({ email: "alice@example.com", id: 1, name: "Alice" })

const productRepo = new Repository<Product>()
productRepo.save({id: 1, name: "Laptop", price: 1000})

const user = userRepo.findById(1)
const product = productRepo.findById(1)

おわりに

ジェネリクスを使うことで、コードの重複を削減でき、型安全性と柔軟性を両立させることができます。

似たようなコード多いな、型ごとに処理を分けているなと思ったらジェネリクスで共通化できないか検討してみてください!

Discussion