Skip to content

Commit

Permalink
refactor: use z.brand to generate branded types
Browse files Browse the repository at this point in the history
  • Loading branch information
Josh-Cena committed May 12, 2024
1 parent 52c2962 commit 3c0f0f1
Show file tree
Hide file tree
Showing 7 changed files with 96 additions and 79 deletions.
13 changes: 3 additions & 10 deletions frontend/src/components/Worksheet/AddFriendDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from 'react-select';
import { useUser } from '../../contexts/userContext';
import type { NetId } from '../../queries/graphql-types';
import { fetchAllNames } from '../../utilities/api';
import { fetchAllNames, type UserNames } from '../../utilities/api';
import { Popout } from '../Search/Popout';
import { PopoutSelect } from '../Search/PopoutSelect';

Expand All @@ -25,13 +25,6 @@ const FriendContext = createContext<{
removeFriend: (netId: NetId, isRequest: boolean) => Promise<void>;
} | null>(null);

type FriendNames = {
netId: NetId;
first: string | null;
last: string | null;
college: string | null;
}[];

interface OptionType {
value: NetId;
label: string;
Expand Down Expand Up @@ -158,15 +151,15 @@ function SingleValueComponent(props: SingleValueProps<OptionType, false>) {
function AddFriendDropdownDesktop() {
const { user } = useUser();
const { isFriend } = useContext(FriendContext)!;
const [allNames, setAllNames] = useState<FriendNames>([]);
const [allNames, setAllNames] = useState<UserNames>([]);
const [searchText, setSearchText] = useState('');
const [isLoading, setIsLoading] = useState(false);

useEffect(() => {
setIsLoading(true);
async function fetchNames() {
const data = await fetchAllNames();
if (data) setAllNames(data.names as FriendNames);
if (data) setAllNames(data.names);
setIsLoading(false);
}
void fetchNames();
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/contexts/ferryContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ import * as Sentry from '@sentry/react';
import AsyncLock from 'async-lock';
import { toast } from 'react-toastify';

import { useUser, type UserWorksheets } from './userContext';
import { useUser } from './userContext';
import type { WorksheetCourse } from './worksheetContext';
import seasonsData from '../generated/seasons.json';
import type { Crn, Season } from '../queries/graphql-types';
import { fetchCatalog, fetchEvals } from '../utilities/api';
import {
fetchCatalog,
fetchEvals,
type UserWorksheets,
} from '../utilities/api';
import type { Listing } from '../utilities/common';

export const seasons = seasonsData as Season[];
Expand Down
32 changes: 7 additions & 25 deletions frontend/src/contexts/userContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import React, {
useEffect,
} from 'react';
import { toast } from 'react-toastify';
import type { NetId, Season, Crn } from '../queries/graphql-types';
import type { NetId } from '../queries/graphql-types';
import {
fetchUserWorksheets,
fetchFriendWorksheets,
Expand All @@ -16,29 +16,11 @@ import {
requestAddFriend as baseRequestAddFriend,
removeFriend as baseRemoveFriend,
checkAuth,
type UserWorksheets,
type FriendRecord,
type FriendRequests,
} from '../utilities/api';

// Not using z.infer because we want narrower types
export type UserWorksheets = {
[season: Season]: {
[worksheetNumber: number]: {
crn: Crn;
color: string;
hidden: boolean;
}[];
};
};
export type FriendRecord = {
[netId: NetId]: {
name: string | null;
worksheets: UserWorksheets;
};
};
type FriendRequests = {
netId: NetId;
name: string | null;
}[];

type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';

type Store = {
Expand Down Expand Up @@ -85,7 +67,7 @@ export function UserProvider({
const userRefresh = useCallback(async (): Promise<void> => {
const data = await fetchUserWorksheets();
if (data) {
setNetId(data.netId satisfies string as NetId);
setNetId(data.netId);
setHasEvals(data.evaluationsEnabled ?? undefined);
setYear(data.year ?? undefined);
setSchool(data.school ?? undefined);
Expand All @@ -101,13 +83,13 @@ export function UserProvider({

const friendRefresh = useCallback(async (): Promise<void> => {
const data = await fetchFriendWorksheets();
if (data) setFriends(data.friends as FriendRecord);
if (data) setFriends(data.friends);
else setFriends(undefined);
}, [setFriends]);

const friendReqRefresh = useCallback(async (): Promise<void> => {
const data = await fetchFriendReqs();
if (data) setFriendRequests(data.requests as FriendRequests);
if (data) setFriendRequests(data.requests);
else setFriendRequests(undefined);
}, [setFriendRequests]);

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/contexts/worksheetContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@ import React, {
} from 'react';
import { seasons, useWorksheetInfo } from './ferryContext';
import type { Option } from './searchContext';
import { useUser, type UserWorksheets } from './userContext';
import { useUser } from './userContext';
import { CUR_SEASON } from '../config';
import type { Season, Crn, NetId } from '../queries/graphql-types';
import { toggleCourseHidden } from '../utilities/api';
import { toggleCourseHidden, type UserWorksheets } from '../utilities/api';
import { useSessionStorageState } from '../utilities/browserStorage';
import type { Listing } from '../utilities/common';

Expand Down
17 changes: 12 additions & 5 deletions frontend/src/queries/graphql-types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
// These types are branded so you never pass the wrong thing
declare const type: unique symbol;
export type Season = string & { [type]: 'season' };
export type NetId = string & { [type]: 'netid' };
export type Crn = number & { [type]: 'crn' };
import z from 'zod';

// These types are branded because they represent opaque identifiers. You would
// never dynamically generate them or manipulate them.
export const seasonSchema = z.string().brand('season');
export const netIdSchema = z.string().brand('netid');
export const crnSchema = z.number().brand('crn');

export type Season = z.infer<typeof seasonSchema>;
export type NetId = z.infer<typeof netIdSchema>;
export type Crn = z.infer<typeof crnSchema>;

export type ExtraInfo =
| 'ACTIVE'
| 'MOVED_TO_SPRING_TERM'
Expand Down
99 changes: 65 additions & 34 deletions frontend/src/utilities/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,13 @@ import type {
ListingFragment,
ListingRatingsFragment,
} from '../generated/graphql';
import type { Season, Crn, NetId } from '../queries/graphql-types';
import {
crnSchema,
netIdSchema,
type Season,
type Crn,
type NetId,
} from '../queries/graphql-types';

type BaseFetchOptions = {
breadcrumb: Sentry.Breadcrumb & {
Expand Down Expand Up @@ -49,10 +55,10 @@ async function fetchAPI(
* is present. A response body is expected and will be parsed.
* Returns the parsed response if successful, or undefined if an error occurred.
*/
async function fetchAPI<T>(
async function fetchAPI<T extends z.ZodSchema>(
endpointSuffix: string,
options: BaseFetchOptions & { body?: {}; schema: z.ZodType<T> },
): Promise<T | undefined>;
options: BaseFetchOptions & { body?: {}; schema: T },
): Promise<z.infer<T> | undefined>;
async function fetchAPI(
endpointSuffix: string,
{
Expand Down Expand Up @@ -310,7 +316,7 @@ const userWorksheetsSchema = z.record(
z.record(
z.array(
z.object({
crn: z.number(),
crn: crnSchema,
color: z.string(),
// This currently is not sent by the backend.
hidden: z.boolean().optional().default(false),
Expand All @@ -319,10 +325,20 @@ const userWorksheetsSchema = z.record(
),
);

// Change index type to be more specific. We don't use the key type of z.record
// on purpose; see https://github.com/colinhacks/zod/pull/2287
export type UserWorksheets = {
[season: Season]: {
[worksheetNumber: number]: NonNullable<
z.infer<typeof userWorksheetsSchema>[Season]
>[string];
};
};

export async function fetchUserWorksheets() {
const res = await fetchAPI('/user/worksheets', {
schema: z.object({
netId: z.string(),
netId: netIdSchema,
// This cannot be null in the real application, because the site creates a
// user if one doesn't exist. This is purely for completeness.
evaluationsEnabled: z.union([z.boolean(), z.null()]),
Expand All @@ -338,27 +354,34 @@ export async function fetchUserWorksheets() {
if (!res) return undefined;
const hiddenCourses = hiddenCoursesStorage.get();
if (!hiddenCourses) return res;
for (const season in res.data) {
if (!hiddenCourses[season as Season]) continue;
for (const seasonKey in res.data) {
// Narrow type
const season = seasonKey as Season;
if (!hiddenCourses[season]) continue;
for (const num in res.data[season]) {
for (const course of res.data[season]![num]!) {
course.hidden =
hiddenCourses[season as Season]?.[course.crn as Crn] ?? false;
}
for (const course of res.data[season]![num]!)
course.hidden = hiddenCourses[season]?.[course.crn] ?? false;
}
}
return res;
}

const friendsSchema = z.record(
z.object({
name: z.string().nullable(),
worksheets: userWorksheetsSchema,
}),
);

// Narrower index type
export type FriendRecord = {
[netId: NetId]: NonNullable<z.infer<typeof friendsSchema>[string]>;
};

export function fetchFriendWorksheets() {
return fetchAPI('/friends/worksheets', {
schema: z.object({
friends: z.record(
z.object({
name: z.string().nullable(),
worksheets: userWorksheetsSchema,
}),
),
friends: friendsSchema,
}),
breadcrumb: {
category: 'friends',
Expand All @@ -367,15 +390,19 @@ export function fetchFriendWorksheets() {
});
}

const friendRequestsSchema = z.array(
z.object({
netId: netIdSchema,
name: z.string().nullable(),
}),
);

export type FriendRequests = z.infer<typeof friendRequestsSchema>;

export function fetchFriendReqs() {
return fetchAPI('/friends/getRequests', {
schema: z.object({
requests: z.array(
z.object({
netId: z.string(),
name: z.string().nullable(),
}),
),
requests: friendRequestsSchema,
}),
breadcrumb: {
category: 'friends',
Expand All @@ -384,17 +411,21 @@ export function fetchFriendReqs() {
});
}

const userNamesSchema = z.array(
z.object({
netId: netIdSchema,
first: z.union([z.string(), z.null()]),
last: z.union([z.string(), z.null()]),
college: z.union([z.string(), z.null()]),
}),
);

export type UserNames = z.infer<typeof userNamesSchema>;

export function fetchAllNames() {
return fetchAPI('/friends/names', {
schema: z.object({
names: z.array(
z.object({
netId: z.string(),
first: z.union([z.string(), z.null()]),
last: z.union([z.string(), z.null()]),
college: z.union([z.string(), z.null()]),
}),
),
names: userNamesSchema,
}),
breadcrumb: {
category: 'friends',
Expand Down Expand Up @@ -454,9 +485,9 @@ export async function checkAuth() {
schema: z.union([
z.object({
auth: z.literal(true),
netId: z.string(),
netId: netIdSchema,
user: z.object({
netId: z.string(),
netId: netIdSchema,
evals: z.boolean(),
email: z.string().optional(),
firstName: z.string().optional(),
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/utilities/course.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Performing various actions on the listing dictionary
import type { Listing } from './common';
import type { SortKeys } from '../contexts/searchContext';
import type { FriendRecord, UserWorksheets } from '../contexts/userContext';
import type { WorksheetCourse } from '../contexts/worksheetContext';
import type {
Courses,
Expand All @@ -14,6 +13,7 @@ import {
type Weekdays,
weekdays,
} from '../queries/graphql-types';
import type { FriendRecord, UserWorksheets } from '../utilities/api';

export function truncatedText(
text: string | null | undefined,
Expand Down

0 comments on commit 3c0f0f1

Please sign in to comment.