DEV Community

Cover image for tgo Dev log #4: The Dread of Competition, Native Compilation, and the Relentless Grind of Hard Things
Mike Cornwell
Mike Cornwell

Posted on

tgo Dev log #4: The Dread of Competition, Native Compilation, and the Relentless Grind of Hard Things

I had a micro-heart attack this week.

I was in the middle of working through some deep implementation issues on tgo when I stumbled across a project called Goja. My stomach instantly dropped. I got this sudden, heavy sense of dread that someone had already built exactly what I’ve been pouring my life into for the last several weeks.

If you've ever tried to build something unique, you know that exact feeling. It’s the fear that you’re late to the party, that your work is redundant, and that you've been wasting your most precious asset: your time.

I stopped what I was doing and spent a chunk of time getting Goja set up so I could run benchmarks. I wanted to compare their results against my results.

The verdict? We are faster. But more importantly, Goja is not what I am building.

The Architecture of Speed: Interpreter vs. Compiler

Goja is designed for a very specific purpose: if you are writing a Go application and you want to run dynamic JavaScript scripting or plugins inside it at runtime, Goja gives you that engine. I've run into this exact engineering problem years ago with C# and Python. Goja serves its purpose well, but it is an interpreter. It runs scripts at runtime.

tgo is not an interpreter. It is a true native compiler.

Let’s be crystal clear about the difference:

  • The Node/Goja model: At runtime, the engine takes your script, interprets it, and executes it on the fly.
  • The tgo model: It takes your TypeScript code, transpiles it directly into clean Go code, and compiles it down.

The output of tgo is typed native code compiled specifically for your system architecture. When you run it, you are running a raw, optimized system binary. That is why it runs ridiculously fast. It is a complete replacement for the Node engine.

There is No Shortcut Around "Hard"

Once the panic subsided, I was left with a familiar realization: building hard things is just hard.

"Sometimes you can unlock a brilliant abstraction that suddenly makes everything scale. But other times? There is no clever shortcut. It’s just relentlessly hard work."

I find myself constantly edging toward wanting to pivot to something slightly easier, or worrying that someone has already solved this. But the reality is that no one has done this. And they haven't done it because the deep-level implementation is a nightmare.

My goal with tgo is to speed up TypeScript execution dramatically. But to do that for real-world projects, the compiler has to support actual, real-world JavaScript libraries. And this is where the real war is being fought.

The Hidden War with Legacy JavaScript

Here is the irony of building a TypeScript-to-Go compiler: I am not getting stuck on TypeScript. I am getting stuck on JavaScript.

TypeScript is structurally sound. The problem is that almost every major library has a thin layer of TypeScript types sitting on top of a mountain of messy, legacy JavaScript deep in the dependency tree.

I’m currently playing an endless game of whack-a-mole with Lodash [2]. I’m digging deep into dependencies of dependencies, dealing with raw JavaScript that does highly dynamic, obnoxious things.

In JavaScript, a variable's type can be interpreted one way at the start, and then used in a completely different, un-typed way further down the chain. Mapping that dynamic, chaotic behavior into Go's strict, type-safe compiler is incredibly difficult.

I can only imagine what's going to happen when I try to compile a library like Express, which is essentially a massive web of deeply nested dependencies. I’m going to hit an entirely new level of system-breaking problems.

We Keep Plugging

I don't know exactly how I'm going to resolve some of these nested dynamic typing issues yet. The whack-a-mole is going to continue for a while.

But as I've written about before, you have to let go of the need for immediate, easy validation [3]. You have to establish a process, keep your head down, and trust the execution [3]. When you hit a wall of extreme complexity, that is where the pretenders turn around.

The complexity is the moat. I’m going to keep plugging through.


Top comments (2)

Collapse
 
nazar-boyko profile image
Nazar Boyko

"I am not getting stuck on TypeScript, I am getting stuck on JavaScript" is the truest line in this whole log. The typed surface is the easy 20%, and the messy dynamic stuff underneath is where the years go. Lodash is whack-a-mole, but I'd guess the wall you really hit is the stuff that has no static shape at all, like Proxy traps, prototype mutation at runtime, or a value that's a string on Monday and an object on Tuesday. When you reach those, is the plan to refuse to compile them, or to fall back to a tiny embedded interpreter for the genuinely dynamic paths and keep the native compile for everything else? Curious where you'll draw that line, because that boundary feels like the whole game.

Collapse
 
mtleadership profile image
Mike Cornwell

So, one thing I am somewhat finger crossing on this, is that this behavior happens within one's own library. Kind of a standard "this is really how you should be doing things" kinda thing. The compiler detects if this thing I'm touching is a static or a dynamic object, and then handles it. Static gets turned into a struct, and dynamic gets turned into a "JsObject".

The JsObject, works actually really easily and well believe it or not, which fully supports all that dynamic mutation. Where it becomes difficult is 1. determining is this actually static or dynamic? (If its dynamic within the bounds of the library that's a bounded problem, but if its 'static' but people use it dynamically outside the library, that's a big, generally 'unsolveable' problem. Or rather a wack a mole. Maybe i'd need to put in something that "configures" the compiler, so that it knows that certain types are... indeed dynamic, even if it would normally consider it static.

Prototype mutations at runtime, is interesting. I have to double check if I have examples of that. I think that I do.

The issue, which is the real value of doing this, is "how much of this can we make statically typed". Because if I go with the "any on everything", things become alot easier. (But i lose the speed optimizations). I've run into this about a million times when working with numbers. I had to make a decision where if you really want integers you have to use "BigInt". That's not a normal practice with typescript developers. But after reading the typescript docs, it makes it clear that "if its number its a float, and BigInt is stored as an integer". So since that's how its actually stated, then thats what should be done. The problem with the "culture" is that even things like this:

const DEFAULT_TTL = 3 * 24 * 60 * 60 * 1000
Enter fullscreen mode Exit fullscreen mode

Are actually floats rather than integers. (So then the compiler has to do float based math rather than integers, which is non-trivially faster).

So to get those "better speeds" (really when it counts) you'd have to do:

const DEFAULT_TTL = 3n * 24n * 60n * 60n * 1000n
Enter fullscreen mode Exit fullscreen mode

Answer your question, the only thing I'm planning on outright failing are going to be things like "eval" (although maybe I could get around it with something like goja, i just saw this recently).

Right now, it handles dynamic very very well. Its just identifying when that becomes hard.