Typescript crash course

❗ It’s a crash course for me. I have no idea what I’m doing ❗

There’s a free course on Codecademy: https://www.codecademy.com/learn/learn-typescript. I’m starting it now. The goal is to get some understanding of Typescript’s fundamentals. I might not finish the course.

Here’s the code that prompted this:

// app/root.tsx

...
import { useMatches } from "@remix-run/react";
...
export default function App() {
  const matches = useMatches();

  return (
    <Document>
      <Header />
      <div className="flex-1">
        {matches
          .filter((match) => match.handle && match.handle.breadcrumb)
          .map((match, index) => (
            <li key={index}>{match.handle.breadcrumb(match)}</li>
          ))}
        <Outlet />
      </div>
      <ScrollRestoration />
      <Scripts />
      <LiveReload />
      <Footer />
    </Document>
  );
}

This is taken from the Remix breadcrumbs guide: https://remix.run/docs/en/main/guides/breadcrumbs.

Here’s what I’m seeing in VS Code:

The code runs as expected. The warnings are:

  • “property ‘breadcrumb’ does not exist on type ‘{}'”
  • “‘match.handle’ is of type ‘unknown'”

Trying to fix the warnings led to more warnings. I think resulted from not having a clear understanding of the description that VS Code gives for useMatches():

UIMatch<unknown, unknown>[]

The goal is to figure out what’s going on.

For reference, the eventual “solution” I found was:

root.tsx
// root.tsx

import type { LinksFunction } from "@remix-run/node";
import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useRouteError,
  useMatches,
} from "@remix-run/react";
import type { PropsWithChildren } from "react";

import Header from "~/components/Header";
import Footer from "~/components/Footer";
import styles from "./tailwind.css";

interface Handle {
  breadcrumb?: (match: any) => JSX.Element;
}

export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];

function Document({ children }: PropsWithChildren) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="robots" content="noindex" />
        <Meta />
        <Links />
      </head>
      <body className="min-h-screen flex flex-col bg-slate-50">
        {children}
        <LiveReload />
      </body>
    </html>
  );
}

export default function App() {
  const matches = useMatches();

  return (
    <Document>
      <Header />
      <div className="flex-1">
        {matches
          .filter(
            (match) => match.handle && (match?.handle as Handle)?.breadcrumb
          )
          .map((match, index) => (
            <li key={index}>{(match.handle as Handle).breadcrumb!(match)}</li>
          ))}
        <Outlet />
      </div>
      <ScrollRestoration />
      <Scripts />
      <LiveReload />
      <Footer />
    </Document>
  );
}

export function ErrorBoundary() {
  const error = useRouteError();

  const errorMessage = error instanceof Error ? error.message : "Unknown error";
  return (
    <Document>
      <div className="mx-auto max-w-2xl px-20 py-4 my-10 bg-red-200 border rounded">
        <h1>App Error</h1>
        <pre>{errorMessage}</pre>
      </div>
    </Document>
  );
}

That solution is using typecasting (match.handle as Handle) and a “non-null assertion” (breadcrumb!) to suppress the warnings. That works, but I think it kind of defeats the purpose of Typescript.

Learning Typescript (the course)

Why Typescript?

Because Javascript (and Microsoft I guess. It sure seems coupled to VS Code.)

Can I run the course’s exercises locally, instead of on the Codecademy page?

I think so, loosely following https://code.visualstudio.com/docs/typescript/typescript-compiling:

$ mkdir codecademy_typescript
$ cd codecademy_typescript
$ npm init
$ npm install -D typescript
$ touch tsconfig.json
// tsconfig.json

{
  "compilerOptions": {
    "target": "ES5",
    "module": "CommonJS",
    "sourceMap": true
  }
}

Add a “tsc” script to package.json:

// package.json
{
  "name": "codecademy",
  "version": "1.0.0",
  "description": "codecademy typescript course",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "tsc": "tsc"
  },
  "author": "scossar",
  "license": "ISC",
  "devDependencies": {
    "typescript": "^5.4.2"
  }
}

Create a Typescript file:

$ touch helloworld.ts
// helloworld.ts

var message = "Hello World";
console.log(message);

Compile the file (I installed typescript locally, not globally):

$ npm run tsc

Compiles to helloworld.js:

// helloworld.js

var message = "Hello World";
console.log(message);
//# sourceMappingURL=helloworld.js.map

(Unsure about the sourceMappingURL line.)

Run the file:

$ node helloworld.js

Back to the course…

The first lesson was more or less what I posted above, but run in Codecademy’s bash terminal (that seems to be eating my computer’s resources. Edit: I also had redis-server running in the background, using ~20% of my computer’s memory.)

Onto the real stuff:

Type Inferences

JavaScript allows any value to be assigned to any variable. This leads to chaos in large javascript (enough with the caps) projects.

In typescript, when a variable is declared with an initial value, it can’t be reassigned a value of a different data type.

Typescript recognized these built in data types:

  • boolean
  • number
  • null
  • string
  • undefined

I’ll test this on VS Code to make sure I’ve got it setup correctly:

I just found the “problems” tab on VS Code 🙂

So the idea is that typescript is “inferring” the type of a variable based on its initial value.

Type shapes

An object’s “shape” describes what properties and methods it contains. (There’s probably more to it.) The idea is that the built in javascript types have properties and methods associated with them. `”foo”.length`, etc.

Typescript knows about an object’s shape and will throw an error(?), trigger a warning(?), refuse to compile(?) if a method that isn’t in an object’s shape is called on it. Or if an attempt is made to access a property that doesn’t exist for an object.

I’ll test out the “refuse to compile” question:

// helloworld.ts

"Foo".bar();
$ npm run tsc
> codecademy@1.0.0 tsc
> tsc

helloworld.ts:1:7 - error TS2339: Property 'bar' does not exist on type '"Foo"'.

1 "Foo".bar();
        ~~~


Found 1 error in helloworld.ts:1

It finds the error, but also compiles the file:

// helloworld.js

"Foo".bar();
//# sourceMappingURL=helloworld.js.map

So the idea here is that typescript knows if code is trying to do something that doesn’t correspond to an object’s shape.

Any

Where type inference doesn’t happen:

// helloworld.ts

let foo;
foo = 1;
foo = "one"
console.log(`foo: ${foo}`);

The above code compiles without warnings because… when a variable is declared without being assigned an initial value, typescript considers the variable to be of type any. Variables of the any type can be assigned to a value of any type.

The course material says that typescript won’t give an error unless the any type variable is assigned to a different type later on. I guess that means explicitly has its type set?

It’s probably worth noting that :any has been my go to fix for typescript issues. Not after today 🙂

I’m not sure if taking this course, running its examples locally, and live blogging about it is a great approach to learning or a distraction. The proof will be in the pudding. I’ll see how much of this sticks.

Variable type annotations

Declare a variable without an initial value while setting its type:

// helloworld.ts

let foo : string;
foo = 1;
// Type 'number' is not assignable to type 'string'.

There’s an interesting point in this section of the course. If I’m understanding the issue correctly, typescript doesn’t actually execute the code, it just checks for types in the code. I ran into that yesterday with this:

// app/root.tsx

...
{matches
  .filter(
    (match) => match.handle && (match?.handle as Handle)?.breadcrumb
  )
  .map((match, index) => (
    <li key={index}>{(match.handle as Handle).breadcrumb!(match)}</li>
  ))}
...

Even though the code in the filter method was removing any match elements that didn’t have a breadcrumb property, typescript was showing a warning if I didn’t use the not-null assertion (breadcrumb!) in the call to map.

Also, I’ve been declaring variable in my code (let foo, etc) without type annotations. No more 🙂

The tsconfig.json file

I created one already, but just copied it from the VS Code docs.

The file allows for overriding the default typescript rules: https://www.typescriptlang.org/docs/handbook/compiler-options.html. I think this is done in the config file’s "compilerOptions" section.

Here’s the tsconfig.json file that’s being used on Codecademy:

{
  "compilerOptions": {
    "target": "es2017",
    "module": "commonjs",
    "strictNullChecks": true
  },
  "include": ["**/*.ts"]
}

Here’s the tsconfig.json file I copied off the VS Code docs:

{
  "compilerOptions": {
    "target": "ES5",
    "module": "CommonJS",
    "sourceMap": true
  }
}

So yes, the compilerOptions section contains the rules that the typescript compiler will enforce. Getting some help from ChatGPT here:

"target"

Specifies the ECMAScript target version that the typescript compiler will output. Comparing the two config files I posted above: "target": "es2017" tells the compiler to output javascript that conforms to the ECMAScript 2017 standard; "target": "ES5" tells the compiler to output javascript that conforms to the ES5 standard (an older version of javascript.)

"module"

(This is the one that’s been confusing me.)

It defines the module system for the project. In both of the config files, the module system is set to "CommonJS".

Trying to compare this to the tsconfig.json file in my current Remix project, I’m not seeing a "module" property:

// Remix tsconfig.json

{
  "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
  "compilerOptions": {
    "lib": ["DOM", "DOM.Iterable", "ES2022"],
    "isolatedModules": true,
    "esModuleInterop": true,
    "jsx": "react-jsx",
    "moduleResolution": "Bundler",
    "resolveJsonModule": true,
    "target": "ES2022",
    "strict": true,
    "allowJs": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "baseUrl": ".",
    "paths": {
      "~/*": ["./app/*"]
    },

    // Remix takes care of building everything in `remix build`.
    "noEmit": true
  }
}

Instead of a "module" property, it has the rule "moduleResolution": "Bundler". I think this means that it’s telling the compiler that the app’s bundler will handle module resolution. I think Remix is now using ESM modules.

Anyway, this is getting off track 🙂

Function argument types

Javascript doesn’t provide a way of specifying a function’s argument types. The workaround is to add error handling to functions.

With typescript you can add type annotations to function parameters:

// helloworld.ts
function strLen(str: string) {
  console.log(`The string is ${str.length} characters long`);
}

strLen(3);

// Problems
Argument of type 'number' is not assignable to parameter of type 'string'.

If a parameter type annotation isn’t given, the parameter will have the any type and no warning will be generated. The output would be: The string is undefined characters long

I’m learning! I’ve been writing “typescript”, but have not been adding type annotations to function parameters.

Optional parameters

// helloworld.ts

function hello(name: string) {
  console.log(`Hi ${name || "there"} !`);
}

hello();

// Problems

Expected 1 arguments, but got 0.

In the example above, hello() is called with the name argument being undefined. To allow for this:

// helloworld.ts

function hello(name?: string) {
  console.log(`Hi ${name || "there"} !`);
}

hello();

Default parameters

(Just read on Twitter: “Typescript is not a programming language, it’s a vs code extension.”)

Typescript infers variable types based on default parameters:

// helloworld.ts

function hello(name = "you") {
  console.log(typeof name);
}

hello();

// outputs: string

I guess the concept here is that default params allow for cleaner code.

Inferring return types

(I think this is related to the issue I ran into yesterday.)

Will this trigger a warning?

// helloworld.ts

function numGen() {
    return Math.random();
}

function hello(name: string) {
    console.log(`hello ${name}`);
}

hello(numGen());

// Problems
Argument of type 'number' is not assignable to parameter of type 'string'.

Explicit return types

Maybe this was the problem.

It’s the syntax rules that have been throwing me off.

// helloworld.ts

function hello(name: string): string {
 if(name) {
  return name;
  }
// triggers a warning
return undefined;
}

// or explicit return type with arrow function

const hello = (name: string): string => {
  return name;
};

The void return type:

// helloworld.ts

// triggers a warning
const hello = (name: string): string => {
  console.log(name);
}

// correct
const hello = (name: string): void => {
  console.log(name);
};

Arrays

Typescript annotation for arrays:

let foo: string[] = ['bar', 'baz']

or

let foo: Array<string> = ['bar', 'baz']

The latter is called the Array<T> syntax. T stands for the type.

// index.ts

let foo: string[] = ["bar", "baz"];
let bar: Array<string> = ["foo", "baz"];

// proof it works
bar.push("baz");
bar.push(1);

// Problems

Argument of type 'number' is not assignable to parameter of type 'string'.

Multidimensional arrays:

// index.ts

let foo: string[][] = [["bar"], ["baz"]]

Can a top level string be pushed onto it? Nope:

// index.ts
let foo: string[][] = [["bar"], ["baz"]];
foo.push("bat");

// Problems
Argument of type 'string' is not assignable to parameter of type 'string[]'.

Empty arrays:

// index.ts

let foo: string[] = [];
// push a string
foo.push("bar");

To initialize a variable as an array, it seems that the = [] is required:

// index.ts

let foo: string[];
foo.push("bar");

// Problems

Variable 'foo' is used before being assigned.

Tuples

The typescript term for an array with a fixed length and fixed types:

// index.ts

let foo: [number, number, number] = [1, 2, 3];

// length is fixed, so
let bar: [number, string] = [1, "foo", 2];

// Problems
Type '[number, string, number]' is not assignable to type '[number, string]'.
  Source has 3 element(s) but target allows only 2.

A tuple is a distinct type in typescript. An array can’t be assigned to a tuple:

// index.ts

let foo: [number, number] = [1, 2];
let bar: number[] = [3, 4];

let baz: [number, number] = [5, 6]

foo = baz;
// try assigning it to an array:
foo = bar;

// Problems

Type 'number[]' is not assignable to type '[number, number]'.
  Target requires 2 element(s) but source may have fewer.

Array type inference

// index.ts

let foo = [1, 2, 3];
// can push a number, so it's an array, not a tuple
foo[3] = 4;
// can't push a string. The inferred type is `number: []`
foo[3] = "four"; 

// Problems

Type 'string' is not assignable to type 'number'.

So for array type inference, typescript infers the less restrictive type – array, not tuple.

Rest parameters

First, what are rest parameters?

// rest parameters
function sum(...args) {
  let total = 0;
  for (const arg of args) {
    total += arg;
  }
  return total;
}

The annotation of rest parameters is the same as the annotation of arrays:

// index.ts

function sum(...args: number[]): number {
  let total = 0;
  for (const arg of args) {
    total += arg;
  }

  return total;
}

console.log(sum(1, 2, 3));

console.log(sum("one", "two", "three"));

// Problems

Argument of type 'string' is not assignable to parameter of type 'number'.

Spread syntax

Not to be confused with rest parameters.

function sum(x, y, z) {
  return x + y + z;
}

const numbers = [1, 2, 3];

sum(...numbers);

I guess the idea here is that using tuples with the spread syntax is handy.

Complex types

Enums

Enums have come up with GraphQL. For example idType is an enum in WPGraphQL.

// index.ts

enum IdType {
  Slug,
  ID,
  DatabaseId,
}

let idType: IdType;
idType = IdType.Slug;

// can't directly assign it
idType = ID;
// can't use a value that doesn't exist in the type
idType = IdType.Cursor;
// can't assign a string or whatever
idType = "id";

// Problems
Cannot find name 'ID'.
Property 'Cursor' does not exist on type 'typeof IdType'.
Type "id" is not assignable to type 'idType'.

How are enum types compiled to javascript? (I think this is a ‘numeric’ enum):

// index.ts

enum IdType {
  Slug,
  ID,
  DatabaseId,
}

let idType: IdType;
idType = IdType.Slug;
// index.js
var IdType;
(function (IdType) {
    IdType[IdType["Slug"] = 0] = "Slug";
    IdType[IdType["ID"] = 1] = "ID";
    IdType[IdType["DatabaseId"] = 2] = "DatabaseId";
})(IdType || (IdType = {}));

let idType;
idType = IdType.Slug;

String enums vs numeric enums

Example:

enum DirectionNumber { North, South, East, West }
enum DirectionString { North = 'NORTH', South = 'SOUTH', East = 'EAST', West = 'WEST' }

How does a string enum compile to javascript?

// index.js

var DirectionString;
(function (DirectionString) {
    DirectionString["North"] = "NORTH";
    DirectionString["South"] = "SOUTH";
    DirectionString["East"] = "EAST";
    DirectionString["West"] = "WEST";
})(DirectionString || (DirectionString = {}));

That’s less weird. It seems that string enums are the way to go.

Object types

Type annotations for objects look like object literals, but instead of values after properties, there are types:

// index.ts

let person: {name: string, age: number};
person = {name: "simon", age: 55};

Type aliases

Use the format type <alias name> = <type>

type Person = {name: string, age: number};
let me: Person = { name: "Simon", age: 55 };

Function types

Allows for controlling the type of function that can be assigned to a variable:

// index.ts

// doesn't matter what the args are named
type StringsToNumberFunction = (arg0: string, arg1: string) => number;

let convertor: StringsToNumberFunction;
convertor = (str1, str2) => {
  return str1.length + str2.length;
};

console.log(convertor("this", "that"));

Generic types

This is where things get hazy.

Array<T> is an example of a generic type. Any type can be used for T. For example Array<string>.

Here’s the example from the course:

type Family<T> = {
  parents: [T, T], mate: T, children: T[]
};

T is just a placeholder. Family<T> cannot be used in a type annotation. T needs to be substituted for an actual type:

let testFam: Family<string> = {
  parents: ["Betty", "Bob"],
  mate: "Sally",
  children: ["Bob Junior"],
};

console.log(testFam);

… my attempt to finish the course in a day has failed. It was a worthy try. Going back to yesterday’s problem, I wonder if I can understand it a little better:

Here’s the code that seems like it’s expected to work without problems:

root.tsx
// app/root.tsx

import type { LinksFunction } from "@remix-run/node";
import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useRouteError,
  useMatches,
} from "@remix-run/react";
import type { PropsWithChildren } from "react";

import Header from "~/components/Header";
import Footer from "~/components/Footer";
import styles from "./tailwind.css";

export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];

function Document({ children }: PropsWithChildren) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="robots" content="noindex" />
        <Meta />
        <Links />
      </head>
      <body className="min-h-screen flex flex-col bg-slate-50">
        {children}
        <LiveReload />
      </body>
    </html>
  );
}

export default function App() {
  const matches = useMatches();

  return (
    <Document>
      <Header />
      <div className="flex-1">
        <ol>
          {matches
            .filter((match) => match.handle && match.handle.breadcrumb)
            .map((match, index) => (
              <li key={index}>{match.handle.breadcrumb(match)}</li>
            ))}
        </ol>
        <Outlet />
      </div>
      <ScrollRestoration />
      <Scripts />
      <LiveReload />
      <Footer />
    </Document>
  );
}

export function ErrorBoundary() {
  const error = useRouteError();

  const errorMessage = error instanceof Error ? error.message : "Unknown error";
  return (
    <Document>
      <div className="mx-auto max-w-2xl px-20 py-4 my-10 bg-red-200 border rounded">
        <h1>App Error</h1>
        <pre>{errorMessage}</pre>
      </div>
    </Document>
  );
}

Maybe the first thing to note is that the interface solution suggested by ChatGPT is the same as creating a type alias. From https://www.typescriptlang.org/docs/handbook/2/objects.html:

In JavaScript, the fundamental way that we group and pass around data is through objects. In TypeScript, we represent those through object types.

As we’ve seen, they can be anonymous:

function greet(person: { name: string; age: number }) {
  return "Hello " + person.name;
}

or they can be named by using either an interface:

interface Person {
  name: string;
  age: number;
}
 
function greet(person: Person) {
  return "Hello " + person.name;
}

or a type alias:

type Person = {
  name: string;
  age: number;
};
 
function greet(person: Person) {
  return "Hello " + person.name;
}

That’s good to know, I’m not there yet though. In the VS Code “Problems” panel:

Property 'breadcrumb' does not exist on type '{}'.
'match.handle' is of type 'unknown'.

The first problem is here:

{matches
    .filter((match) => match.handle && match.handle.breadcrumb)

match.handle has the type unknown. unknown is similar to the any type, but it’s safer because “it’s not legal to do anything with an unknown value” (https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown)

I think typescript is “saying”, cast match.handle to the type that you need before trying to access it.

interface Handle {
  breadcrumb?: (match: any) => JSX.Element;
}

This interface is essentially(?) the same as the following type alias:

type Handle = {
  breadcrumb?: (match: any) => JSX.Element;
};

In the examples above, breadcrumb?: (match: any) => JSX.Element; defines a function type. The breadcrumb property, if available, is a function that takes an argument of any type and returns a JSX.Element. That all seems correct. Here’s how I’m exporting handle from the blog._index.tsx route:

export const handle = {
  breadcrumb: () => <Link to="/blog">Blog</Link>,
};

For reference, here’s the useMatches definition from Remix(https://github.com/remix-run/remix/blob/4a23e6e3a861238ea5ad285c7d436102bdd43564/packages/remix-react/components.tsx#L1022-L1024):

// /remix-react/components.tsx

/**
 * Returns the active route matches, useful for accessing loaderData for
 * parent/child routes or the route "handle" property
 *
 * @see https://remix.run/hooks/use-matches
 */
export function useMatches(): UIMatch[] {
  return useMatchesRR() as UIMatch[];
}

That’s saying that the function returns an array of objects with the UIMatch type.

The “solution” (this is not a tutorial!) for the useMatches issue on my Remix app is to define a Handle interface:

// app/root.tsx

interface Handle {
  breadcrumb?: (match: any) => JSX.Element;
}

Then use it as a type in a Match interface:

interface Match {
  handle?: Handle;
  pathname: string;
  data: any; // probably not correct.
  params: any; // probably not correct.
}

In the root route’s default export, cast the result of useMatches as an array of Match types:

const matches = useMatches() as Match[];

Last but not least, use a ternary operation to confirm that match?.handle?.breadcrumb exists before trying to set it (I tried a guard function here, but typescript still complained):

  {matches
    .filter((match) => match.handle && match.handle.breadcrumb)
    .map((match, index) => (
      <li key={index}>
        {match?.handle?.breadcrumb
          ? match.handle.breadcrumb(match)
          : ""}
      </li>
    ))}

That ends my deep dive into typescript. I’ll keep learning as I go.