I’m a Java dev, but I wanted to learn React and improve my TypeScript, so I decided to make an app. This is my take on how that went.

Udemy Primer

My employer has quarterly hack weeks. They also provide us with Udemy memberships, and during hack weeks, we’re allowed to take classes as our projects. So I decided to refresh on React. I’d taken a Udemy React course a few years prior, but hadn’t followed it up with a project, and therefore forgotten everything completely.

The Idea

This time, I had a (side) project idea, one which I thought wasn’t too extensive, but which would certainly put my newly refreshed React knowledge to use. I’d create an app which would let non-technical users create voice commands. That might sound like a heavy lift, but my app would just be exporting text files, not doing speech recognition or anything involved like that. Specifically, it’d be exporting config files (for the Dragonfly Python framework among others).

That’s Sleight in a nutshell: a basic CRUD app. I could say more about what it does, but I’d like to focus here instead on the process, considerations, and developer experience. If you’d like to know more about Sleight, check out the repo.

Twitch

I could write an entire article about this alone, but I decided to stream a lot of my coding sessions on Twitch. The VODs are here.

I streamed on Twitch for two reasons. First, I thought it’d be fun. It was. Second, I thought it could be interesting in the future for me or others to have the VODs available: they’re both a demonstration and a time capsule.

Going in, I knew that even the top coding streams only get a few hundred simultaneous viewers, and so I expected to have 0-1 viewers permanently. With a few exceptions, that happened. That said, there were often times when either a viewer would teach me something or I would teach a viewer. Those were great.

Getting Started

The initial project setup couldn’t have be simpler. There are CRA templates of all kinds which get you to Hello World in minutes. I chose one for React + TypeScript + Electron and was doing actual design work on day one.

Technology Choices

Every project has technology choices to be made. One thing I experienced constantly as a code streamer was people questioning said choices. Viewers asked:

  • Why had I chosen React over Vue? Or Angular?
  • Why Electron over Tauri?
  • Why Brandi.js over the variety of other dependency injection frameworks?
  • Why Bootstrap over any number of other hot new CSS frameworks?
  • Why Webpack over Vite?

The answer to many of these dependency choice questions was simply “because it’s currently the most popular and I want help”. I was happy to debate the relative merits of other libraries and frameworks, but ultimately I was optimizing for developer availability. Projects live and die because of maintenance.

I do want to highlight the most interesting bullet point in that group above though: Brandi.js.

Why Brandi.js?

There are a lot of TypeScript dependency injection libraries, some much more popular than Brandi.js, so why did I break the pattern for this particular library? Many of the other dependency injection libraries I vetted required project setup changes and/or used decorators.

Decorators have been an experimental feature for a very long time. (One Java year is seven JavaScript years.) In my opinion, this makes their usage questionable unless your project will be short lived.

As for changes to tsconfig.json/etc., every project setup change I make is one more peculiarity to support potentially indefinitely. There is a trade off here, and it is worth it sometimes, but in this case the alternatives allowed me to do without.

Brandi.js cleverly uses only mainstream TypeScript features for type safe dependency injection. Thus, it was an easy choice once I found it.

Thoughts on React and TypeScript

Next up, let’s talk about the two big technology choices, React and TypeScript.

React Velocity

I have not used Angular or Vue, but I’ve worked on frameworkless projects extensively, in pure JavaScript and in mostly-jQuery. Let me start out by saying that React is a very different, very pleasant experience in comparison. Yes, you should learn fundamentals first. No, you do not need React. Yes, it has drawbacks. However. The speed with which you can move in React versus with simpler approaches is frankly shocking, especially if paired with a nice component library.

React Hooks

When React hooks first came out, I was among the appalled. “Nani?!?! Functions which you have to call once and unconditionally? That’s bizarre. What even are the gains from this strange and too-magical approach?”

… but I came around. I started thinking of them as being similar to Spring Boot annotations. Put annotation at top of class, stuff happens under the hood and dependencies get injected. Put hook at top of functional component, stuff happens under the hood and state gets tracked. Alternatively, you can think of them as being kind of like Scala’s implicits: an extra set of parameters that don’t come from the caller directly. Whatever floats your boat. The point is, hooks are a clean and convenient way to manage local state and keep state local. I’m won over.

Moving Errors Into the Type System

Among the many reasons TypeScript is great is that it allows you to move certain classes of errors into the type system (and therefore get them caught by the compiler).

A demonstration is in order. Let’s say you have a type which sometimes has a value for some property and sometimes doesn’t. You might define it like so.

type MyType = {
  sometimesValue:number|undefined;
  otherValue:string;
}

Then, wherever you access MyType.sometimesValue, you must check that it is defined.

let sum:number = 0;
const thing:MyType = createThing();

// This fails because "sometimesValue" might be undefined.
sum = sum + thing.sometimesValue; // doesn't compile

// Within this `if` block, "sometimesValue" is guaranteed to be defined.
if (thing.sometimesValue) {
  sum = sum + thing.sometimesValue; // compiles
}

Already we’re doing better than Java, Python, and other languages which don’t have any such null safety.

TypeScript Discriminated Unions

What if our model is a bit more complicated? Let’s say for example, that we have some related types which always share certain properties and never share others. Do we then need layers of if blocks? Nope. We can use discriminated unions!

A discriminated union is a type which is composed of two or more types which (A) have been unioned together and (B) share a common property which is unique per type. This common property, because it is unique, can be used to “discriminate” between the unioned types when the specific unioned type isn’t given.

Discriminated unions are useful because they let us create and use related types without the messy hierarchy of inheritance.

Behold.

type Dog = {
  kind: "dog";
  age:number;
  barks:boolean;
}
type Snake = {
  kind: "snake";
  age:number;
  poisonous:boolean;
}
type Animal = Dog | Snake;

In this example, the union type Animal can be either a Dog or Snake type.

If we have a object of type Animal, then we can access age without any if or switch statements. The compiler (tsc) knows that both Dog and Snake have it.

In order to access the type-specific properties (like barks), we have to provide tsc with a guarantee that animal is a Dog.

function logAnimal(animal:Animal) {
  console.log(animal.age); // compiles

  // TypeScript doesn't know yet which kind of animal "animal" is.
  console.log(animal.barks); // doesn't compile :)

  // However, we can guarantee that "animal" is a dog
  // because the union of Dog | Snake only has two possibilities
  // for the "kind" field. 
  if (animal.kind === "dog") {
    console.log(animal.barks); // compiles
  }
}
// There are syntactically slicker ways to do this than 
// with `if` blocks too. I demonstrated this way for 
// understandability.

This kind of clever type checking also allows for total or near-total elimination of explicit casting.

Type Guards

I should also say something here about type guards.

A type guard is a function which guarantees to tsc that an object is of a certain type. In addition to using them in if blocks and ternaries, you can use them with arrays.

// This is a type guard. You can also write them with the "function" keyword.
const isDog = (animal:Animal): animal is Dog => 
    animal.type === "dog";

const animals:Animal[] = getAnimals();

animals.forEach(animal => console.log(animal.barks)); // doesn't compile
animals.filter(isDog)
    .forEach(dog => console.log(dog.barks)); // compiles

I do like type guards, and they are useful, but one thing to keep in mind with them is that they can be wrong.

const isDogIncorrect = (animal:Animal): animal is Dog => 
    animal.type === "snake";

const animals:Animal[] = getAnimals();

// Non-dogs do not have the "barks" property.
animals.filter(isDogIncorrect)
    .forEach(animal => console.log(animal.barks)); // doesn't compile

If a type guard is wrong, errors like the above one will get caught, but sneakier errors may not be. (Imagine that above, instead of logging Dog.barks, we were logging Animal.age and thought we were only getting dog ages.)

TypeScript Structural Types

Coming from the Java world, structural types were something of a surprise initially.

In Java, these two interfaces cannot be used interchangeably, despite the fact that they’re structurally identical.

interface A {
  int count();
}
interface B {
  int count();
}
void doesntWork() {
  A a = getA();
  B b = getB();
  a = b; // doesn't compile
}

In TypeScript, these two types can be used interchangeably.

type A = {
  count:number;
}
type B = {
  count:number;
}
function doesWork() {
  let a:A = getA();
  const b:B = getB();
  a = b; // compiles
}

This is because Java’s types are “nominal” and TypeScript’s types are “structural”. That is to say, if two types are structurally identical, TypeScript considers those types to be equal, whereas Java does not. Incidentally, this even works for types which partially overlap.

type A = {
  count:number;
}
type B = {
  name:string;
  count:number;
}
function overlapping() {
  let a:A = getA();
  let b:B = getB();
  a = b; // compiles: any "B" is also an "A"
  b = a; // doesn't compile: "A"s do not have "name"
}

This has pros and cons. Before finding Brandi.js, I tried to roll my own dependency injection system a few times and found myself reaching for something like a MyType.class to use to identify a type. But because TypeScript’s type information is erased at compile time, nothing like that exists. On the other hand, I didn’t have to worry about third party APIs restricting me to types provided by the library, which can be annoying when a Java library is designed poorly.

Strategy Pattern versus Exhaustive Switch

As I became more familiar with TypeScript, answers to my and viewers’ questions mostly resolved cleanly, eventually. For example:

  • When should I use undefined versus null?
  • When should I use interface versus type?
  • When should I use enum at all?

There was one question that I wrestled with a lot though: when to use the strategy pattern plus delegation versus an exhaustive switch.

Let’s back up a little. Remember our Dog and Snake types from the discriminated unions section? They’re related types which have been unioned together. It is often the case that we’ll have a reference to the union type (Animal) in some function and need to do something specific depending on which unioned type (that is, Dog or Snake) our object is. In languages with algebraic data types and exhaustiveness (like TypeScript), we have the option of using that exhaustiveness, often in a switch statement or match expression, to accomplish this. In languages without exhaustiveness, we end up with some form of the strategy pattern plus delegation. (In many code bases, that is a big ugly if/else-if/else block rather than delegate classes.)

I come from the Java world. We didn’t have exhaustiveness there until Java 17.

Strategy Pattern plus Delegation

First of all, lots of people have never even seen the strategy pattern with delegation. So let’s go through it. How can we rewrite this ugly if/else block?

if (animal.kind === "dog") {
  // do dog stuff
} else if (animal.kind === "snake") {
  // do snake stuff
} else {
  // notes below on how this error could be better
  throw new Error(`unhandled animal kind: ${animal.kind}`);
}

One option, especially if your language doesn’t have exhaustiveness, is to make each if/else branch its own class and then have a delegating class. Something like this.

type AnimalHandler = {
  handle: (animal:Animal) => void; 
}

// DogHandler is a strategy - a single implementation of the delegate type.
class DogHandler implements AnimalHandler {
  handle(animal:Animal) { 
    // check animal kind, do dog stuff if applicable
  }
}
class DelegatingAnimalHandler implements AnimalHandler {
  constructor(private delegates:AnimalHandler[]) {}
  handle(animal:Animal) { 
    // DogHandler, SnakeHandler, etc., are the delegates
    this.delegates.forEach(delegate => delegate.handle(animal));
    // What is this delegator missing?
  }
}

You might be saying to yourself, “What have we actually saved here?! You still need the if blocks!” You do, somewhere, but they won’t be in the thing calling the delegator and that’s much of the point.

Strategy pattern plus delegation pros

  • One module/file per strategy implementation makes for cleaner and more testable code.
  • Especially if you use one file per strategy, git merge conflicts are minimized.

Strategy pattern plus delegation cons

  • Missing strategies cause runtime errors at best or undefined behavior at worst. This is a big deal.

Exhaustiveness

What if your language does have exhaustiveness?

const kind = animal.kind;
switch(kind) {
  case "dog":
    // do dog stuff
    break;
  case "snake":
    // do snake stuff
    break;
  default:
    const shouldNeverReach:never = kind;
    throw new CustomExhaustivenessError(shouldNeverReach);
}

The big advantage here is in the default block. The never type is recognized by tsc as always being unreachable. If you add “cat” to the Animal union type, the delegator in the previous section will still compile, but this switch statement won’t.

You can use the compiler to guarantee that your union type has been handled exhaustively.

Exhaustive switch pros

  • As explained above, missing strategies are guaranteed to be found by the compiler.

Exhaustive switch cons

  • If you have subtypes, you’ll have nested switches or be forced to flatten your hierarchy.
  • If each strategy requires its own dependencies, the dependency injection gets bloated quickly.
  • It’s less easily testable.

No Clear Resolution

I’m inclined generally toward exhaustive switches since they provide stronger guarantees, but it’s not a clear cut win. Arguably the testability criterion can be dropped since, per RTL, unit tests are much less valuable than functional tests anyway, but even without that criterion, the code uglification points aren’t trivial.

What I ended up doing in Sleight was to prefer exhaustive switches in most cases, but where the strategy pattern really called to me, I wrote “delegation tests” which used exhaustive switches to ensure that all cases were handled. It seems like a bit of a workaround but it does indirectly leverage the compiler.

General Thoughts on Design Patterns

There’s also this line of thinking, that design patterns indicate missing language features. It’s an interesting argument, and not obviously wrong, but I’m not sure it’s obviously right either.

The Ecosystem

This part really depends on your perspective. Compared to the Java or Python ecosystems, the JavaScript/TypeScript world is the wild west. Compared to the Haxe world, everything is rosy and wonderful!

Overall, I’d say I had a pretty good experience. I did get stuck a few nights on difficult package.json configuration problems. As seems to be the case with most ecosystems, most of these problems were related to versioning and fragmentation.

  • Some libraries require configuration that would require a CRA eject. But that’s bad it seems.
  • Some libraries required specific versions of CRA, or only worked with Vite, or didn’t support TypeScript.

The list goes on. Suffice to say, it’s not like this in the Java world. Security vulnerabilities notwithstanding, you can use Java code from twenty years ago with no problems. So maybe I’m biased, but that does kind of seem like the bar.

Ultimately, while annoying, these kinds of problems were never severe enough to get me hard stuck, even if I had to use my second or third choice library sometimes (or just do without a library I wanted).

Bonus: Dev’s First Monad

In my quest to replace my shitty homebrew dependency injection system with something better, I ran across the reader monad. I am not a functional programming wizard. Haskell looks to me like HTML looks to muggles. Still, the reader monad is seemingly one of the easier monads, and when I understood it, it brought me great joy. I hope my explanation here is adequate to do the same for you.

Let’s say we have a function which takes some data, but also has a dependency. The typical way I’ve seen this implemented is as follows.

type PaperShredder = {
  shred: (paper:Paper) => void;
}
const dispose = (paper:Paper, shredderDependency:PaperShredder) => {
  const shredded = shredderDependency.shred(paper);
  // do other stuff
}

This can get cumbersome for a couple of reasons. First of all, what if this dispose function is three deep in the call stack? We have to pass our PaperShredder through all of those functions. The dependencies pile up and your top level function ends up with eighteen things getting passed in. Second, even if the call stack is not deep, your PaperShredder has to be available at the time that dispose is called, and that’s not always the case.

Using the reader monad, we readjust the dispose function to look like this.

type Reader<E, R> = (env: E) => R;
const dispose = (paper:Paper):Reader<PaperShredder, Paper> => {
  return (shredderDependency) => {
    const shredded = shredderDependency.shred(paper);
    // do other stuff
  }
}

The reader monad then, is just a function which returns another function. The returned function takes the required dependencies and closes over the data. This lets us defer execution until some later point when the dependencies are available.

Initially I thought, “What if this functional call is nested and there are multiple dependencies? Won’t that still get messy?” I think it could, but there are solutions for that, like bundling dependencies together under minimal interfaces (so as to not end up with a god object).

The reader monad is doubly awesome because with it you can make even the equivalent of Java’s static methods mockable without relying on heavy reflection and the like.

I ultimately didn’t make much use of the reader monad because I learned about it too late in the game and Sleight’s code isn’t functional-style enough for extensive monad usage, but it is certainly an interesting trick, and maybe a foot in the door with understanding monads generally.

The Publishing Decision

Two words: Github Copilot. I wish the githubcopilotlitigation.com legal team the absolute best as they square off against Microsoft.

Copilot’s egregious FOSS license violations made me seriously reconsider whether I should publish Sleight on Github or somewhere else. I decided to go with Github in spite of my misgivings for one reason: I want help with Sleight, and Github is presently the center of the FOSS universe.

I shouldn’t have to choose between Sleight standing a chance of getting pull requests and its license being respected, but Sleight is too large for me to maintain on my own at any reasonable speed, and as features are added, it will only grow. I am sad about my choice but also hopeful about the lawsuit.

Conclusions and Takeaways

Where does that leave us? I had fun. Sleight was a 10/10 for enjoyment, learning, career growth, and community.

I am not done! I am going to continue working on Sleight, albeit less maniacally than I have for the last six months.

Even if no one uses it, even if it gets abandoned or replaced, I would do it again and would recommend a project like this for you, dear reader.