What are generics?

Unlock the power of TypeScript

Developers unfamiliar with robust type systems that support generics might see the following code as a huge “WTF…”

export function filterUndefined<TArrayElementType>(
  theArray: readonly (TArrayElementType | undefined)[]
): Exclude<TArrayElementType, undefined>[] {
  if (!theArray) {
    return [];
  }
  return theArray.filter(
    (item): item is Exclude<TArrayElementType, undefined> => !isUndefined(item)
  );
}

function isUndefined(subject: unknown): subject is undefined {
  return subject === undefined;
}

The semantics you see, e.g., <TArrayElementType>, after filterUndefined is what’s known as a generic. Generics let consumers of your API pass types known to them, but not by us, so that your API can work with and later utilize those passed-in types. Think of them like function arguments for types.

The above filterUndefined function declares a generic, named TArrayElementType, declaring that TArrayElementType is the type passed to the function argument theArray. and guarantees the caller that theArray will return the same type, but with all undefined values removed.

Breakdown

export function filterUndefined<TArrayElementType>(
  theArray: readonly (TArrayElementType | undefined)[]
): Exclude<TArrayElementType, undefined>[]

Let’s take this one chunk at a time. The first three symbols are easy: export function filterUndefined is nothing you haven’t seen before if you’re familiar with Javascript. Then, it gets hairy!

export function filterUndefined<TArrayElementType>

This is the generic signature, equivalent to telling TypeScript “prepare a new variable named TArrayElementType; it can be anything.” But how do we use it?

export function filterUndefined<TArrayElementType>(
  theArray: readonly (TArrayElementType | undefined)[]
)

There we go. This line tells TypeScript, “this function takes a property, theArray, that is an array (that this function only reads from) made up of TArrayElemenyType | undefined.”

Now, the final part is the function return signature:

export function filterUndefined<TArrayElementType>(
  theArray: readonly (TArrayElementType | undefined)[]
): Exclude<TArrayElementType, undefined>[]

Exclude<Haystack, Needle> is a built-in TypeScript generic that takes type Haystack, and narrows it, so that Needle is no longer part of Haystack. For example, Exclude<"foo" | "bar", "bar"> = "foo". Thus, we’re telling TypeScript here that the return type of this function is an array of TArrayElementType with all of the undefined values removed.

From there, it’s standard type guards and return type function signatures!

Examples

This can be useful if your API traffics in inputs that are forgiving (typically to make frontend UI work a bit less complex). You can use helpers like these to easily make your functions more robust. For example:

function doRequest(request: Request) {
  await handleUsersToDeleteForMutation(getUsersToDeleteFromRequest(request));
}

function handleUsersToDeleteForMutation(generousInput: (string | undefined)[]) {
  const usersToDelete: string[] = filterUndefined(generousInput);

  await database.deleteUsersByIds(usersToDelete);
}

Some prefer to make function APIs as slim as possible, however, and would prefer the following:

function doRequest(request: Request) {
  await handleUsersToDeleteForMutation(
    filterUndefined(getUsersToDeleteFromRequest(request))
  );
}

function handleUsersToDeleteForMutation(notSoGenerousInput: string[]) {
  await database.deleteUsersByIds(notSoGenerousInput);
}

If I were reading this code, I would accept either. The trade offs here seem too minimal to spend much more than a few minutes debating it one way or the other.

Over time, you can add these small, generic-powered helper functions as needed, and grow your toolbox into a treasure trove. With enough experience, such helpers can massively reduce the complexity of your code, improve the at-a-glance readability, and substantially reduce the number of test cases you actually need to write.

Take the following code:

import React from "react";
import { useToast } from "useToast";

interface ComplexFormState {}

export function AComplexReactFormMadeSimple(
  initialState: Partial<ComplexFormState> = {}
) {
  // Normally, it's bad practice to pass props straight into state. But here, we
  // want to reset the "initial" form state if the user saves the form to the
  // values we just sent to the backend. That way, it's synchronized if the user
  // makes future changes without re-loading the form.
  const [initialFormState, setInitialFormState] =
    React.useState<Partial<ComplexFormState>>(initialState);
  const resetForm = React.useCallback(() => {
    setInitialFormState(initialFormState);
  }, [initialFormState]);

  const [formState, setFormState] =
    React.useState<Partial<ComplexFormState>>(initialFormState);

  const { error, success } = useToast();

  const [saveForm, { loading: saving }] = useSaveFormMutation({
    onComplete: () => {
      success("Form saved successfully!");
      setInitialFormState(formState);
    },
    onError: () => {
      error("Unexpected error saving the form.")
    }
  });

  // We could consider `useCallback`ing this function, but since it must change
  // every `formState` change, it's probably not worth the overhead.
  const handleSubmit = () => saveForm({
    variables: input: formState
  })

  return (
    <form onSubmit={handleSubmit}>
      <SomeForm formState={formState} setFormState={setFormState} />
    </form>
  );
}

This code is quite annoying to test at the unit level, relying on firing synthetic React events to simulate button submissions and other states, and mocking our useSaveFormMutation function. Yet, these functions are simple closures that provide zero value to exercise, given they’re just calling well-tested, well-structured, type-secured functions. We can save the trouble and around 50 lines of code by leaning on our type checker and our generics-powered tools to make this a single test case!

import React from "react";
import { useToast } from "useToast";

import { getClosureFor, composeSimpleFunctions } from "myCoolLibrary";

interface ComplexFormState {}

export function AComplexReactFormMadeSimple(
  initialState: Partial<ComplexFormState> = {}
) {
  // Normally, it's bad practice to pass props straight into state. But here, we
  // want to reset the "initial" form state if the user saves the form to the
  // values we just sent to the backend. That way, it's synchronized if the user
  // makes future changes without re-loading the form.
  const [initialFormState, setInitialFormState] =
    React.useState<Partial<ComplexFormState>>(initialState);
  const resetForm = React.useCallback(() => {
    setInitialFormState(initialFormState);
  }, [initialFormState]);

  const [formState, setFormState] =
    React.useState<Partial<ComplexFormState>>(initialFormState);

  const { error, success } = useToast();

  const [saveForm, { loading: saving }] = useSaveFormMutation({
    onComplete: composeSimpleFunctions(
      getClosureFor(success, "Form saved successfully!"),
      getClosureFor(setInitialFormState, formState)
    ),
    onError: getClosureFor(error, "Unexpected error saving the form."),
  });

  // We could consider `useCallback`ing this function, but since it must change
  // every `formState` change, it's probably not worth the overhead.
  const handleSubmit = getClosureFor(saveForm, {
    variables: {
      input: formState,
    },
  });

  return (
    <form onSubmit={handleSubmit}>
      <SomeForm formState={formState} setFormState={setFormState} />
    </form>
  );
}

That complex form state handler is now not only entirely declarative, it’s very easy to test! For us at WorkTango, we tend to avoid testing our tools and tests spanning multiple files. (Those are reserved for integration and end-to-end tests!) Because the entirety of this component is thus tooling, we can effectively write a test ensuring that SomeForm was rendered. A unit test for this file would be as simple as the below.

import React from "react";
import { render } from "react-testing-library";

import { AComplexReactFormMadeSimple } from "./AComplexReactFormMadeSimple";

jest.mock("./SomeForm", () => ({
  SomeForm: <b data-testid="some-form">SomeForm</b>,
}));

it("renders SomeForm", () => {
  const { queryByTestId } = render(<AComplexReactFormMadeSimple />);

  expect(queryByTestId("some-form")).toBeTruthy();
});

Testing anything else outside of the end-user experience (for which, at least in our experience, unit tests are ill-suited) encroaches into “testing your tools” territory, so we’re A-OK!

Looking for a Challenge?

Try writing the following generic-powered functions we use above!

  • composeSimpleFunctions
  • getClosureFor