From 2cc20b32efc0e03f3e87f465d02bcfc29b3882f9 Mon Sep 17 00:00:00 2001
From: Tamara <60857422+Myranae@users.noreply.github.com>
Date: Tue, 15 Oct 2024 15:17:18 -0500
Subject: [PATCH] Improve Grapher types (#1730)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

## Summary:
As part of the Server Side Scoring project, this refines Grapher's Rubric to be only what is needed for scoring. It also refines the UserInput type to refer to the rubric as the types are mostly the same. In addition, it cleans up some error expects and some related TODOs by adding lots more typing.

Issue: LEMS-2466

## Test plan:
- Confirm all tests pass
- Confirm widget works as intended via Storybook

Author: Myranae

Reviewers: Myranae, handeyeco

Required Reviewers:

Approved By: handeyeco

Checks: ✅ Publish npm snapshot (ubuntu-latest, 20.x), ✅ Lint, Typecheck, Format, and Test (ubuntu-latest, 20.x), ✅ Cypress (ubuntu-latest, 20.x), ✅ Check builds for changes in size (ubuntu-latest, 20.x), ✅ Check for .changeset entries for all changed files (ubuntu-latest, 20.x), ✅ Publish Storybook to Chromatic (ubuntu-latest, 20.x), ✅ gerald

Pull Request URL: https://github.com/Khan/perseus/pull/1730
---
 .changeset/wet-eyes-promise.md                |   5 +
 packages/perseus/src/perseus-types.ts         | 100 ++++----
 packages/perseus/src/validation.types.ts      |  17 +-
 .../src/widgets/grapher/grapher-types.ts      |  78 +++++++
 .../widgets/grapher/grapher-validator.test.ts |  38 ---
 .../src/widgets/grapher/grapher-validator.ts  |  39 +++-
 packages/perseus/src/widgets/grapher/util.tsx | 219 +++++++++++-------
 7 files changed, 309 insertions(+), 187 deletions(-)
 create mode 100644 .changeset/wet-eyes-promise.md
 create mode 100644 packages/perseus/src/widgets/grapher/grapher-types.ts

diff --git a/.changeset/wet-eyes-promise.md b/.changeset/wet-eyes-promise.md
new file mode 100644
index 0000000000..20baab019b
--- /dev/null
+++ b/.changeset/wet-eyes-promise.md
@@ -0,0 +1,5 @@
+---
+"@khanacademy/perseus": patch
+---
+
+Refine Grapher types and clean up relevant code
diff --git a/packages/perseus/src/perseus-types.ts b/packages/perseus/src/perseus-types.ts
index ebdabe7974..ff63565c2c 100644
--- a/packages/perseus/src/perseus-types.ts
+++ b/packages/perseus/src/perseus-types.ts
@@ -474,6 +474,56 @@ export type GraphRange = [
     y: [min: number, max: number],
 ];
 
+export type GrapherAnswerTypes =
+    | {
+          type: "absolute_value";
+          coords: [
+              // The vertex
+              Coord, // A point along one line of the absolute value "V" lines
+              Coord,
+          ];
+      }
+    | {
+          type: "exponential";
+          // Two points along the asymptote line. Usually (always?) a
+          // horizontal or vertical line.
+          asymptote: [Coord, Coord];
+          // Two points along the exponential curve. One end of the curve
+          // trends towards the asymptote.
+          coords: [Coord, Coord];
+      }
+    | {
+          type: "linear";
+          // Two points along the straight line
+          coords: [Coord, Coord];
+      }
+    | {
+          type: "logarithm";
+          // Two points along the asymptote line.
+          asymptote: [Coord, Coord];
+          // Two points along the logarithmic curve. One end of the curve
+          // trends towards the asymptote.
+          coords: [Coord, Coord];
+      }
+    | {
+          type: "quadratic";
+          coords: [
+              // The vertex of the parabola
+              Coord, // A point along the parabola
+              Coord,
+          ];
+      }
+    | {
+          type: "sinusoid";
+          // Two points on the same slope in the sinusoid wave line.
+          coords: [Coord, Coord];
+      }
+    | {
+          type: "tangent";
+          // Two points on the same slope in the tangent wave line.
+          coords: [Coord, Coord];
+      };
+
 export type PerseusGrapherWidgetOptions = {
     availableTypes: ReadonlyArray<
         | "absolute_value"
@@ -484,55 +534,7 @@ export type PerseusGrapherWidgetOptions = {
         | "sinusoid"
         | "tangent"
     >;
-    correct:
-        | {
-              type: "absolute_value";
-              coords: [
-                  // The vertex
-                  Coord, // A point along one line of the absolute value "V" lines
-                  Coord,
-              ];
-          }
-        | {
-              type: "exponential";
-              // Two points along the asymptote line. Usually (always?) a
-              // horizontal or vertical line.
-              asymptote: [Coord, Coord];
-              // Two points along the exponential curve. One end of the curve
-              // trends towards the asymptote.
-              coords: [Coord, Coord];
-          }
-        | {
-              type: "linear";
-              // Two points along the straight line
-              coords: [Coord, Coord];
-          }
-        | {
-              type: "logarithm";
-              // Two points along the asymptote line.
-              asymptote: [Coord, Coord];
-              // Two points along the logarithmic curve. One end of the curve
-              // trends towards the asymptote.
-              coords: [Coord, Coord];
-          }
-        | {
-              type: "quadratic";
-              coords: [
-                  // The vertex of the parabola
-                  Coord, // A point along the parabola
-                  Coord,
-              ];
-          }
-        | {
-              type: "sinusoid";
-              // Two points on the same slope in the sinusoid wave line.
-              coords: [Coord, Coord];
-          }
-        | {
-              type: "tangent";
-              // Two points on the same slope in the tangent wave line.
-              coords: [Coord, Coord];
-          };
+    correct: GrapherAnswerTypes;
     graph: {
         backgroundImage: {
             bottom?: number;
diff --git a/packages/perseus/src/validation.types.ts b/packages/perseus/src/validation.types.ts
index 7d219385da..cb1e18ed3f 100644
--- a/packages/perseus/src/validation.types.ts
+++ b/packages/perseus/src/validation.types.ts
@@ -1,12 +1,11 @@
-import type {Coord} from "./interactive2/types";
 import type {
+    GrapherAnswerTypes,
     PerseusDefinitionWidgetOptions,
     PerseusDropdownChoice,
     PerseusExplanationWidgetOptions,
     PerseusExpressionAnswerForm,
     PerseusGradedGroupSetWidgetOptions,
     PerseusGradedGroupWidgetOptions,
-    PerseusGrapherWidgetOptions,
     PerseusGraphType,
     PerseusGroupWidgetOptions,
     PerseusIFrameWidgetOptions,
@@ -79,18 +78,12 @@ export type PerseusGradedGroupRubric = PerseusGradedGroupWidgetOptions;
 
 export type PerseusGradedGroupSetRubric = PerseusGradedGroupSetWidgetOptions;
 
-export type PerseusGrapherRubric = PerseusGrapherWidgetOptions;
-
-/**
- * TODO: this is kind of just a guess right now
- * based off of an old comment in grapher
- */
-export type PerseusGrapherUserInput = {
-    type: string;
-    asymptote: ReadonlyArray<Coord>;
-    coords: ReadonlyArray<Coord>;
+export type PerseusGrapherRubric = {
+    correct: GrapherAnswerTypes;
 };
 
+export type PerseusGrapherUserInput = PerseusGrapherRubric["correct"];
+
 export type PerseusIFrameRubric = PerseusIFrameWidgetOptions;
 
 export type PerseusIFrameUserInput = {
diff --git a/packages/perseus/src/widgets/grapher/grapher-types.ts b/packages/perseus/src/widgets/grapher/grapher-types.ts
new file mode 100644
index 0000000000..c004902cbb
--- /dev/null
+++ b/packages/perseus/src/widgets/grapher/grapher-types.ts
@@ -0,0 +1,78 @@
+import type {Coord} from "@khanacademy/perseus";
+
+export type Coords = [Coord, Coord];
+
+// Includes common properties for all function types and plotDefaults
+type SharedGrapherType = {
+    url: string;
+    defaultCoords: Coords;
+    getFunctionForCoeffs: (coeffs: ReadonlyArray<number>, x: number) => number;
+    getEquationString: (coords: Coords, asymptote?: Coords) => string | null;
+    areEqual: (
+        coeffs1: ReadonlyArray<number>,
+        coeffs2: ReadonlyArray<number>,
+    ) => boolean;
+    Movable: any;
+    getCoefficients: (
+        coords: Coords,
+        asymptote?: Coords,
+    ) => ReadonlyArray<number> | undefined;
+};
+
+type AsymptoticGraphsType = {
+    defaultAsymptote: Coords;
+    extraCoordConstraint: (
+        newCoord: Coord,
+        oldCoord: Coord,
+        coords: Coords,
+        asymptote: Coords,
+        graph: any,
+    ) => boolean | Coord;
+    extraAsymptoteConstraint: (
+        newCoord: Coord,
+        oldCoord: Coord,
+        coords: Coords,
+        asymptote: Coords,
+        graph: any,
+    ) => Coord;
+    allowReflectOverAsymptote: boolean;
+};
+
+export type LinearType = SharedGrapherType & {
+    getPropsForCoeffs: (coeffs: ReadonlyArray<number>) => {fn: any};
+};
+
+export type QuadraticType = SharedGrapherType & {
+    getPropsForCoeffs: (coeffs: ReadonlyArray<number>) => {
+        a: number;
+        b: number;
+        c: number;
+    };
+};
+
+export type SinusoidType = SharedGrapherType & {
+    getPropsForCoeffs: (coeffs: ReadonlyArray<number>) => {
+        a: number;
+        b: number;
+        c: number;
+        d: number;
+    };
+};
+
+export type TangentType = SharedGrapherType & {
+    getPropsForCoeffs: (coeffs: ReadonlyArray<number>) => {fn: any};
+};
+
+export type ExponentialType = SharedGrapherType &
+    AsymptoticGraphsType & {
+        getPropsForCoeffs: (coeffs: ReadonlyArray<number>) => {fn: any};
+    };
+
+export type LogarithmType = SharedGrapherType &
+    AsymptoticGraphsType & {
+        getPropsForCoeffs: (coeffs: ReadonlyArray<number>) => {fn: any};
+    };
+
+export type AbsoluteValueType = SharedGrapherType & {
+    getPropsForCoeffs: (coeffs: ReadonlyArray<number>) => {fn: any};
+};
diff --git a/packages/perseus/src/widgets/grapher/grapher-validator.test.ts b/packages/perseus/src/widgets/grapher/grapher-validator.test.ts
index 70614e3932..cdf90f11e5 100644
--- a/packages/perseus/src/widgets/grapher/grapher-validator.test.ts
+++ b/packages/perseus/src/widgets/grapher/grapher-validator.test.ts
@@ -25,15 +25,11 @@ describe("grapherValidator", () => {
         };
 
         const rubric: PerseusGrapherRubric = {
-            availableTypes: ["exponential", "logarithm"],
             correct: {
                 type: "logarithm",
                 asymptote,
                 coords,
             },
-            // The rubric type is probably wrong,
-            // the validator doesn't use graph
-            graph: {} as any,
         };
 
         // Act
@@ -64,15 +60,11 @@ describe("grapherValidator", () => {
         };
 
         const rubric: PerseusGrapherRubric = {
-            availableTypes: ["exponential", "logarithm"],
             correct: {
                 type: "exponential",
                 asymptote,
                 coords,
             },
-            // The rubric type is probably wrong,
-            // the validator doesn't use graph
-            graph: {} as any,
         };
 
         // Act
@@ -86,10 +78,6 @@ describe("grapherValidator", () => {
         // I honestly don't understand what a coefficient is
         // but this seems to get triggered when the type is "linear"
         // and the points are in the same spot
-        const asymptote: [Coord, Coord] = [
-            [-10, -10],
-            [-10, -10],
-        ];
         const coords: [Coord, Coord] = [
             [-10, -10],
             [-10, -10],
@@ -98,19 +86,14 @@ describe("grapherValidator", () => {
         // Arrange
         const userInput: PerseusGrapherUserInput = {
             type: "linear",
-            asymptote,
             coords,
         };
 
         const rubric: PerseusGrapherRubric = {
-            availableTypes: ["linear"],
             correct: {
                 type: "linear",
                 coords,
             },
-            // The rubric type is probably wrong,
-            // the validator doesn't use graph
-            graph: {} as any,
         };
 
         // Act
@@ -121,10 +104,6 @@ describe("grapherValidator", () => {
     });
 
     it("can be answered correctly", () => {
-        const asymptote: [Coord, Coord] = [
-            [-10, -10],
-            [10, 10],
-        ];
         const coords: [Coord, Coord] = [
             [-10, -10],
             [10, 10],
@@ -133,19 +112,14 @@ describe("grapherValidator", () => {
         // Arrange
         const userInput: PerseusGrapherUserInput = {
             type: "linear",
-            asymptote,
             coords,
         };
 
         const rubric: PerseusGrapherRubric = {
-            availableTypes: ["linear"],
             correct: {
                 type: "linear",
                 coords,
             },
-            // The rubric type is probably wrong,
-            // the validator doesn't use graph
-            graph: {} as any,
         };
 
         // Act
@@ -156,17 +130,9 @@ describe("grapherValidator", () => {
     });
 
     it("can be answered incorrectly when user input and rubric coords don't match", () => {
-        // TODO: user input type is probably wrong,
-        // I don't think asymptote is needed for all types
-        const asymptote: [Coord, Coord] = [
-            [10, 10],
-            [-10, -10],
-        ];
-
         // Arrange
         const userInput: PerseusGrapherUserInput = {
             type: "linear",
-            asymptote,
             coords: [
                 [2, 3],
                 [-4, -5],
@@ -174,7 +140,6 @@ describe("grapherValidator", () => {
         };
 
         const rubric: PerseusGrapherRubric = {
-            availableTypes: ["linear"],
             correct: {
                 type: "linear",
                 coords: [
@@ -182,9 +147,6 @@ describe("grapherValidator", () => {
                     [10, 10],
                 ],
             },
-            // The rubric type is probably wrong,
-            // the validator doesn't use graph
-            graph: {} as any,
         };
 
         // Act
diff --git a/packages/perseus/src/widgets/grapher/grapher-validator.ts b/packages/perseus/src/widgets/grapher/grapher-validator.ts
index 74e2e93600..95b37cf9f3 100644
--- a/packages/perseus/src/widgets/grapher/grapher-validator.ts
+++ b/packages/perseus/src/widgets/grapher/grapher-validator.ts
@@ -1,16 +1,39 @@
+import {Errors, PerseusError} from "@khanacademy/perseus-core";
+
 import {functionForType} from "./util";
 
+import type {GrapherAnswerTypes} from "../../perseus-types";
 import type {PerseusScore} from "../../types";
 import type {
     PerseusGrapherRubric,
     PerseusGrapherUserInput,
 } from "../../validation.types";
 
+function getCoefficientsByType(
+    data: GrapherAnswerTypes,
+): ReadonlyArray<number> | undefined {
+    if (data.type === "exponential" || data.type === "logarithm") {
+        const grader = functionForType(data.type);
+        return grader.getCoefficients(data.coords, data.asymptote);
+    } else if (
+        data.type === "linear" ||
+        data.type === "quadratic" ||
+        data.type === "absolute_value" ||
+        data.type === "sinusoid" ||
+        data.type === "tangent"
+    ) {
+        const grader = functionForType(data.type);
+        return grader.getCoefficients(data.coords);
+    } else {
+        throw new PerseusError("Invalid grapher type", Errors.InvalidInput);
+    }
+}
+
 function grapherValidator(
-    state: PerseusGrapherUserInput,
+    userInput: PerseusGrapherUserInput,
     rubric: PerseusGrapherRubric,
 ): PerseusScore {
-    if (state.type !== rubric.correct.type) {
+    if (userInput.type !== rubric.correct.type) {
         return {
             type: "points",
             earned: 0,
@@ -20,7 +43,7 @@ function grapherValidator(
     }
 
     // We haven't moved the coords
-    if (state.coords == null) {
+    if (userInput.coords == null) {
         return {
             type: "invalid",
             message: null,
@@ -28,13 +51,9 @@ function grapherValidator(
     }
 
     // Get new function handler for grading
-    const grader = functionForType(state.type);
-    const guessCoeffs = grader.getCoefficients(state.coords, state.asymptote);
-    const correctCoeffs = grader.getCoefficients(
-        rubric.correct.coords,
-        // @ts-expect-error - TS(2339) - Property 'asymptote' does not exist on type '{ type: "absolute_value"; coords: [Coord, Coord]; }'
-        rubric.correct.asymptote,
-    );
+    const grader = functionForType(userInput.type);
+    const guessCoeffs = getCoefficientsByType(userInput);
+    const correctCoeffs = getCoefficientsByType(rubric.correct);
 
     if (guessCoeffs == null || correctCoeffs == null) {
         return {
diff --git a/packages/perseus/src/widgets/grapher/util.tsx b/packages/perseus/src/widgets/grapher/util.tsx
index 3058555c9d..c077e438bd 100644
--- a/packages/perseus/src/widgets/grapher/util.tsx
+++ b/packages/perseus/src/widgets/grapher/util.tsx
@@ -7,6 +7,16 @@ import Graphie from "../../components/graphie";
 import {getDependencies} from "../../dependencies";
 import Util from "../../util";
 
+import type {
+    Coords,
+    LinearType,
+    QuadraticType,
+    SinusoidType,
+    TangentType,
+    ExponentialType,
+    LogarithmType,
+    AbsoluteValueType,
+} from "./grapher-types";
 import type {Coord} from "../../interactive2/types";
 
 // @ts-expect-error - TS2339 - Property 'Plot' does not exist on type 'typeof Graphie'.
@@ -89,13 +99,14 @@ function canonicalTangentCoefficients(coeffs: any) {
 }
 
 const PlotDefaults = {
-    areEqual: function (coeffs1, coeffs2) {
+    areEqual: function (
+        coeffs1: ReadonlyArray<number>,
+        coeffs2: ReadonlyArray<number>,
+    ): boolean {
         return Util.deepEq(coeffs1, coeffs2);
     },
-
     Movable: Plot,
-
-    getPropsForCoeffs: function (coeffs) {
+    getPropsForCoeffs: function (coeffs: ReadonlyArray<number>): {fn: any} {
         return {
             // @ts-expect-error - TS2339 - Property 'getFunctionForCoeffs' does not exist on type '{ readonly areEqual: (coeffs1: any, coeffs2: any) => boolean; readonly Movable: any; readonly getPropsForCoeffs: (coeffs: any) => any; }'.
             fn: _.partial(this.getFunctionForCoeffs, coeffs),
@@ -103,7 +114,7 @@ const PlotDefaults = {
     },
 } as const;
 
-const Linear = _.extend({}, PlotDefaults, {
+const Linear: LinearType = _.extend({}, PlotDefaults, {
     url: "https://ka-perseus-graphie.s3.amazonaws.com/67aaf581e6d9ef9038c10558a1f70ac21c11c9f8.png",
 
     defaultCoords: [
@@ -111,7 +122,9 @@ const Linear = _.extend({}, PlotDefaults, {
         [0.75, 0.75],
     ],
 
-    getCoefficients: function (coords) {
+    getCoefficients: function (
+        coords: Coords,
+    ): ReadonlyArray<number> | undefined {
         const p1 = coords[0];
         const p2 = coords[1];
 
@@ -127,21 +140,21 @@ const Linear = _.extend({}, PlotDefaults, {
         return [m, b];
     },
 
-    getFunctionForCoeffs: function (coeffs, x) {
+    getFunctionForCoeffs: function (coeffs: ReadonlyArray<number>, x: number) {
         const m = coeffs[0],
             b = coeffs[1];
         return m * x + b;
     },
 
-    getEquationString: function (coords) {
-        const coeffs = this.getCoefficients(coords);
-        const m = coeffs[0],
-            b = coeffs[1];
+    getEquationString: function (coords: Coords) {
+        const coeffs: ReadonlyArray<number> = this.getCoefficients(coords);
+        const m: number = coeffs[0],
+            b: number = coeffs[1];
         return "y = " + m.toFixed(3) + "x + " + b.toFixed(3);
     },
 });
 
-const Quadratic = _.extend({}, PlotDefaults, {
+const Quadratic: QuadraticType = _.extend({}, PlotDefaults, {
     url: "https://ka-perseus-graphie.s3.amazonaws.com/e23d36e6fc29ee37174e92c9daba2a66677128ab.png",
 
     defaultCoords: [
@@ -151,7 +164,7 @@ const Quadratic = _.extend({}, PlotDefaults, {
     // @ts-expect-error - TS2339 - Property 'Parabola' does not exist on type 'typeof Graphie'.
     Movable: Graphie.Parabola,
 
-    getCoefficients: function (coords) {
+    getCoefficients: function (coords: Coords): ReadonlyArray<number> {
         const p1 = coords[0];
         const p2 = coords[1];
 
@@ -167,14 +180,21 @@ const Quadratic = _.extend({}, PlotDefaults, {
         return [a, b, c];
     },
 
-    getFunctionForCoeffs: function (coeffs, x) {
+    getFunctionForCoeffs: function (
+        coeffs: ReadonlyArray<number>,
+        x: number,
+    ): number {
         const a = coeffs[0],
             b = coeffs[1],
             c = coeffs[2];
         return (a * x + b) * x + c;
     },
 
-    getPropsForCoeffs: function (coeffs) {
+    getPropsForCoeffs: function (coeffs: ReadonlyArray<number>): {
+        a: number;
+        b: number;
+        c: number;
+    } {
         return {
             a: coeffs[0],
             b: coeffs[1],
@@ -182,7 +202,7 @@ const Quadratic = _.extend({}, PlotDefaults, {
         };
     },
 
-    getEquationString: function (coords) {
+    getEquationString: function (coords: Coords) {
         const coeffs = this.getCoefficients(coords);
         const a = coeffs[0],
             b = coeffs[1],
@@ -198,7 +218,7 @@ const Quadratic = _.extend({}, PlotDefaults, {
     },
 });
 
-const Sinusoid = _.extend({}, PlotDefaults, {
+const Sinusoid: SinusoidType = _.extend({}, PlotDefaults, {
     url: "https://ka-perseus-graphie.s3.amazonaws.com/3d68e7718498475f53b206c2ab285626baf8857e.png",
 
     defaultCoords: [
@@ -208,7 +228,7 @@ const Sinusoid = _.extend({}, PlotDefaults, {
     // @ts-expect-error - TS2339 - Property 'Sinusoid' does not exist on type 'typeof Graphie'.
     Movable: Graphie.Sinusoid,
 
-    getCoefficients: function (coords) {
+    getCoefficients: function (coords: Coords) {
         const p1 = coords[0];
         const p2 = coords[1];
 
@@ -220,7 +240,7 @@ const Sinusoid = _.extend({}, PlotDefaults, {
         return [a, b, c, d];
     },
 
-    getFunctionForCoeffs: function (coeffs, x) {
+    getFunctionForCoeffs: function (coeffs: ReadonlyArray<number>, x: number) {
         const a = coeffs[0],
             b = coeffs[1],
             c = coeffs[2],
@@ -228,7 +248,7 @@ const Sinusoid = _.extend({}, PlotDefaults, {
         return a * Math.sin(b * x - c) + d;
     },
 
-    getPropsForCoeffs: function (coeffs) {
+    getPropsForCoeffs: function (coeffs: ReadonlyArray<number>) {
         return {
             a: coeffs[0],
             b: coeffs[1],
@@ -237,7 +257,7 @@ const Sinusoid = _.extend({}, PlotDefaults, {
         };
     },
 
-    getEquationString: function (coords) {
+    getEquationString: function (coords: Coords) {
         const coeffs = this.getCoefficients(coords);
         const a = coeffs[0],
             b = coeffs[1],
@@ -255,7 +275,10 @@ const Sinusoid = _.extend({}, PlotDefaults, {
         );
     },
 
-    areEqual: function (coeffs1, coeffs2) {
+    areEqual: function (
+        coeffs1: ReadonlyArray<number>,
+        coeffs2: ReadonlyArray<number>,
+    ) {
         return Util.deepEq(
             canonicalSineCoefficients(coeffs1),
             canonicalSineCoefficients(coeffs2),
@@ -263,7 +286,7 @@ const Sinusoid = _.extend({}, PlotDefaults, {
     },
 });
 
-const Tangent = _.extend({}, PlotDefaults, {
+const Tangent: TangentType = _.extend({}, PlotDefaults, {
     url: "https://ka-perseus-graphie.s3.amazonaws.com/7db80d23c35214f98659fe1cf0765811c1bbfbba.png",
 
     defaultCoords: [
@@ -271,7 +294,7 @@ const Tangent = _.extend({}, PlotDefaults, {
         [0.75, 0.75],
     ],
 
-    getCoefficients: function (coords) {
+    getCoefficients: function (coords: Coords) {
         const p1 = coords[0];
         const p2 = coords[1];
 
@@ -283,7 +306,7 @@ const Tangent = _.extend({}, PlotDefaults, {
         return [a, b, c, d];
     },
 
-    getFunctionForCoeffs: function (coeffs, x) {
+    getFunctionForCoeffs: function (coeffs: ReadonlyArray<number>, x: number) {
         const a = coeffs[0],
             b = coeffs[1],
             c = coeffs[2],
@@ -291,7 +314,7 @@ const Tangent = _.extend({}, PlotDefaults, {
         return a * Math.tan(b * x - c) + d;
     },
 
-    getEquationString: function (coords) {
+    getEquationString: function (coords: Coords) {
         const coeffs = this.getCoefficients(coords);
         const a = coeffs[0],
             b = coeffs[1],
@@ -309,7 +332,10 @@ const Tangent = _.extend({}, PlotDefaults, {
         );
     },
 
-    areEqual: function (coeffs1, coeffs2) {
+    areEqual: function (
+        coeffs1: ReadonlyArray<number>,
+        coeffs2: ReadonlyArray<number>,
+    ) {
         return Util.deepEq(
             canonicalTangentCoefficients(coeffs1),
             canonicalTangentCoefficients(coeffs2),
@@ -317,7 +343,7 @@ const Tangent = _.extend({}, PlotDefaults, {
     },
 });
 
-const Exponential = _.extend({}, PlotDefaults, {
+const Exponential: ExponentialType = _.extend({}, PlotDefaults, {
     url: "https://ka-perseus-graphie.s3.amazonaws.com/9cbfad55525e3ce755a31a631b074670a5dad611.png",
 
     defaultCoords: [
@@ -348,23 +374,23 @@ const Exponential = _.extend({}, PlotDefaults, {
      * coordinate, and `false` uses oldCoord as the resulting coordinate.
      */
     extraCoordConstraint: function (
-        newCoord,
-        oldCoord,
-        coords,
-        asymptote,
+        newCoord: Coord,
+        oldCoord: Coord,
+        coords: Coords,
+        asymptote: Coords,
         graph,
     ) {
-        const y = _.head(asymptote)[1];
+        const y: number = asymptote[0][1];
         return _.all(coords, (coord) => coord[1] !== y);
     },
 
     extraAsymptoteConstraint: function (
-        newCoord,
-        oldCoord,
-        coords,
-        asymptote,
+        newCoord: Coord,
+        oldCoord: Coord,
+        coords: Coords,
+        asymptote: Coords,
         graph,
-    ) {
+    ): Coord {
         const y = newCoord[1];
         const isValid =
             _.all(coords, (coord) => coord[1] > y) ||
@@ -387,24 +413,30 @@ const Exponential = _.extend({}, PlotDefaults, {
 
     allowReflectOverAsymptote: true,
 
-    getCoefficients: function (coords, asymptote) {
+    getCoefficients: function (
+        coords: Coords,
+        asymptote: Coords,
+    ): ReadonlyArray<number> {
         const p1 = coords[0];
         const p2 = coords[1];
 
-        const c = _.head(asymptote)[1];
+        const c = asymptote[0][1];
         const b = Math.log((p1[1] - c) / (p2[1] - c)) / (p1[0] - p2[0]);
         const a = (p1[1] - c) / Math.exp(b * p1[0]);
         return [a, b, c];
     },
 
-    getFunctionForCoeffs: function (coeffs, x) {
+    getFunctionForCoeffs: function (
+        coeffs: ReadonlyArray<number>,
+        x: number,
+    ): number {
         const a = coeffs[0],
             b = coeffs[1],
             c = coeffs[2];
         return a * Math.exp(b * x) + c;
     },
 
-    getEquationString: function (coords, asymptote) {
+    getEquationString: function (coords: Coords, asymptote: Coords) {
         if (!asymptote) {
             return null;
         }
@@ -423,7 +455,7 @@ const Exponential = _.extend({}, PlotDefaults, {
     },
 });
 
-const Logarithm = _.extend({}, PlotDefaults, {
+const Logarithm: LogarithmType = _.extend({}, PlotDefaults, {
     url: "https://ka-perseus-graphie.s3.amazonaws.com/f6491e99d34af34d924bfe0231728ad912068dc3.png",
 
     defaultCoords: [
@@ -437,13 +469,13 @@ const Logarithm = _.extend({}, PlotDefaults, {
     ],
 
     extraCoordConstraint: function (
-        newCoord,
-        oldCoord,
-        coords,
-        asymptote,
+        newCoord: Coord,
+        oldCoord: Coord,
+        coords: Coord,
+        asymptote: Coords,
         graph,
     ) {
-        const x = _.head(asymptote)[0];
+        const x = asymptote[0][0];
         return (
             _.all(coords, (coord) => coord[0] !== x) &&
             coords[0][1] !== coords[1][1]
@@ -451,12 +483,12 @@ const Logarithm = _.extend({}, PlotDefaults, {
     },
 
     extraAsymptoteConstraint: function (
-        newCoord,
-        oldCoord,
-        coords,
-        asymptote,
+        newCoord: Coord,
+        oldCoord: Coord,
+        coords: Coords,
+        asymptote: Coords,
         graph,
-    ) {
+    ): ReadonlyArray<number> {
         const x = newCoord[0];
         const isValid =
             _.all(coords, (coord) => coord[0] > x) ||
@@ -479,34 +511,46 @@ const Logarithm = _.extend({}, PlotDefaults, {
 
     allowReflectOverAsymptote: true,
 
-    getCoefficients: function (coords, asymptote) {
+    getCoefficients: function (
+        coords: Coords,
+        asymptote: Coords,
+    ): ReadonlyArray<number> | undefined {
         // It's easiest to calculate the logarithm's coefficients by thinking
         // about it as the inverse of the exponential, so we flip x and y and
         // perform some algebra on the coefficients. This also unifies the
         // logic between the two 'models'.
-        const flip = (coord: any) => [coord[1], coord[0]];
+        const flip = (coord: Coord): Coord => [coord[1], coord[0]];
         const inverseCoeffs = Exponential.getCoefficients(
-            _.map(coords, flip),
-            _.map(asymptote, flip),
+            _.map(coords, flip) as Coords,
+            _.map(asymptote, flip) as Coords,
         );
-        const c = -inverseCoeffs[2] / inverseCoeffs[0];
-        const b = 1 / inverseCoeffs[0];
-        const a = 1 / inverseCoeffs[1];
-        return [a, b, c];
+        if (inverseCoeffs) {
+            const c = -inverseCoeffs[2] / inverseCoeffs[0];
+            const b = 1 / inverseCoeffs[0];
+            const a = 1 / inverseCoeffs[1];
+            return [a, b, c];
+        }
     },
 
-    getFunctionForCoeffs: function (coeffs, x, asymptote) {
+    getFunctionForCoeffs: function (
+        coeffs: ReadonlyArray<number>,
+        x: number,
+        asymptote: Coords,
+    ) {
         const a = coeffs[0],
             b = coeffs[1],
             c = coeffs[2];
         return a * Math.log(b * x + c);
     },
 
-    getEquationString: function (coords, asymptote) {
+    getEquationString: function (coords: Coords, asymptote: Coords) {
         if (!asymptote) {
             return null;
         }
-        const coeffs = this.getCoefficients(coords, asymptote);
+        const coeffs: ReadonlyArray<number> = this.getCoefficients(
+            coords,
+            asymptote,
+        );
         const a = coeffs[0],
             b = coeffs[1],
             c = coeffs[2];
@@ -521,7 +565,7 @@ const Logarithm = _.extend({}, PlotDefaults, {
     },
 });
 
-const AbsoluteValue = _.extend({}, PlotDefaults, {
+const AbsoluteValue: AbsoluteValueType = _.extend({}, PlotDefaults, {
     url: "https://ka-perseus-graphie.s3.amazonaws.com/8256a630175a0cb1d11de223d6de0266daf98721.png",
 
     defaultCoords: [
@@ -529,7 +573,9 @@ const AbsoluteValue = _.extend({}, PlotDefaults, {
         [0.75, 0.75],
     ],
 
-    getCoefficients: function (coords) {
+    getCoefficients: function (
+        coords: Coords,
+    ): ReadonlyArray<number> | undefined {
         const p1 = coords[0];
         const p2 = coords[1];
 
@@ -550,15 +596,15 @@ const AbsoluteValue = _.extend({}, PlotDefaults, {
         return [m, horizontalOffset, verticalOffset];
     },
 
-    getFunctionForCoeffs: function (coeffs, x) {
+    getFunctionForCoeffs: function (coeffs: ReadonlyArray<number>, x: number) {
         const m = coeffs[0],
             horizontalOffset = coeffs[1],
             verticalOffset = coeffs[2];
         return m * Math.abs(x - horizontalOffset) + verticalOffset;
     },
 
-    getEquationString: function (coords) {
-        const coeffs = this.getCoefficients(coords);
+    getEquationString: function (coords: Coords) {
+        const coeffs: ReadonlyArray<number> = this.getCoefficients(coords);
         const m = coeffs[0],
             horizontalOffset = coeffs[1],
             verticalOffset = coeffs[2];
@@ -586,12 +632,24 @@ const functionTypeMapping = {
 
 export const allTypes: any = _.keys(functionTypeMapping);
 
-export function functionForType(
-    // TODO(jeremy): Actually `$Keys<typeof functionTypeMapping>` but that
-    // triggers TypeScript to require all of our Plot types to be fully typed which
-    // is a big amount of work/change.
-    type: string,
-): any {
+type FunctionTypeMappingKeys = keyof typeof functionTypeMapping;
+
+type ConditionalGraderType<T extends FunctionTypeMappingKeys> =
+    // prettier-ignore
+    T extends "linear" ? LinearType
+    : T extends "quadratic" ? QuadraticType
+    : T extends "sinusoid" ? SinusoidType
+    : T extends "tangent" ? TangentType
+    : T extends "exponential" ? ExponentialType
+    : T extends "logarithm" ? LogarithmType
+    : T extends "absolute_value" ? AbsoluteValueType
+    : never;
+
+export function functionForType<T extends FunctionTypeMappingKeys>(
+    type: T,
+): ConditionalGraderType<T> {
+    // @ts-expect-error: TypeScript doesn't know how to use deal with generics
+    // and conditional types in this way.
     return functionTypeMapping[type];
 }
 
@@ -646,7 +704,10 @@ export const maybePointsFromNormalized = (
 
 /* Given a plot type, return the appropriate default value for a grapher
  * widget's plot props: type, default coords, default asymptote. */
-export const defaultPlotProps = (type: string, graph: any): any => {
+export const defaultPlotProps = (
+    type: FunctionTypeMappingKeys,
+    graph: any,
+): any => {
     // The coords are null by default, to indicate that the user has not
     // moved them from the default position, and that this widget should
     // therefore be considered empty and ineligible for grading. The user
@@ -669,13 +730,15 @@ export const defaultPlotProps = (type: string, graph: any): any => {
     // widget before even reading the question; you can't lose, but you
     // might get a free win.
     const model = functionForType(type);
+    const defaultAsymptote =
+        "defaultAsymptote" in model ? model.defaultAsymptote : null;
     const gridStep = [1, 1];
     // @ts-expect-error - TS2345 - Argument of type 'number[]' is not assignable to parameter of type '[number, number]'.
     const snapStep = Util.snapStepFromGridStep(gridStep);
     return {
         type,
         asymptote: maybePointsFromNormalized(
-            model.defaultAsymptote,
+            defaultAsymptote,
             graph.range,
             graph.step,
             snapStep,
@@ -736,7 +799,7 @@ export const DEFAULT_GRAPHER_PROPS: any = {
     availableTypes: [defaultPlot.type],
 };
 
-export const typeToButton = (type: string): any => {
+export const typeToButton = (type: FunctionTypeMappingKeys): any => {
     const capitalized = type.charAt(0).toUpperCase() + type.substring(1);
     const staticUrl = getDependencies().staticUrl;