Description
TypeScript
Generic Types & Conditional Types
useFetch hook을 정의하는 데 사용했습니다.
useFetch Full Definition
//#region Type Definition
type DataType<T, S> = S extends undefined ? T : S;
type ArgType<T, S> = {
urlSegment: string;
modifier?: (arg: T) => DataType<T, S>;
callbacks?: ((arg: DataType<T, S>) => void | Promise<void>)[]; // What if S is provided but callbacks need T?
additionalDeps?: DependencyList;
additionalCondition?: boolean;
};
type CustomReturnType<T, S> = [
DataType<T, S> | null,
Dispatch<SetStateAction<DataType<T, S> | null>>
];
//#endregion
/**
* useFetch<T, S = undefined>에 대해, S가 어떻게 쓰이는지?
*
* useFetch에 의해 fetch되는 데이터를 약간 수정해서 이용해야 하는 경우가 존재한다.
* 이때 그 수정된 데이터가 기존의 타입 T와 다른 경우, S를 이용해 그 수정된 데이터의 타입을 명시한다.
* e.g) the useFetch call in the client/src/Pages/Statistics/Statistics.tsx
*
* callbacks: fetch된 데이터 혹은 추후 수정된 데이터를 argument로 하여 call되는 callback함수들의 array.
*
* return type은 S가 정의된 경우 S 그렇지 않으면 T이다.
* callbacks은 return type관여하지 않는다.
*
*/
export function useFetch<T, S = undefined>({
urlSegment,
modifier,
callbacks,
additionalDeps,
additionalCondition,
}: ArgType<T, S>): CustomReturnType<T, S> {
const [data, setData] = useState<DataType<T, S> | null>(null);
const { user } = useAuthContext()!;
let moreDeps: DependencyList = additionalDeps ?? [];
useEffect(() => {
if (isUserSignedIn() && isAdditionalConditionSatisfiedWhenProvided()) {
getData();
}
/**
* Purpose: to get data from either remote server or cache storage.
*/
async function getData() {
let resData = await caches.match(BASE_URL + urlSegment);
if (resData) {
let data: DataType<T, S> =
modifier !== undefined
? modifier((await resData.json()) as T)
: await resData.json();
console.log("data from cache", data);
if (callbacks !== undefined) {
for (const fn of callbacks) {
await fn(data);
}
}
setData(data);
} else {
let res = await fetchDataFromServer();
if (res !== undefined) {
let resFetched = new Response(JSON.stringify(res.data));
let cache = DynamicCache || (await openCache(CacheName));
await cache.put(BASE_URL + urlSegment, resFetched);
}
}
}
// This is going to be used in the getData() function.
async function fetchDataFromServer() {
try {
const response = await axiosInstance.get(urlSegment);
let data =
modifier !== undefined ? modifier(response.data as T) : response.data;
if (callbacks !== undefined) {
for (const fn of callbacks) {
await fn(data);
}
}
setData(data);
return response;
} catch (error) {
console.warn(error);
}
}
function isUserSignedIn() {
return user !== null;
}
function isAdditionalConditionSatisfiedWhenProvided() {
return additionalCondition ?? true;
}
}, [user, ...moreDeps]);
return [data, setData];
}
사용한 방식
useFetch는 다음과 같이 T와 S, 두 가지 Generic 타입을 받는 함수입니다.
function useFetch<T, S = undefined>({
urlSegment,
modifier,
callbacks,
additionalDeps,
additionalCondition,
}: ArgType<T, S>): CustomReturnType<T, S>
- 이 함수에서
ArgType<T, S>와CustomReturnType<T, S>는 T와 S에 영향을 받는데, - 두 타입 모두 결과적으로는
DataType<T, S>에 의해서만 영향을 받게 됩니다. DataType<T, S>은 useFetch가 정의된S와 함께 호출되면S를, 그렇지 않으면T값을 갖는 Conditional Type입니다.
타입 정의
type ArgType<T, S> = {
urlSegment: string;
modifier?: (arg: T) => DataType<T, S>;
callbacks?: ((arg: DataType<T, S>) => void | Promise<void>)[]; // What if S is provided but callbacks need T?
additionalDeps?: DependencyList;
additionalCondition?: boolean;
};
type CustomReturnType<T, S> = [
DataType<T, S> | null,
Dispatch<SetStateAction<DataType<T, S> | null>>
];
type DataType<T, S> = S extends undefined ? T : S;
사용한 이유
서버에서 직접 받아오는 데이터의 type은 T인데, 때에 따라서 이 값을 직접 return하지 않고 중간에
modify해야하는 경우가 있습니다. modify한다고 해서 항상 그 결과 값의 타입이 변하는 것은 아니지만,
제가 사용하는 use case에서는 그런 경우가 존재했고 그래서 이것이 일반적인 경우라고 가정하여, S라는
두 번째 타입을 정의했습니다. modify했을 때 결과 값의 달라지는 타입이 S이고 선택적으로 명시해주면 됩니다.
참고로 callbacks는 data를 사용하는 처지만을 갖고, 수정하는 권한은 없다고 정의했습니다.
예시
/statistics에서 render되는 Statistics component는 pomodoro data를 서버로부터 가져옵니다.
const [statData, setStatData] = useFetch<
PomodoroSessionDocument[],
DayStat[]
>({
urlSegment: RESOURCE.POMODOROS,
modifier: calculateDailyPomodoroDuration,
callbacks: [calculateWeeklyTrend, calculateOverview, calculateThisWeekData],
});
타입 정의
// T
export type PomodoroSessionDocument = {
userEmail: string;
duration: number;
startTime: number;
date: string;
category?: CategoryForStat;
};
// S
export type DayStat = TimeRelated & DurationRelated;
type TimeRelated = {
date: string;
timestamp: number;
dayOfWeek: string;
weekNumber: number;
};
type DurationRelated = {
total: number;
subtotalByCategory: CategorySubtotal;
withoutCategory: number;
};
Index Signature
사용 방식 및 이유
/statistics에서 통계 그래프를 그리기 위해서는 서버로부터 가져오는 PomodoroSessionDocument[]를 이용해 DayStat[]를 만들어야 합니다.
그리고 DayStat은 아래처럼 해당 날짜에 완료된 pomodoro session들의 Category별 통계를 가지고 있어야 합니다.
type DayStat = {
//
date: string;
timestamp: number;
dayOfWeek: string;
weekNumber: number;
//
total: number;
subtotalByCategory: CategorySubtotal; // <---------------
withoutCategory: number;
}
이때, CategorySubtotal의 형태를 정의해야 하는데, 사용자마다 각자 갖고 있는 카테고리의 이름이 다르므로,
다음처럼 array를 이용해서 정의해볼 수 있습니다.
array style definition
type CategorySubtotal = {
name: string;
_uuid: string;
duration: number;
isOnStat: boolean;
}[];
그런데 이 경우, 여러개의 pomodoro document에 대해 linear search를 해야하므로 비록 category들이 몇개 없을 것이기 때문에 효율에 있어서는 별 차이가 없지만 신경이 쓰였습니다.
하지만, 이 경우 소계를 구하려면 카테고리 이름을 기준으로 linear search를 수행해야 합니다. 따라서 카테고리 종류가 많아질수록 비효율이 발생할 수 있습니다. 이를 해결하기 위해, 직접 접근이 가능한 object structure로 타입을 정의했습니다. 또한, 어떤 카테고리 이름이든 문자열이라면 객체의 key로 사용할 수 있도록, index signature를 활용해 다음과 같이 타입을 정의했습니다.
interface definition
interface CategorySubtotal {
[name: string]: {
_uuid: string;
duration: number;
isOnStat: boolean;
};
}
관련 함수들
createBaseCategorySubtotal()- 사용자의 카테고리들을 이용해서 base category subtotal object를 만들어줍니다.
- 각 카테고리의 소계는 0으로 설정해 줍니다.
definition
function createBaseCategorySubtotal() {
const retVal = listOfCategoryDetails.reduce<CategorySubtotal>(
(previousValue, currentValue) => {
previousValue[currentValue.name] = {
_uuid: currentValue._uuid,
duration: 0,
isOnStat: currentValue.isOnStat,
};
return previousValue;
},
{}
);
return retVal;
}
// listOfCategoryDetail의 타입
type CategoryDetail = {
name: string;
color: string;
isOnStat: boolean;
_uuid: string;
isCurrent?: boolean;
};
calculateDailyPomodoroDuration()의 일부분- pomodoro document의 카테고리가 정의되어 있으면, 해당 카테고리에 접근해서 duration을 더해줍니다.
definition
function calculateDailyPomodoroDuration(
pomodoroDocs: PomodoroSessionDocument[]
): DayStat[] {
let days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// [{ date: '9/12/2022', total: 300 }, ... ]
let arrOfDurationByDate = pomodoroDocs
.sort(
(a: PomodoroSessionDocument, b: PomodoroSessionDocument) =>
a.startTime - b.startTime // in ascending order
)
.reduce<DayStat[]>((acc: DayStat[], curRec: PomodoroSessionDocument) => {
// 1. 첫번째 계산
if (acc.length === 0) {
const dayOfWeekIndex = new Date(curRec.date).getDay();
const categorySubtotal = createBaseCategorySubtotal(); //<--------------------
const timestamp = new Date(curRec.date).getTime();
let dailyPomos: DayStat = {
date: curRec.date,
timestamp,
dayOfWeek: days[dayOfWeekIndex],
weekNumber: getISOWeek(timestamp),
total: curRec.duration,
subtotalByCategory: categorySubtotal,
withoutCategory: 0,
};
if (curRec.category !== undefined) { //<--------------------
dailyPomos.subtotalByCategory[curRec.category.name].duration =
curRec.duration;
} else {
dailyPomos.withoutCategory += curRec.duration;
}
return [dailyPomos];
}
...