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 3536ca0
Show file tree
Hide file tree
Showing 4 changed files with 49 additions and 39 deletions.
11 changes: 4 additions & 7 deletions frontend/src/components/Worksheet/AddFriendDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,9 @@ 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;
}[];
type FriendNames = NonNullable<
Awaited<ReturnType<typeof fetchAllNames>>
>['names'];

interface OptionType {
value: NetId;
Expand Down Expand Up @@ -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();
Expand Down
24 changes: 12 additions & 12 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, Season } from '../queries/graphql-types';
import {
fetchUserWorksheets,
fetchFriendWorksheets,
Expand All @@ -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<ReturnType<typeof fetchUserWorksheets>>
>['data'][Season]
>[string];
};
};
export type FriendRecord = {
Expand All @@ -34,10 +35,9 @@ export type FriendRecord = {
worksheets: UserWorksheets;
};
};
type FriendRequests = {
netId: NetId;
name: string | null;
}[];
type FriendRequests = NonNullable<
Awaited<ReturnType<typeof fetchFriendReqs>>
>['requests'];

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

Expand Down Expand Up @@ -85,7 +85,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 @@ -107,7 +107,7 @@ export function UserProvider({

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
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
36 changes: 21 additions & 15 deletions frontend/src/utilities/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 & {
Expand Down Expand Up @@ -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<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 @@ -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),
Expand All @@ -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()]),
Expand All @@ -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;
Expand Down Expand Up @@ -372,7 +378,7 @@ export function fetchFriendReqs() {
schema: z.object({
requests: z.array(
z.object({
netId: z.string(),
netId: netIdSchema,
name: z.string().nullable(),
}),
),
Expand All @@ -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()]),
Expand Down Expand Up @@ -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(),
Expand Down

0 comments on commit 3536ca0

Please sign in to comment.