diff --git a/frontend/src/components/Worksheet/AddFriendDropdown.tsx b/frontend/src/components/Worksheet/AddFriendDropdown.tsx index 610341fe9..45b36ea8a 100644 --- a/frontend/src/components/Worksheet/AddFriendDropdown.tsx +++ b/frontend/src/components/Worksheet/AddFriendDropdown.tsx @@ -25,12 +25,9 @@ const FriendContext = createContext<{ removeFriend: (netId: NetId, isRequest: boolean) => Promise; } | null>(null); -type FriendNames = { - netId: NetId; - first: string | null; - last: string | null; - college: string | null; -}[]; +type FriendNames = NonNullable< + Awaited> +>['names']; interface OptionType { value: NetId; @@ -166,7 +163,7 @@ function AddFriendDropdownDesktop() { 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(); diff --git a/frontend/src/contexts/userContext.tsx b/frontend/src/contexts/userContext.tsx index 34c980537..a31227769 100644 --- a/frontend/src/contexts/userContext.tsx +++ b/frontend/src/contexts/userContext.tsx @@ -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, Season } from '../queries/graphql-types'; import { fetchUserWorksheets, fetchFriendWorksheets, @@ -21,11 +21,12 @@ import { // Not using z.infer because we want narrower types export type UserWorksheets = { [season: Season]: { - [worksheetNumber: number]: { - crn: Crn; - color: string; - hidden: boolean; - }[]; + // This index type is the only thing that changed + [worksheetNumber: number]: NonNullable< + NonNullable< + Awaited> + >['data'][Season] + >[string]; }; }; export type FriendRecord = { @@ -34,10 +35,9 @@ export type FriendRecord = { worksheets: UserWorksheets; }; }; -type FriendRequests = { - netId: NetId; - name: string | null; -}[]; +type FriendRequests = NonNullable< + Awaited> +>['requests']; type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; @@ -85,7 +85,7 @@ export function UserProvider({ const userRefresh = useCallback(async (): Promise => { 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); @@ -107,7 +107,7 @@ export function UserProvider({ const friendReqRefresh = useCallback(async (): Promise => { const data = await fetchFriendReqs(); - if (data) setFriendRequests(data.requests as FriendRequests); + if (data) setFriendRequests(data.requests); else setFriendRequests(undefined); }, [setFriendRequests]); diff --git a/frontend/src/queries/graphql-types.ts b/frontend/src/queries/graphql-types.ts index aea5e2add..52171b343 100644 --- a/frontend/src/queries/graphql-types.ts +++ b/frontend/src/queries/graphql-types.ts @@ -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; +export type NetId = z.infer; +export type Crn = z.infer; + export type ExtraInfo = | 'ACTIVE' | 'MOVED_TO_SPRING_TERM' diff --git a/frontend/src/utilities/api.ts b/frontend/src/utilities/api.ts index 338c3c998..5f9101dc3 100644 --- a/frontend/src/utilities/api.ts +++ b/frontend/src/utilities/api.ts @@ -12,7 +12,14 @@ import type { ListingFragment, ListingRatingsFragment, } from '../generated/graphql'; -import type { Season, Crn, NetId } from '../queries/graphql-types'; +import { + seasonSchema, + crnSchema, + netIdSchema, + type Season, + type Crn, + type NetId, +} from '../queries/graphql-types'; type BaseFetchOptions = { breadcrumb: Sentry.Breadcrumb & { @@ -49,10 +56,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( +async function fetchAPI( endpointSuffix: string, - options: BaseFetchOptions & { body?: {}; schema: z.ZodType }, -): Promise; + options: BaseFetchOptions & { body?: {}; schema: T }, +): Promise | undefined>; async function fetchAPI( endpointSuffix: string, { @@ -307,10 +314,11 @@ export async function verifyChallenge(body: { } const userWorksheetsSchema = z.record( + seasonSchema, 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), @@ -322,7 +330,7 @@ const userWorksheetsSchema = z.record( 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()]), @@ -340,11 +348,9 @@ export async function fetchUserWorksheets() { if (!hiddenCourses) return res; for (const season in res.data) { if (!hiddenCourses[season as 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 num in res.data[season as Season]) { + for (const course of res.data[season as Season]![num]!) + course.hidden = hiddenCourses[season as Season]?.[course.crn] ?? false; } } return res; @@ -372,7 +378,7 @@ export function fetchFriendReqs() { schema: z.object({ requests: z.array( z.object({ - netId: z.string(), + netId: netIdSchema, name: z.string().nullable(), }), ), @@ -389,7 +395,7 @@ export function fetchAllNames() { schema: z.object({ names: z.array( z.object({ - netId: z.string(), + netId: netIdSchema, first: z.union([z.string(), z.null()]), last: z.union([z.string(), z.null()]), college: z.union([z.string(), z.null()]), @@ -454,9 +460,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(),