TypeScript All the Things!

pahund

Patrick Hund

Posted on December 10, 2020

TypeScript All the Things!

I'm building a social media network and collaboration tool based on mind maps, documenting my work in this series of blog posts. Follow me if you're interested in what I've learned along the way about building web apps with React, Tailwind CSS, Firebase, Apollo/GraphQL, three.js and TypeScript.

Today's Goal

In my previous posts, I've built a 3D mind map, using React and three.js.

In the last post, I added local state management with Apollo Client. Since all the code examples I learned from were written in TypeScript, I decided to migrate my project to TypeScript, as well. Today, I'll convert all the JavaScript code for my 3D mind map that I've written so far to TypeScript.

To Type or Not to Type

TypeScript extends JavaScript by adding types, promising fewer bugs and a better developer experience.

I'm not going to lie, I've had my reservations with TypeScript. Still do, actually.

I don't like how it nudges you in the direction of object oriented programming, a programming style that in my opinion can do more harm than good if used incorrectly. Functional programming, on the other hand, which I love, can be a real pain to type properly.

I also read a lot of complaints on Twitter from developers who fiddle and fight the type system to do their bidding. There are blog posts from smart people whom I respect that say it's a hype that only got so big because it is backed by mighty Microsoft.

On the other hand, coming from Java programming, I know that types can go a long way to make programming easier and take mental load off a programmer writing code – the TypeScript compiler helps you with every keystroke, telling you what you variables can and cannot contain, what kind of arguments to pass to functions, where something might be null or undefined.

So I haven't made up my mind yet if, in the teaser picture above, I'm the boy with the trumpet or the girl holding her ears shut.

I have, however, made up my mind to use TypeScript for my 3D mind map side project, so let's get into the nitty gritty!

Getting Started

If you're using create-react-app, like I do, getting started is quite easy. CRA has TypeScript “built in”. All you have to do is change the file name extension from a JavaScript module from .js to .ts, and boom – you have a TypeScript module.

TypeScript infers the types in the TS modules, so unlike Java, you don't have to write what type it is every time you create a variable. The TS compiler will just assume type any when it can't figure out by itself what type something is.

Warning about implicit any

As you can see here, when the compiler runs in strict mode, it will complain about “implicit any” type in these cases – great! My goal is to not ever use “any” anywhere. I think only then using TypeScript really makes sense.

Null Checking

One thing I've noticed while converting my code to TypeScript: the compiler warns me about something that may be null or undefined in cases where I just didn't bother with checking. I didn't bother checking because from my experience, I can rely on something to be defined/not null. The TS compiler of course can't judge from experience or gut feeling, it tries to help me and warn me.

Take the old JavaScript code of my MindMap React component, for example:

function MindMap({ data }) {
  const divRef= createRef();
  useEffect(() => {
    renderMindMap(divRef.current, data);
  }, [divRef, data]);
  return <div ref={divRef} />;
}
Enter fullscreen mode Exit fullscreen mode

It's just rendering a div to the DOM, then passing a reference to the DOM node to my renderMindMap function that creates the 3D model of the mind map.

Converted to TypeScript:

interface Props {
  data: MindMapData;
}

export default function MindMap({ data }: Props) {
  const divRef: RefObject<HTMLDivElement> = createRef();
  useEffect(() => {
    renderMindMap(divRef.current, data);
  }, [divRef, data]);
  return <div ref={divRef} />;
}
Enter fullscreen mode Exit fullscreen mode

I have to define an interface for the props to tell TypeScript what type of data can be passed to the component – great!

But what's this?

MindMap component compiler warning

TypeScript thinks divRef.current could be null, so I'm not allowed to pass it to the renderMindMap function, which expects a DOM element as first argument!

I add a null check to make the compiler happy:

function MindMap({ data }: Props) {
  const divRef: RefObject<HTMLDivElement> = createRef();
  useEffect(() => {
    const div = divRef.current;
    if (!div) {
      console.error("Rendering the mind map div element failed");
      return;
    }
    renderMindMap(div, data);
  }, [divRef, data]);
  return <div ref={divRef} />;
}
Enter fullscreen mode Exit fullscreen mode

I actually don't think the ref could ever be null, so did TypeScript, in this case, help me prevent a bug, or did it just force my to write extra code? 🤔 Debatable…

When I'm 100% sure I know better than the TypeScript compiler and something simply cannot be null or undefined, I can use ! to override the null check:

function MindMap({ data }: Props) {
  const divRef: RefObject<HTMLDivElement> = createRef();
  useEffect(() => {
    renderMindMap(divRef.current!, data);
  }, [divRef, data]);
  return <div ref={divRef} />;
}
Enter fullscreen mode Exit fullscreen mode

❤️ Thanks Daniel for pointing this out in the comments!

Adding My Own Types

When including a library in your project, usually by installing an npm package, those libraries need to have type definitions if you want to use them properly with your TypeScript project.

Luckily, all the libraries I've included in my 3D mind map project so far do have types. It's great to see that TypeScript nowadays is already so widely supported! 👍🏻

There is only one dependency that doesn't have types, three-trackballcontrols. I'm using this to be able to zoom, pan and rotate my model (see previous post).

So, what to do?

I have to add my own type definitions. Create-react-app comes with a type definition file react-app-env.d.ts that I can use to add my type definition:

declare module 'three-trackballcontrols' {
  declare const TrackballControls: any;
  export default TrackballControls;
}
Enter fullscreen mode Exit fullscreen mode

With this, I can at least import and use the library in my code without compiler warnings. Of course, it doesn't add any value, because I'm just saying the constructor for the TrackballControls object is a function that can accept any old arguments and returns who knows what.

“But Patrick, you said your goal is to not use any anywhere!” – yes, yes, I should really create a proper type definition here. Someday, somewhere, somehow… 😅

Update!

There actually is a TypeScript version of the library, three-trackballcontrols-ts.

When looking for a TS compatible version of an npm package, or for type definitions for a package that you can install separately, it is always a good idea to search on the type search page of the official TypeScript website.

❤️ Thanks stereobooster for pointing this out in the comments!

CSS Modules

Another thing I had to do some research one is using CSS modules properly. I have one CSS file per React component, containing the styles for this particular component. I can import the CSS files in the TypeScript module thanks to some dark webpack magic.

TypeScript is not happy about this:

CSS module compiler warning

To fix this, I add this custom type definition to my react-app-env.d.ts file:

declare module '*.css' {
  interface IClassNames {
    [className: string]: string;
  }
  const classNames: IClassNames;
  export = classNames;
}
Enter fullscreen mode Exit fullscreen mode

It's a good thing that TypeScript has been around for some time now and is widely used, so in cases like this, someone else already had the same problem long ago and fixed it. I can just copy my solutions off StackOverflow or GitHub.

Enforcing Good Practices

One thing I like as I convert my project to TypeScript that the compiler calls me out in cases where I've used bad coding practices and forces me to do better.

Let me explain with one example:

renderMindMap.js (before converting)

data.nodes = await Promise.all(
  data.nodes.map((node) =>
    renderToSprite(<MindMapNode label={node.name} level={node.level} />)
  )
);
const graph = new ThreeForceGraph().graphData(data);
graph.nodeThreeObject(({ sprite }) => sprite);
Enter fullscreen mode Exit fullscreen mode

This code is preparing the nodes of my 3D mind map by pre-rendering them asynchronously. The ThreeForceGraph library has a method that allows me to pass custom objects for the graph nodes, which I'm using to pass the pre-rendered sprites.

What's wrong with this code?

The object data was passed as a function argument. It contains all the data of my mind map. I'm blatantly mutating this object by adding the pre-rendered mind map nodes, for ThreeForceGraph to use. Mutating an object that is passed as an argument to a function makes this function impure and is a bad coding practice, indeed.

With TypeScript, I have to define a type for my mind map data. I tried defining the type of data.node so that it contains a sprite. However, when ThreeForceGraph passes this data to the nodeThreeObject callback function, the TypeScript compiler notices that there is a sprite property in there that, according to the type definition of ThreeForceGraph should not be there.

I fix this by creating a separate map of pre-rendered nodes and then access this map in nodeThreeObject:

renderMindMap.tsx

const preRendered: Map<
  string | number | NodeObject | undefined,
  PreRendered
> = new Map();
await Promise.all(
  data.nodes.map(({ name, val, id }) =>
    renderToSprite(<MindMapNode label={name} level={val} />)
    .then((sprite) => 
      preRendered.set(id, { sprite, linkMaterial });
    )
  )
);
const graph = new ThreeForceGraph().graphData(data);
graph.nodeThreeObject(({ id }) => {
  const sprite = preRendered.get(id)?.sprite;
  if (!sprite) {
    console.error(`Error – no pre-rendered mind map node for ID ${id}`);
    return new THREE.Mesh(
      new THREE.BoxGeometry(),
      new THREE.MeshBasicMaterial({ color: 0xffffff })
    );
  }
  return sprite;
});
Enter fullscreen mode Exit fullscreen mode

Note how, again, in this case I have to add a null check, even though I'm pretty sure that, after having pre-rendered a sprite for every ID, it cannot happen that the map will return null. Oh, well…

But it is a good thing that TypeScript makes me collect the pre-rendered sprites in a separate map instead of just adding them to the original data. “TypeScript made me a better developer.” 😂

The Result

These were just a few things I've noticed while working with TypeScript. I hope you found them interesting. If you want to take a look at the whole project after conversion to TS, here's the code sandbox:

To Be Continued…

I'm planning to turn my mind map into a social media network and collaboration tool and will continue to blog about my progress in follow-up articles. Stay tuned!

💖 💪 🙅 🚩
pahund
Patrick Hund

Posted on December 10, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related