The 3 things I didn't understand about TypeScript

When the ClassDojo engineering team started using TypeScript on our backend, I was incredibly frustrated. It felt like I needed to constantly work around the type system, and it felt like the types weren't providing any value at all. And, they weren't providing any value to me: I had fundamental misconceptions about how TypeScript worked, and my attempts to work around the Type system by using lots of any and as created some truly abominable TypeScript code.

Type Inference

Whenever you create a variable using let, const, or var, and don't provide a type, TypeScript will infer the type: this means it will use its best guess for the type. These best guesses are pretty good, but they aren't magic!

If you use let or var, TypeScript will assume that it can re-assign to any similar value. If you write let method = "GET";, TypeScript will infer that method is a string, and will let you later reassign method: method = "KonMari". If instead you use const, const method = "GET", TypeScript will infer that method is of type "GET". This means when you use let, you'll often need to use a type. let method: HTTPMethod = "GET" will allow only type-safe reassignment.

When TypeScript infers types for Objects and Arrays, it will similarly give its best guess. In JavaScript, objects are mutable: if you set up a variable like const request = { method: "GET" }, it'll assume that the type that you want is { method: string } to let you update the method field. A type of { method: string } won't be usable by something that wants { method: HTTPMethod }, so you need to either explicitly tell it the type (const request: { method: HTTPMethod } = { method: "GET" }, or tell TypeScript that it's safe to infer a stricter type (const request = { method: "GET" as const }).

Here's a TypeScript playground that explores type inference in a bit more depth, and the type inference part of the handbook is great if you want a detailed introduction.

Why isn't it narrowing?

One of the things that I found most frustrating about TypeScript was writing a clause that felt like it should have narrowed the type of a variable and not have it actually narrow. I'd often use as to bypass the type system entirely and force something to narrow, but overriding TypeScript like this is a real anti-pattern. When I finally learned about type narrowing, and type guards, TypeScript became so much more pleasant to use.

One of the very first things I learned about JavaScript was that you should never use in with Objects because in doesn't differentiate between an object's properties and those of its prototype. In TypeScript, in is crucial to use for type narrowing. If you have a function that takes a type like Error | (Error & { type: "NotFound", table: string }) | (Error & { type: "NotAllowed", reason: string }), you can't write if (!err.type) return because TypeScript doesn't know whether err has that field or not. Instead, you need to write if (!("type" in err)) return and TypeScript won't error, and will correctly narrow the type.

One related confusion was why I couldn't use if statements to narrow a type. I'd try to write code like the following:

Don't do this!

type NotFound = Error & { NotFound: true; table: string };
type ServerError = Error & { ServerError: true; message: string };
type ClientError = Error & { ClientError: true; status: number };

function getResponse(err: Error | NotFound | ServerError | ClientError) {
  if ((err as ClientError).ClientError) {
    return { status: (err as ClientError).status, message: "client error" };
  }
  if ((err as NotFound).NotFound) {
    const notFoundError = err as NotFound;
    return {
      status: 404,
      message: `not found in ${notFoundError.table}`;
    }
  }

  return {
    status: 500,
    message: (err as ServerError).message || "server error",
  }
}

This code worked, but I had to use as everywhere to override TypeScript and TypeScript made it harder to write this code rather than easier!

TypeScript can automatically discriminate a union type if the members of a union all share a field. This can lead to much simpler, type-safe, and easier to work with code:

type NotFound = Error & { type: "NotFound"; table: string };
type ServerError = Error & { type: "ServerError"; message: string };
type ClientError = Error & { type: "ClientError"; status: number };

function getResponse(err: Error | NotFound | ServerError | ClientError) {
  // doing this first lets TypeScript discriminate using the `type` property
  if (!("type" in err)) {
    return {
      status: 500,
      message: "server error",
    };
  }

  if (err.type === "ClientError") {
    // err now has type ClientError, so we can use status!
    return { status: err.status, message: "client error" };
  }

  if (err.type === "NotFound") {
    return {
      status: 404,
      message: `not found in ${err.table}`,
    };
  }

  // it even narrows this to ServerError!
  // although it may be wiser to have an explicit `if` statement and assert that every case is handled
  return {
    status: 500,
    message: err.message,
  };
}

Finally, if there are more complex types that you need to narrow, writing a custom type guard function is always an option—this is a function that returns a boolean that lets TypeScript narrow a type whenever you call it.

// `method isHTTPMethod` tells callers that any string that you've checked with this function is an HTTPMethod
function isHTTPMethod(method: string): method is HTTPMethod {
  return ["GET", "PUT", "POST", "DELETE"].includes(method);
}

Here's a TypeScript playground where you can see how this works.

"Brands" are required for Nominal Typing

TypeScript uses "structural typing", which means that only the shape of something matters, and the name of the type is completely irrelevant. If something looks like a duck, it's a duck as far as TypeScript is concerned. This is surprising if you're coming from a "nominal typing" background where two types with the same shapes can't be assigned to each other.

// this is valid TypeScript
type EmailAddress = string;
type Url = string;
const emailAddress: EmailAddress = "myEmail@classdojo.com";
const url: Url = emailAddress;

If you want nominal-style types for a string or number, you need to use "Brands" to create "impossible" types.

type EmailAddress = string & { __impossible_property: "EmailAddress" };
type URL = string & { __impossible_property: "Url" };
const emailAddress = "myEmail@classdojo.com" as EmailAddress; // we need to use `as` here because the EmailAddress type isn't actually possible to create in JS
const url: URL = emailAddress; // this now errors the way we'd want

(The above isn't how you'd actually want to write this code: I'm writing it in a way that hopefully makes it a bit more clear what's going on. If you'd like to see a more realistic example, take a look at this playground)

TypeScript isn't just "JS with types"

TypeScript is a great language, but just treating it as "JS with types" is a surefire recipe for frustration. It takes time to learn the language, and I wish I'd spent more of that time up-front reading through the docs: it'd have saved a ton of pain. The TypeScript handbook is great, and there are tons of great resources online to help understand TypeScript better. There's still a ton I don't know about TypeScript, but finally learning about inference, type narrowing, and structural typing has made developing with TypeScript so much nicer.