DEV Community

Cover image for TypeScript Types Are The Best Kind Of Magic
Kyle Pena
Kyle Pena

Posted on

TypeScript Types Are The Best Kind Of Magic

Sufficiently Advanced Technology

The received wisdom among developers is to avoid magic: magic numbers, magic strings, and framework-flavored "auto-magic". It's brittle, it's unpredictable, and it's not explicit.

On the other hand, Arthur C. Clarke stated that any sufficiently advanced technology is indistinguishable from magic.

So: Is some bit of magic code a hand grenade with the pin pulled, or are we just not clever enough to get it? Is there any way to tell the difference?

It Depends

I had a colleague who was tasked with building a Bun-compatible auto-instrumentation framework. The goal was to create a developer-friendly mechanism to add instrumentation around a wide variety of ES2020+ features - method calls, property access, yields from a generator, and so on.

By way of attribute or a call to instrument(...), a function would become "blessed" with rich diagnostic and logging capabilities without much (or any) extra manual setup. I'm sure I'm missing the finer points, but that was the gist of it as best as I could gather from the sidelines.

Sadly, it all went a bit sideways. The edge cases multiplied, the rewrites intensified every week. After two months of work, it just wasn't reliable enough to use in production and leaked memory. I felt nothing but sympathy - he correctly diagnosed the problem as having "relied too much on magic", but the valuable time was already down the drain.

What was the deeper reason the project didn't pan out? I think it boils down to scope.

In this case, the "thing" that the magic was wrangling - its domain, or "scope" - was an entire language. And ES2020+ is one of the more complicated language specifications out there. Check out the obscure corners.

For "magic" to work reliably, a restricted domain of operation seems necessary. A language's type system is usually quite a bit better behaved than the language itself. That is why, as I dive deeper into TypeScript, I'm warming up to the idea of "type magic".

Quick Case Study

Suppose we have some unmanaged resource that we retrieve by scoped ID:

  • employee/543293 represents Employee 543293.
  • department/55 represents Department 55.
  • building/3 represents Building 3.

Because the resource is ingested into the runtime as some kind of untyped "thing" (bytes, JSON, database record, etc.) we have to parse and then cast with as (if we want to avoid unknown or any).

Frankly, writing as Employee, as Department, or as Building - even within dedicated methods like getEmployee - makes my skin crawl a little. That looks like an opportunity for a late-night miscast with no compile error.

But thankfully it is possible (through the kind of magic that I think Arthur C. Clarke would smile upon) to write the code very cleanly:

const employee : Employee = retrieve(`employee/${key}`);

const department : Department = retrieve(`department/${employee.departmentId}`);

const building : Building = retrieve(`building/${department.buildingId}`);
Enter fullscreen mode Exit fullscreen mode

The magic is that the return type of retrieve automatically varies depending on which keyword precedes / in the object identifier : employee, department, or building.

Here's the source, followed by an explanation:

type Employee = {
    name : string
    departmentId : string;
}

type Department = {
    id : string
    name : string
    budget : number
    buildingId: string
}

type Building = {
    id : string
    name : string
}

type KeywordToObjectType = {
    employee : Employee,
    department : Department,
    building : Building
}

// Keyword ::= employee, department, building
type Keyword = keyof KeywordToObjectType;

// Format of the string literal
type ObjectIdentifier = `${Keyword}/${string}`;

// Utility: Extracts the keyword out of the string literal
type ExtractKeyword<T> = T extends `${infer K extends Keyword}/${string}` ? 
    K : 
    never;

// Utility: Maps ObjectIdentifier to appropriate type
type TypeForObjectIdentifier<T> = T extends ObjectIdentifier ? 
    KeywordToObjectType[ExtractKeyword<T>] : 
    never;

// And this is the retrieve function, which handles the cast
export async function retrieve<T extends ObjectIdentifier>(identifier: T) : 
    Promise<TypeForObjectIdentifier<T>> {
    const retrievedValue = {}; // fetch / parse / etc.
    return retrievedValue as TypeForObjectIdentifier<T>;
}
Enter fullscreen mode Exit fullscreen mode

Here's a step-by-step breakdown.

Generic Types As "Functions On Types"

Note the return type of retrieve: TypeForObjectIdentifier<T>.

It's helpful to think of TypeForObjectIdentifier<T> as a "function on type T", rather than a generic type in the traditional sense.

Therefore the declaration of TypeForObjectIdentifier<T> amounts to a simple "type program" which accepts an object identifier T and returns the appropriate object type. For example, by mapping building/3 to object type Building.

Template Literal Parsing

One of the most powerful features in TypeScript is the ability to perform type inference on template literals - some portions of which may have values that are only known at runtime!

In our declaration of ExtractKeyword<T>, we ask TypeScript to match T to the template literal ${infer K extends Keyword}/${string}, otherwise return never (which can trigger a compiler failure).

type ExtractKeyword<T> = T extends `${infer K extends Keyword}/${string}` ? 
    K : 
    never;
Enter fullscreen mode Exit fullscreen mode

When TypeScript matches building/${department.buildingId} to ${infer K extends Keyword}/${string}, department.buildingId is statically known to be a string.

Likewise, building is bound to K by way of the keyword infer. We can think of the binding to K as if K is a named capture group in a regex - it's a side effect of matching the object identifier to the template literal type.

Referring back to the definition of ExtractKeyword<T>, we return the captured K provided T "extends" (read: "matches") the string template.

Indexed Access Types

In TypeScript's "type language", the syntax Type['property'] produces the type corresponding to the property property on Type. This is called a indexed access type. Therefore, KeywordToObjectTypeMap['building'] produces the type Building.

Putting It All Together

Now it should be clearer how TypeForObjectIdentifier<T> works. If T is an ObjectIdentifier, extract the Keyword portion of the string template via KeywordOf<T>, and then map it to the object type.

type TypeForObjectIdentifier<T> = T extends ObjectIdentifier ? 
    KeywordToObjectType[ExtractKeyword<T>] : 
    never;
Enter fullscreen mode Exit fullscreen mode

Drawbacks

This trick does not guarantee runtime safety. If the shape of the retrieved object does not conform to the expected type given its identifier, we're past the limits of what compile-time safety can guarantee. We can remedy this with some clever use of Zod schemas, but I'll save that for a future post.

Conclusion

It is possible, helpful, and practical to implement "magic type tricks" for cleaner TypeScript code. Rather than create trouble, these tricks increase type safety and make for a better developer experience.

Top comments (0)