Overview

TypeScript has evolved from an interesting Microsoft experiment to being the standard for writing quality JavaScript code. Using TypeScript helps catch many errors that would have been left to show up at runtime by end users. Using TypeScript has become easier for several reasons:

  • TypeScript's ability to infer types keeps getting better. Much code can be written in plain JavaScript without explicit types, but the types are still there and checked to catch inconsistencies.
  • Most modern JavaScript libraries and frameworks, including React and Firebase, come with TypeScript type definitions included.
  • More and more examples online use TypeScript.

The notes below are intended to help beginners get the most value out of TypeScript, especially for class projects involving React and externally fetched data. If you see something that contradicts recommendations that you have seen elsewhere, send me a link!

Links

  • Learn TypeScript -- intoduces elements of TypeScript one by one, with practice exercises

Installing Typescript

For class projects, use the react-ts-vitest template to create a Vite-based React project with TypeScript and Vitest already set up.

mkdir app-name
cd app-name
npx degit criesbeck/react-ts-vitest

This installs TypeScript and typescript-eslint, with the TypeScript mode set to strict.

To create a Vite-based TypeScript React project from scratch

npm create vite@latest app-name -- --template react-ts

Links

Typescript file extensions

Use the extension .ts for files with TypeScript code. Use the extension .tsx for files with TypeScript and JSX code. VS Code and other tools will apply TypeScript processing and linting only to files with these extensions. with TypeScript and Vitest already set up.

VS Code will import type declarations for the packages your app uses. These either come with the package, or have been created by the developer community. If there are some bits of JavaScript that are still untyped, you can add your own type declarations.

Links

Avoid 'any'

When you don't know the type of something -- for example, you are calling a function that gets data from the network -- use the type unknown, not any.

  • any turns off type-checking. You can store anything into a variable of type any and store the value anywhere else, with no checking at all. That's clearly dangerous.
  • unknown says the type is unknown. You can store anything into a variable of type unknown, but if you try to use it or store it somewhere where, you will have to verify the type.

The recommended default configuration in ESLint has noExplicitAny turned on to catch cases where you try to use any. The strict mode in TypeScript turns on noImplicitAny to tell TypeScript not to infer any when it can't tell what something is.

Make objects read-only

const variables in JavaScript are easier to maintain because they can't be reassigned accidentally in later code. But that doesn't mean that they can't be changed.

const obj = { a: 1, b: 2 };
obj.a = 3; // not an error
const lst = [1, 2, 3];
lst[1] = 4; // not an error

This is true of JavaScript, TypeScript, Python, Java, C++, and so on. const prevents reassignment but not mutation.

Data that is fetched from a file or network should not be mutated. To catch this in TypeScript, you can declare read-only properties in the interface:

interface User { readonly name: string; readonly age: number };

const useFetchUser(path: string): [User, boolean, Error | null] => { ... }

const [user, isLoading, error] = useFetchUser(...);
...
user.age = 24; // TypeScript error

If you want both mutable and immutable objects for a given type:

interface MutableUser {
  name: string;
  email: string;
}

type ImmutableUser = Readonly<MutableUser>

You can make data constants read-only by adding as const to the assignment.

const obj = { a: 1, b: 2 } as const;
obj.a = 3; // TypeScript error
const lst = [1, 2, 3] as const;
lst[1] = 4; // TypeScript error 
            

Links

Type component properties

Forgetting to include a property on a conponent or passing the wrong type of value in a propery is a common error in React code. Declaring a type for the component properties can help catch this error during coding. For clarity, use a separate interface or type declaration to define the type of a component's properties.

import type { User } from '../types/interfaces';
import UserCard from './UserCard';

interface UserListProps {
  users: { Record<string, User };
};

const UserList = ({ users }: UserListProps) => (
  <div className="grid grid-cols-[repeat(auto-fill,_minmax(200px,_1fr))] gap-4 px-4">
    {
      Object.entries(users).map(([key, user]) => (
        <UserCard key={key} user={user} />
      ))
    }
  </div>
);

export default UserList;

Mark optional properties as appropriate.

Links

Explicitly type returned tuples

A common pattern with hooks that fetch or send data is to return a tuple of values to represent different states of the process. For example, /src/utilities/firebase.ts defines a hook to store data in a Firebase Realtime database. It returns three values: a function to store a value and two variables to hold information about what happened.

Here's a badly typed definition:

export const useDataUpdate = (path: string) => {
  const [message, setMessage] = useState<string>();
  const [error, setError] = useState<Error>();
  const updateData = useCallback((value: object) => {
    update(ref(database, path), value)
    .then(() => { 
      setMessage('Update succeeded');
    })
    .catch((error: Error) => {
      setMessage('Update failed');
      setError(error);
    })
  }, [path]);

  return [updateData, message, error];
};

There are no type errors with this definition, but there are when you try to use it:

const [update, message, error] = useDataUpdate('/products');
...
  const storeValue = (key: string, value: number ) => {
    update({ [key]: value })  // type error -- update is not  callable
  };
};

The error happens because when TypeScript sees an array, it infers the type to be T[] where T is the best common type of the elements in the array. In this case, TypeScript creates a union type (string | () => void | Error | undefined). With that type, TypeScript can't assume that update is a function.

When returning fixed tuples, specify an explicit typed tuple, like this:

export const useDataUpdate = (path: string) => {
  const [message, setMessage] = useState<string>();
  const [error, setError] = useState<Error>();
!!  const updateData = useCallback((value: object): [(value:object) => void, string | undefined, Error | undefined] => {
    update(ref(database, path), value)
    .then(() => { 
      setMessage('Update succeeded');
    })
    .catch((error: Error) => {
      setMessage('Update failed');
      setError(error);
    })
  }, [path]);

  return [updateData, message, error];
};

Generic typing in JSX files

Most generic functions are defined in a pure TypeScript file, e.g., this code defines randomPick() to return a random element of a list. The return type will be the type of the array elements.

export const randomPick = <T>(lst: T[]): T | null => (
  lst.length === 0 ? null : lst[Math.floor(Math.random() * lst.length)]
);

If you try to put this definition in a JSX file, you get a parse error.

The JSX processor interprets <T> as an HTML tag.

One short but unintuitive fix is to add a comma, like this

export const randomPick = <T,>(lst: T[]): T | null => (
  lst.length === 0 ? null : lst[Math.floor(Math.random() * lst.length)]
);

<T,> comma is not legal HTML, so JSX leaves it alone, to be interpreted by the TypeScript parser as a generic type name.

Links

Type and validate external data

TypeScript information about variables and functions is erased before the code runs. This means there is no run-time cost to adding TypeScript to your code, but there is also no type-checking when your code runs. This becomes an issue when external data is read or fetched, something that almost all apps do.

For example, here is a basic React hook to fetch JSON from a URL.

import { useEffect, useState } from 'react';

type JsonQueryResult = [unknown, boolean, Error | null];

export function useJsonQuery(url: string): JsonQueryResult {
  const [data, setData] = useState();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isMounted = true;

    const fetchData = async () => {
      setLoading(true);
      setData(undefined);
      setError(null);

      try {
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error(`HTTP error ${response.status}`);
        }
!!        const json = await response.json();  // json is type any
        if (isMounted) {
          setData(json);
        }
      } catch (err) {
        if (isMounted) {
          setError(err as Error);
        }
      } finally {
        if (isMounted) {
          setLoading(false);
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [url]);

!!  return [data, loading, error]; // data is type unknown
}

The important items to understand about the typing is that

  • The return type of the JavaScript Response.json() method is any. There is no type checking when that value is used.
  • The type of the data returned by the hook is unknown. Any code using the data returned by the hook must be explicit about the type assumed.

For example, the following code won't be accepted by TypeScript.

const [json, isLoading, error] = useJsonQuery('/courses.json');
const schedule: Schedule = data;  // type error: can't assign unknown to Schedule

The quick and dirty way to get rid of the type error is to use a type assertion:

const [json, isLoading, error] = useJsonQuery('/courses.json');
const schedule: Schedule = data as Schedule;  // no type error but no type checking either

More robust easier to debug code would verify at run-time that the data returned actually has the structure required. For simple data, e.g., an object with a few fields, you could write a type predicate that checks for the required information and returns the desired type. But this can be tedious to maintain and keep correct as larger nested object structures evolve, such as a big JSON object returned from a Firebase Realtime Database.

This is why people have developed libraries like Zod. With Zod, you can define a type schema. From the schema you get both a TypeScript type and a JavaScript method to verify that an object satisfies that type.

This can be very helpful with large structures, such as this recipe format. In the code below, /src/utilities/recipes.ts is a possible Zod definition for Recipe. While a basic schema can normally be done using just z.object(), z.array(), z.string(), and z.number(), this code shows some of the additional features Zod provides, including:

  • Using .trim().min(1) to remove beginning and trailing whitespace and require at least one non-blank
  • Using optional() to mark properties that can be omitted
    • TypeScript will catch code that doesn't properly check for null with such properties.
  • Using .url() to check string values that should be legal URLs
  • Using .enum() to check string values that must be one of a small number of values
  • Using .positive() or .nonnegative() to check for numbers that must not be negative

To make it easier to replace Zod with another validation library, the code does not export the Zod schema. Instead, it exports the types and a function to validate that an object satisfies those types, using safeParse(). safeParse() returns a result object where

  • result.success true means validation succeeded and result.data has the data and is strongly typed.
  • result.success false means validation failed and result.error has a Zod error object with a list of issues that can be printed.

The sample code in /src/components/RecipeCards.tsx demonstrates using the Zod code to validate Recipes fetched from DummyJSON. Note that the RecipeCard component must check for an undefined recipe.image, because that property was marked as optional. TypeScript will complain otherwise.

    import { z } from 'zod/v4';
    
    export const Recipe = z.object({
      id: z.number().positive(),
      name: z.string().trim().min(1),
      ingredients: z.array(z.string().trim().min(1)),
      instructions: z.array(z.string().trim().min(1)),
      prepTimeMinutes: z.number().nonnegative(),
      cookTimeMinutes: z.number().nonnegative(),
      servings: z.number().nonnegative(),
      difficulty: z.enum(['Easy', 'Medium', 'Hard']),
      cuisine: z.string().optional(),
      caloriesPerServing: z.number().optional(),
      tags: z.array(z.string()).optional(),
      userId: z.number(),
      image: z.url().optional(),
      rating: z.number().optional(),
      reviewCount: z.number().optional(),
      mealType: z.array(z.string()).optional()
    });
    
    const RecipeCollection = z.object({
      recipes: z.array(Recipe)
    });
    
    !!export const validateRecipe = Recipe.safeParse;
    !!export const validateRecipes = RecipeCollection.safeParse;
    
    !!export type Recipe = z.infer<typeof Recipe>;
    import { useJsonQuery } from '../utilities/fetch';
    !!import { validateRecipes } from '../types/recipes';
    !!import type { Recipe } from '../types/recipes';
    
    const RecipeCard = ({ recipe }: { recipe: Recipe }) => (
      <div className="border-l-4 border-gray-500 italic my-8 pl-4">
        <h2 className="text-lg font-medium">{recipe.name}</h2>
    !!    { recipe.image ? <img src={recipe.image} /> : '' }
        <h4>Ingredients</h4>
        <ul>{recipe.ingredients.map((item, i) => <li key={i} className="ml-2">{item}</li>)}</ul>
      </div>
    );
    
    const RecipeCards = ( ) => {
      const [json, isLoading, error] = useJsonQuery('https://dummyjson.com/recipes');
    
      if (error) return <h1>Error loading recipes: {`${error}`}</h1>;
      if (isLoading) return <h1>Loading recipes...</h1>;
      if (!json) return <h1>No recipe data found</h1>;
    
    !!  const validation = validateRecipes(json);
    
    !!  if (!validation.success) {
    !!    console.log(validation.error);
    !!    return <h1>Error loading recipes. See console log for details.</h1>
    !!  }
    
    !!  const recipes = validation.data.recipes.slice(0, 5);
    
      return (
        <div className="container mx-auto px-4 w-svw">
          <h1 className="text-2xl">Our Top Recipes</h1>
          <div className="grid grid-cols-[repeat(auto-fill,_minmax(200px,_1fr))] gap-4 px-4">
            { recipes.map(recipe => <RecipeCard key={recipe.id} recipe={recipe} />) }
          </div>
        </div>
      )
    }
    export default RecipeCards;

    Links

    Validate form data

    Just as you should validate data that has been fetched from some external source, so too you should validate data before storing it some external location such as a database. Such validation should occur both client-side to inform users about errors that need fixing, and server-side to avoid accidental or intentional corruption. Here we show how to use Zod to validate form data. See Firebase validation rules for how to do server-side validation with the Firebase Realtime Datanase.

    /src/components/QuoteEditor.tsx defines a quote editing form, using React Hook Form, with validation and immediate user feedback. /src/components/QuoteField.tsx defines a text input fields that display messages when there is a problem with the current value in the field. /src/types/quotes.ts defines the schema for a quote and a "resolver" that React Hook Form can use to validate the form data.

      import { useForm, type SubmitHandler, type SubmitErrorHandler } from 'react-hook-form';
      import Button from './Button';
      import QuoteField from './QuoteField';
      import { quoteResolver, type Quote } from '../types/quotes';
      
      const QuoteEditor = ({quote}: { quote: Quote }) => {
        const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<Quote>({
          defaultValues: quote,
          mode: 'onChange',
          resolver: quoteResolver 
        });
        
        const onSubmit: SubmitHandler<Quote> = async(data) => {
          alert(`Submitting ${JSON.stringify(data)}`)
          // Simulate a 2-second API call
          await new Promise(resolve => setTimeout(resolve, 2000));
        };
      
        const onError: SubmitErrorHandler<Quote> = () => {
          alert('Submissions prevented due to form errors')
        };
      
        return (
          <form onSubmit={handleSubmit(onSubmit, onError)}>
            <input type="number" {...register('id')} className="hidden" />
            <QuoteField name="author" label="Author" errors={errors} register={register} />
            <QuoteField name="quote" label="Quote" errors={errors} register={register} />
            <Button type="submit" disabled={isSubmitting}>Submit</Button>
          </form>
        )
      };
      
      export default QuoteEditor;
      import type { Quote } from '../types/quotes';
      import type { FieldErrors, UseFormRegister } from 'react-hook-form';
      
      interface QuoteFieldProps {
        name: keyof Quote;
        label: string;
        errors: FieldErrors<Quote>;
        register: UseFormRegister<Quote>
      }
      
      const QuoteField = ({name, label, errors, register}: QuoteFieldProps) => (
        <label>
          <p className="text-lg">{label}{ errors[name] && <span className="text-sm inline-block pl-2 text-red-400 italic">
            {errors[name].message}</span> }
          </p>
          <input {...register(name)}
            className={`w-full rounded border ${errors[name] ? 'border-red-500' : 'border-gray-300'} bg-inherit p-3 shadow shadow-gray-100 mt-2 appearance-none outline-none text-neutral-80`}
          />
        </label>
      )
      
      export default QuoteField;
      import { z } from 'zod/v4';
      import { zodResolver } from "@hookform/resolvers/zod";
      
      const Quote = z.object({
        id: z.int(),
        author: z.string().trim().min(1),
        quote: z.string().trim().min(1)
      })
      
      const QuoteCollection = z.object({
        quotes: z.array(Quote)
      })
      
      export type Quote = z.infer<typeof Quote>;
      
      export type QuoteCollection = z.infer<typeof QuoteCollection>;
      
      export const validateQuoteCollection = QuoteCollection.safeParse;
      
      export const quoteResolver = zodResolver(Quote);

      Notes

      React Hook Form is one of many React form libraries. The state of the form -- what's been entered so far -- can be managed by the browsers or by React. Browser-managed state is more efficient but tricker to integrate with React code. That's what React Hook Form does.

      The object passed to useForm() in /src/components/QuoteEditor.tsx does three things:

      • It uses defaultValues to initialize the form fields to the corresponding values in quote.
      • It tells React Hook Form to check the validity of the form data on every keystroke for immediate feedback, rather than only when the form is submitted.
      • It tells React Hook Form to use the Quote resolver defined in /src/types/quote.ts to validate this data. This means that just one schema is used to validate both data fetched and data stored.

      useForm() returns an object with many useful variables, but the form here just uses a few:

      • The form tells handleSubmit() to call onSubmit() if the form is submitted and there are no errors in the form data, and call onError() if the form is submitted and there are errors.
      • Each form input element calls register() to register the name of the field and return several properties to insert into the input element, including name and onChange.
      • Each form input element uses the errors object to display any issues with the current form value.
        • This fairly messy code is refactored into /src/components/QuoteField.tsx to keep the form simple.
        • Getting the types right is a bit complicated. It gets worse if you want to make a completely generic input field.
      • The submit button uses isSubmitting to disable itself while a form submission is in process. This is to prevent accidentally submitting the form twice.

      A hidden field is used to pass the quote ID with letting users edit it. An input field of type number is used so that the ID is a number not a string.

      The onSubmit() function simulates a slow form submission so that you can see the submit button is disabled for two seconds.

      Links

      © 2025 Chris Riesbeck
      Template design by Andreas Viklund