Types (ชนิดข้อมูล)
ไกด์นี้เกี่ยวข้องกับ Data types จากภาษาที่มี Type อย่าง TypeScript และอธิบายว่ามันอยู่ตรงไหนใน FSD
คำถามของคุณไม่อยู่ในไกด์นี้เหรอ? โพสต์คำถามของคุณโดยการให้ Feedback ในบทความนี้สิ (ปุ่มสีฟ้าทางขวา) แล้วเราจะพิจารณาขยายไกด์นี้ให้!
Utility types
Utility types คือ Types ที่ไม่ได้มีความหมายในตัวมันเองและมักจะถูกใช้ร่วมกับ Types อื่นๆ ตัวอย่างเช่น:
type ArrayValues<T extends readonly unknown[]> = T[number];
Source: https://github.com/sindresorhus/type-fest/blob/main/source/array-values.d.ts
เพื่อให้ Utility types ใช้ได้ทั่วทั้งโปรเจกต์ของคุณ ให้ติดตั้ง Library อย่าง type-fest หรือสร้าง Library ของคุณเองใน shared/lib อย่าลืมระบุให้ชัดเจนว่า Types ใหม่แบบไหน ควร ถูกเพิ่มเข้ามาใน Library นี้ และ Types แบบไหน ไม่ควร ตัวอย่างเช่น ตั้งชื่อว่า shared/lib/utility-types และเพิ่ม README ไว้ข้างในที่อธิบายว่าอะไรคือ Utility type ในทีมของคุณ
อย่าประเมินความสามารถในการ Reuse ของ Utility type สูงเกินไป แค่เพราะมัน สามารถ Reuse ได้ ไม่ได้แปลว่ามัน จะ ถูก Reuse และด้วยเหตุนี้ ไม่ใช่ทุก Utility type จำเป็นต้องอยู่ใน Shared บาง Utility types อยู่ติดกับที่ที่มันถูกใช้ก็โอเคแล้ว:
- 📂 pages
- 📂 home
- 📂 api
- 📄 ArrayValues.ts (utility type)
- 📄 getMemoryUsageMetrics.ts (โค้ดที่ใช้ utility type)
- 📂 api
- 📂 home
ต้านทานความอยากที่จะสร้างโฟลเดอร์ shared/types หรือเพิ่ม types segment ใน Slices ของคุณ หมวดหมู่ "types" ก็เหมือนหมวดหมู่ "components" หรือ "hooks" ตรงที่มันอธิบายว่า ข้างในคืออะไร ไม่ใช่ มีไว้ทำไม Segments ควรอธิบายจุดประสงค์ของโค้ด ไม่ใช่แก่นแท้ของมัน
Business entities และการอ้างอิงข้ามกัน
หนึ่งใน Types ที่สำคัญที่สุดในแอปคือ Types ของ Business entities หรือสิ่งของในโลกจริงที่แอปของคุณทำงานด้วย เช่น ในแอป Music streaming คุณอาจมี Business entities Song, Album, ฯลฯ
Business entities มักจะมาจาก Backend ขั้นตอนแรกคือการ Type ข้อมูลตอบกลับจาก Backend มันสะดวกที่จะมีฟังก์ชันสำหรับ Request ไปยังทุก Endpoint และ Type ผลลัพธ์ของฟังก์ชันนี้ เพื่อความปลอดภัยของ Type เป็นพิเศษ คุณอาจต้องการรัน Response ผ่าน Schema validation library อย่าง Zod
ตัวอย่างเช่น ถ้าคุณเก็บ Requests ทั้งหมดไว้ใน Shared คุณอาจจะทำแบบนี้:
import type { Artist } from "./artists";
interface Song {
id: number;
title: string;
artists: Array<Artist>;
}
export function listSongs() {
return fetch('/api/songs').then((res) => res.json() as Promise<Array<Song>>);
}
คุณอาจสังเกตเห็นว่า Song type อ้างอิง Entity อื่นคือ Artist นี่เป็นข้อดีของการเก็บ Requests ไว้ใน Shared — Types ในโลกจริงมักจะเกี่ยวพันกัน ถ้าเราเก็บฟังก์ชันนี้ไว้ใน entities/song/api เราจะไม่สามารถ Import Artist จาก entities/artist มาดื้อๆ ได้ เพราะ FSD จำกัดการ Cross-import ระหว่าง Slices ด้วย กฎการ Import ของ Layers:
Module ใน Slice หนึ่ง สามารถ Import Slice อื่นๆ ได้เฉพาะเมื่อ Slice เหล่านั้นอยู่ใน Layer ที่ต่ำกว่าอย่างเคร่งครัด
มีสองวิธีในการจัดการปัญหานี้:
-
Parametrize Types ของคุณ
คุณสามารถทำให้ Types ของคุณรับ Type arguments เป็นช่องว่าง (Slots) สำหรับเชื่อมต่อกับ Entities อื่น และยังกำหนดข้อจำกัด (Constraints) บน Slots เหล่านั้นได้ด้วย ตัวอย่างเช่น:entities/song/model/song.tsinterface Song<ArtistType extends { id: string }> {
id: number;
title: string;
artists: Array<ArtistType>;
}วิธีนี้เวิร์คกับบาง Types ดีกว่าบาง Types อย่าง
Cart = { items: Array<Product> }ที่เรียบง่าย สามารถทำให้ทำงานกับ Product type ไหนก็ได้ง่ายๆ แต่ Types ที่เชื่อมโยงกันมากกว่านี้ เช่นCountryและCityอาจจะไม่แยกออกจากกันง่ายขนาดนั้น -
Cross-import (แต่ทำให้ถูกวิธี)
ในการทำ Cross-imports ระหว่าง Entities ใน FSD คุณสามารถใช้ Public API พิเศษเฉพาะสำหรับแต่ละ Slice ที่จะทำการ Cross-import ตัวอย่างเช่น ถ้าเรามี Entitiessong,artist, และplaylistและสองอันหลังต้องอ้างอิงsongเราสามารถสร้าง Public APIs พิเศษสองอันสำหรับทั้งคู่ในsongentity ด้วยเครื่องหมาย@x:- 📂 entities
- 📂 song
- 📂 @x
- 📄 artist.ts (Public API สำหรับ
artistentity เพื่อ Import) - 📄 playlist.ts (Public API สำหรับ
playlistentity เพื่อ Import)
- 📄 artist.ts (Public API สำหรับ
- 📄 index.ts (Public API ปกติ)
- 📂 @x
- 📂 song
เนื้อหาของไฟล์
📄 entities/song/@x/artist.tsจะคล้ายกับ📄 entities/song/index.ts:entities/song/@x/artist.tsexport type { Song } from "../model/song.ts";จากนั้น
📄 entities/artist/model/artist.tsสามารถ ImportSongได้แบบนี้:entities/artist/model/artist.tsimport type { Song } from "entities/song/@x/artist";
export interface Artist {
name: string;
songs: Array<Song>;
}โดยการสร้างการเชื่อมต่อที่ชัดเจนระหว่าง Entities เราจะควบคุม Inter-dependencies ได้ และยังคงรักษาระดับของการแยก Domain ได้ดีพอสมควร
- 📂 entities
Data transfer objects และ Mappers
Data transfer objects หรือ DTOs เป็นคำที่อธิบายรูปร่างของข้อมูลที่มาจาก Backend บางครั้ง DTO ก็ใช้ได้เลย แต่บางครั้งมันก็ไม่สะดวกสำหรับ Frontend นั่นคือที่มาของ Mappers — พวกมันแปลง DTO ให้เป็นรูปร่างที่สะดวกขึ้น
วาง DTOs ไว้ที่ไหน
ถ้าคุณมี Backend types ใน Package แยกต่างหาก (เช่น ถ้าคุณแชร์โค้ดระหว่าง Frontend และ Backend) ก็แค่ Import DTOs จากที่นั่นก็จบ! ถ้าคุณไม่แชร์โค้ดระหว่าง Backend และ Frontend คุณต้องเก็บ DTOs ไว้สักที่ใน Frontend codebase และเราจะสำรวจกรณีนี้กันด้านล่าง
ถ้าคุณมี Request functions ใน shared/api นั่นคือที่ที่ DTOs ควรอยู่ ติดกับฟังก์ชันที่ใช้มันเลย:
import type { ArtistDTO } from "./artists";
interface SongDTO {
id: number;
title: string;
artist_ids: Array<ArtistDTO["id"]>;
}
export function listSongs() {
return fetch('/api/songs').then((res) => res.json() as Promise<Array<SongDTO>>);
}
ตามที่กล่าวในส่วนที่แล้ว การเก็บ Requests และ DTOs ใน Shared มีข้อดีคือสามารถอ้างอิง DTOs อื่นๆ ได้
วาง Mappers ไว้ที่ไหน
Mappers คือฟังก์ชันที่รับ DTO ไปแปลงร่าง และด้วยเหตุนี้ มันควรจะอยู่ใกล้กับนิยามของ DTO ในทางปฏิบัติหมายความว่าถ้า Requests และ DTOs ของคุณนิยามใน shared/api Mappers ก็ควรอยู่ที่นั่นด้วย:
import type { ArtistDTO } from "./artists";
interface SongDTO {
id: number;
title: string;
disc_no: number;
artist_ids: Array<ArtistDTO["id"]>;
}
interface Song {
id: string;
title: string;
/** ชื่อเต็มของเพลง รวมเลขแผ่นด้วย */
fullTitle: string;
artistIds: Array<string>;
}
function adaptSongDTO(dto: SongDTO): Song {
return {
id: String(dto.id),
title: dto.title,
fullTitle: `${dto.disc_no} / ${dto.title}`,
artistIds: dto.artist_ids.map(String),
};
}
export function listSongs() {
return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO));
}
ถ้า Requests และ Stores ของคุณนิยามใน Entity slices โค้ดพวกนี้ก็จะไปอยู่ที่นั่น โดยต้องคำนึงถึงข้อจำกัดของการ Cross-imports ระหว่าง Slices:
import type { ArtistDTO } from "entities/artist/@x/song";
export interface SongDTO {
id: number;
title: string;
disc_no: number;
artist_ids: Array<ArtistDTO["id"]>;
}
import type { SongDTO } from "./dto";
export interface Song {
id: string;
title: string;
/** ชื่อเต็มของเพลง รวมเลขแผ่นด้วย */
fullTitle: string;
artistIds: Array<string>;
}
export function adaptSongDTO(dto: SongDTO): Song {
return {
id: String(dto.id),
title: dto.title,
fullTitle: `${dto.disc_no} / ${dto.title}`,
artistIds: dto.artist_ids.map(String),
};
}
import { adaptSongDTO } from "./mapper";
export function listSongs() {
return fetch('/api/songs').then(async (res) => (await res.json()).map(adaptSongDTO));
}
import { createSlice, createEntityAdapter } from "@reduxjs/toolkit";
import { listSongs } from "../api/listSongs";
export const fetchSongs = createAsyncThunk('songs/fetchSongs', listSongs);
const songAdapter = createEntityAdapter();
const songsSlice = createSlice({
name: "songs",
initialState: songAdapter.getInitialState(),
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchSongs.fulfilled, (state, action) => {
songAdapter.upsertMany(state, action.payload);
})
},
});
จัดการ Nested DTOs ยังไงดี
ส่วนที่มีปัญหาที่สุดคือเมื่อ Response จาก Backend มี Entities หลายตัว ตัวอย่างเช่น ถ้า Song ไม่ได้มีแค่ ID ของ Authors แต่มี Object ของ Author ทั้งก้อนเลย ในกรณีนี้ เป็นไปไม่ได้เลยที่ Entities จะไม่รู้จักกัน (เว้นแต่เราจะทิ้งข้อมูลหรือไปคุยกับทีม Backend อย่างจริงจัง) แทนที่จะหาวิธีเชื่อมต่อ Slices แบบอ้อมๆ (เช่น Middleware กลางที่จะ Dispatch actions ไปยัง Slices อื่น) ให้เลือกใช้ Explicit cross-imports ด้วยเครื่องหมาย @x ดีกว่า นี่คือวิธี Implement ด้วย Redux Toolkit:
import {
createSlice,
createEntityAdapter,
createAsyncThunk,
createSelector,
} from '@reduxjs/toolkit'
import { normalize, schema } from 'normalizr'
import { getSong } from "../api/getSong";
// Define normalizr entity schemas
export const artistEntity = new schema.Entity('artists')
export const songEntity = new schema.Entity('songs', {
artists: [artistEntity],
})
const songAdapter = createEntityAdapter()
export const fetchSong = createAsyncThunk(
'songs/fetchSong',
async (id: string) => {
const data = await getSong(id)
// Normalize ข้อมูลเพื่อให้ Reducers สามารถ Load payload ที่คาดเดาได้ เช่น:
// `action.payload = { songs: {}, artists: {} }`
const normalized = normalize(data, songEntity)
return normalized.entities
}
)
export const slice = createSlice({
name: 'songs',
initialState: songAdapter.getInitialState(),
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchSong.fulfilled, (state, action) => {
songAdapter.upsertMany(state, action.payload.songs)
})
},
})
const reducer = slice.reducer
export default reducer
export { fetchSong } from "../model/songs";
import { createSlice, createEntityAdapter } from '@reduxjs/toolkit'
import { fetchSong } from 'entities/song/@x/artist'
const artistAdapter = createEntityAdapter()
export const slice = createSlice({
name: 'users',
initialState: artistAdapter.getInitialState(),
reducers: {},
extraReducers: (builder) => {
builder.addCase(fetchSong.fulfilled, (state, action) => {
// และจัดการผลการ Fetch เดียวกันโดยใส่ Artists ที่นี่
artistAdapter.upsertMany(state, action.payload.artists)
})
},
})
const reducer = slice.reducer
export default reducer
วิธีนี้ลดประโยชน์ของ Slice isolation ลงเล็กน้อย แต่มันสะท้อนความเชื่อมโยงระหว่างสอง Entities นี้ที่เราควบคุมไม่ได้อย่างถูกต้อง ถ้า Entities พวกนี้ต้องถูก Refactor มันก็ต้องถูก Refactor ไปพร้อมๆ กัน
Global types และ Redux
Global types คือ Types ที่จะถูกใช้ทั่วทั้งแอปพลิเคชัน มี 2 แบบ โดยแบ่งตามสิ่งที่มันต้องรู้:
- Generic types ที่ไม่มีรายละเอียดเฉพาะเจาะจงของแอปพลิเคชัน
- Types ที่ต้องรู้เกี่ยวกับแอปพลิเคชันทั้งหมด
กรณีแรกแก้ง่าย — วาง Types ของคุณใน Shared ใน Segment ที่เหมาะสม ตัวอย่างเช่น ถ้าคุณมี Interface สำหรับตัวแปร Global สำหรับ Analytics คุณสามารถวางไว้ที่ shared/analytics
หลีกเลี่ยงการสร้างโฟลเดอร์ shared/types มันรวมสิ่งที่ไม่เกี่ยวข้องกันโดยอิงแค่คุณสมบัติของการ "เป็น Type" ซึ่งคุณสมบัตินั้นมักจะไม่มีประโยชน์เวลาค้นหาโค้ดในโปรเจกต์
กรณีที่สองพบบ่อยในโปรเจกต์ที่ใช้ Redux แบบไม่ใช้ RTK Store type สุดท้ายของคุณจะพร้อมก็ต่อเมื่อเอา Reducers ทั้งหมดมารวมกัน แต่ Store type นี้ต้องพร้อมใช้งานสำหรับ Selectors ที่คุณใช้ทั่วทั้งแอป ตัวอย่างเช่น นี่คือนิยาม Store ทั่วไปของคุณ:
import { combineReducers, rootReducer } from "redux";
import { songReducer } from "entities/song";
import { artistReducer } from "entities/artist";
const rootReducer = combineReducers(songReducer, artistReducer);
const store = createStore(rootReducer);
type RootState = ReturnType<typeof rootReducer>;
type AppDispatch = typeof store.dispatch;
มันคงจะดีถ้ามี Redux hooks useAppDispatch และ useAppSelector ที่มี Type กำกับ อยู่ใน shared/store แต่พวกมันไม่สามารถ Import RootState และ AppDispatch จาก App layer ได้เนื่องจาก กฎการ Import ของ Layers:
Module ใน Slice หนึ่ง สามารถ Import Slice อื่นๆ ได้เฉพาะเมื่อ Slice เหล่านั้นอยู่ใน Layer ที่ต่ำกว่าอย่างเคร่งครัด
วิธีที่แนะนำในกรณีนี้คือสร้าง Explicit dependency ระหว่าง Layer Shared และ App สอง Types นี้ RootState และ AppDispatch ไม่น่าจะเปลี่ยนบ่อย และ Redux developers จะคุ้นเคยกับมัน ดังนั้นเราไม่ต้องกังวลกับมันมากนัก
ใน TypeScript คุณทำได้โดยประกาศ Types เป็น Global แบบนี้:
/* same content as in the code block before… */
declare type RootState = ReturnType<typeof rootReducer>;
declare type AppDispatch = typeof store.dispatch;
import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux";
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Enums
กฎทั่วไปของ Enums คือควรนิยาม ใกล้กับตำแหน่งที่ใช้งานให้มากที่สุด เมื่อ Enum แทนค่าที่เฉพาะเจาะจงกับ Feature เดียว มันควรถูกนิยามใน Feature นั้น
การเลือก Segment ควรกำหนดโดยตำแหน่งที่ใช้งานเช่นกัน ถ้า Enum ของคุณเก็บ เช่น ตำแหน่งของ Toast บนหน้าจอ มันควรอยู่ใน ui segment ถ้ามันแทนสถานะการโหลดของ Backend operation มันควรอยู่ใน api segment
บาง Enums เป็น Common จริงๆ ทั่วทั้งโปรเจกต์ เช่น General backend response statuses หรือ Design system tokens ในกรณีนี้ คุณสามารถวางพวกมันไว้ใน Shared และเลือก Segment ตามสิ่งที่ Enum นั้นเป็นตัวแทน (api สำหรับ Response statuses, ui สำหรับ Design tokens, ฯลฯ)
Type validation schemas และ Zod
ถ้าคุณต้องการ Validate ว่าข้อมูลของคุณตรงตามรูปแบบหรือข้อจำกัดบางอย่าง คุณสามารถนิยาม Validation schema ใน TypeScript Library ยอดนิยมสำหรับงานนี้คือ Zod Validation schemas ก็ควรอยู่ร่วมกับโค้ดที่ใช้พวกมันให้มากที่สุดเท่าที่จะทำได้
Validation schemas คล้ายกับ Mappers (ตามที่คุยไปในหัวข้อ Data transfer objects และ Mappers) ในแง่ที่ว่ามันรับ Data transfer object และ Parse มัน ถ้า Parse ไม่ผ่านก็ Error
หนึ่งในกรณีที่พบบ่อยที่สุดของการ Validation คือข้อมูลที่มาจาก Backend โดยทั่วไปคุณต้องการให้ Request ล้มเหลวถ้าข้อมูลไม่ตรงกับ Schema ดังนั้นจึงสมเหตุสมผลที่จะวาง Schema ไว้ที่เดียวกับ Request function ซึ่งปกติคือ api segment
ถ้าข้อมูลของคุณมาจาก User input เช่น Form การ Validation ควรเกิดขึ้นตอนที่ข้อมูลกำลังถูกป้อน คุณสามารถวาง Schema ของคุณใน ui segment ติดกับ Form component หรือใน model segment ถ้า ui segment เริ่มรกเกินไป
Typings ของ Component props และ Context
โดยทั่วไป ดีที่สุดคือเก็บ Interface ของ Props หรือ Context ไว้ในไฟล์เดียวกับ Component หรือ Context ที่ใช้พวกมัน ถ้าคุณใช้ Framework ที่มี Single-file components เช่น Vue หรือ Svelte และคุณนิยาม Props interface ในไฟล์เดียวกันไม่ได้ หรือคุณต้องการแชร์ Interface นั้นระหว่างหลาย Components ให้สร้างไฟล์แยกในโฟลเดอร์เดียวกัน ปกติคือ ui segment
นี่คือตัวอย่างกับ JSX (React หรือ Solid):
interface RecentActionsProps {
actions: Array<{ id: string; text: string }>;
}
export function RecentActions({ actions }: RecentActionsProps) {
/* … */
}
และนี่คือตัวอย่างที่ Interface เก็บในไฟล์แยกสำหรับ Vue:
export interface RecentActionsProps {
actions: Array<{ id: string; text: string }>;
}
<script setup lang="ts">
import type { RecentActionsProps } from "./RecentActionsProps";
const props = defineProps<RecentActionsProps>();
</script>
Ambient declaration files (*.d.ts)
บาง Packages เช่น Vite หรือ ts-reset ต้องการ Ambient declaration files เพื่อให้ทำงานได้ทั่วทั้งแอป ปกติแล้วไฟล์พวกนี้ไม่ได้ใหญ่หรือซับซ้อน ดังนั้นมักจะไม่ต้องการการวางโครงสร้างอะไรมาก โยนไว้ใน src/ ก็ได้ เพื่อให้ src เป็นระเบียบขึ้น คุณสามารถเก็บพวกมันไว้ที่ App layer ใน app/ambient/
Packages อื่นๆ แค่ไม่มี Typings และคุณอาจต้องการประกาศให้มันเป็น Untyped หรือเขียน Typings ให้มันเอง ที่ที่ดีสำหรับ Typings พวกนั้นคือ shared/lib ในโฟลเดอร์เช่น shared/lib/untyped-packages สร้างไฟล์ %LIBRARY_NAME%.d.ts ที่นั่นและประกาศ Types ที่คุณต้องการ:
// Library นี้ไม่มี Typings และเราขี้เกียจเขียนเอง
declare module "use-react-screenshot";
การ Generate Types อัตโนมัติ
เป็นเรื่องปกติที่จะ Generate types จากแหล่งภายนอก เช่น Generate backend types จาก OpenAPI schema ในกรณีนี้ ให้สร้างที่เฉพาะใน Codebase ของคุณสำหรับ Types เหล่านี้ เช่น shared/api/openapi ในอุดมคติ คุณควรใส่ README ในโฟลเดอร์นั้นด้วยเพื่ออธิบายว่าไฟล์พวกนี้คืออะไร วิธี Regenerate ยังไง ฯลฯ