Welcome to the Type World

Learning TypeScript felt like the compiler was against me. But once I understood it as a tool, everything changed.

· #typescript #dx

Disclaimer: This post is based on a talk I gave at the Munich TypeScript Meetup.


The Gate Opens

When I started using TypeScript, it felt like the compiler was against me. I had written Python and JavaScript for years. Then suddenly there was this second layer in my codebase. A darker one. A stricter one.

It did not run in production. But it still controlled everything. For a long time, I fought it. Then something clicked.

TypeScript stopped being an obstacle and became a tool. Once I understood what was really happening, I stopped arguing with the compiler and started collaborating with it.

It Is Not One Language

TypeScript is not JavaScript with annotations. It is JavaScript plus a compile time language that looks similar but behaves differently.

There are two worlds in TypeScript.

Value code runs at runtime. It moves real data. It is what your users execute. Most application code is value code.

Type code runs at compile time. It transforms types. It sets constraints. It never ships. It is a language for describing relationships between values.

And there is Portal code that connects both worlds.

// Value code
function pick(obj, key) {
return obj[key]
}
// Type code
type PickKey<T, K extends keyof T> = T[K]
// Portal code
function pickTyped<Obj, const Key extends keyof Obj>(
obj: Obj,
key: Key,
): PickKey<Obj, Key> {
return obj[key]
}

The compiler does not analyze your runtime logic. It only understands what you explicitly connect. Portal code is where you make that connection.

As application developers, we mostly write value code and portal code. Library authors live much deeper in the type world.

Click go to definition on your favorite helper. You will land in generics, conditionals, and mapped types. That is where the magic happens.

Same Ideas, Different Syntax

The type world is not mystical. It mirrors concepts you already know.

Variables

// Value world
let user = { name: "Carl", age: 30 }
// Type world
type User = { name: "Carl"; age: 30 }

Both create bindings.

Functions

// Value world
function identity(x) {
return x
}
// Type world
type Identity<X> = X

Generics are type level functions.

If Statements

// Value world
function describe(x) {
if (typeof x === "string") {
return "string"
}
return "other"
}
// Type world
type Describe<X> = X extends string ? "string" : "other"

extends is your type level if.

Loops

// Type world
type MakeOptional<T> = {
[K in keyof T]?: T[K]
}

Mapped types are loops over properties. Different syntax. Same mental model.

Where It Gets Uncomfortable

This looks fine:

function get(obj, path) {
let result = obj
for (let key of path.split(".")) {
result = result[key]
}
return result
}
let city = get(person, "address.city")

It works. Until it doesn’t. If "adress.city" is misspelled, you find out at runtime. To catch that at compile time, you need advanced type code. Template literal types. Recursive conditionals. Inference.

This is where many people stop. I did too.

Complex type declarations looked like black magic. I could write JavaScript fluently. Type code felt slow and awkward.

But once you can read it, something changes.

Is it still relevant as AI writes the code?

At my talk someone asked: If agents write the code, do we still need to learn this?

Yes. You will benefit from learning it.

When building @ccssmnn/intl, I wrote the tests first. I described the API and type safety. Then I asked agents to implement it.

They kept telling me TypeScript could not do what I wanted. But they were wrong.

I knew the feature set. I knew the patterns. I knew it was possible. Understanding type code let me tell the difference between a real limitation and an incomplete search. It lets me step in when the agent stalls.

It lets me steer.

Without that understanding, you are limited by what the AI thinks is possible.

A Concrete Example

While building Tilly, I wanted an i18n system without code generation. Messages should live directly in code. Translations must match exactly at compile time.

import { messages, createIntl } from "@ccssmnn/intl"
let base = messages({
greeting: "Hello {$name}",
count: "You have {$n :number} items",
})
let t = createIntl(base, "en")
t("greeting", { name: "Carl" }) // ok
t("count", { n: "Carl" }) // type error
t("greeting") // type error

The messages function is the portal. It captures literal types and feeds them into type level validation.

function messages<const D extends Record<string, string>>(obj: {
[K in keyof D]: ValidateMarkupStructure<D[K]> extends D[K] ? D[K] : never
}): TypedMessages<D> {
return obj as any
}

Three Patterns Worth Learning

You might end up writing complex type code in some part of your application. It might be an internal library, or that one complex module that is somehow necessary.

These patterns help you make this module easier to write and use.

1. Let Functions Be the Portals

Do not manually annotate everything. Let functions capture and propagate types for you.

function messages<const D extends Record<string, string>>(obj: {
[K in keyof D]: ValidateMarkupStructure<D[K]> extends D[K] ? D[K] : never
}): TypedMessages<D> {
return obj as any
}
let base = messages({
greeting: "Hello {$name}",
count: "You have {$n :number} items",
})

The messages function captures literal strings and feeds them into type level validation.

No explicit generics at the call site. No manual type arguments. The function becomes the portal between runtime data and type logic.

If you design the portal well, users get powerful types without writing any type code themselves. You can see this in libraries like @tanstack/router, zod or hono.

2. Produce Helpful Type Errors

Type errors are part of your API surface.

Instead of failing silently or returning never, you can produce descriptive messages.

type RequireGreeting<T extends string> = T extends `Hello ${string}`
? T
: `String must start with "Hello", got "${T}"`
function sayHello<S extends string>(message: RequireGreeting<S>) {
return message
}
sayHello("Hi there")
// Type error: String must start with "Hello", got "Hi there"

This is not just developer experience polish. It also helps AI systems recover. A precise type error narrows the search space.

You are not only rejecting invalid input. You are explaining why. arktype was the first library I saw doing this.

3. Write Type Tests

If your API depends on type level guarantees, test them explicitly.

import { expectTypeOf } from "vitest"
let result = t("greeting", { name: "World" })
expectTypeOf<typeof result>().toBeString()
expect(result).toBe("Hello World!")
if (false as boolean) {
// @ts-expect-error missing required param
t("greeting")
}

The second block never runs. It exists purely to assert compile time behavior.

Type tests document intent. They protect against regressions in your type machinery. And they make it clear what your API promises.

The Shift

Mastering type code does not mean becoming a wizard.

It means recognizing that TypeScript is two languages and learning to move between them intentionally.

Once that clicks, the compiler stops feeling hostile. It becomes leverage.

And leverage compounds.