DEV Community

Cover image for How to think and type in TypeScript
Arek Nawo
Arek Nawo

Posted on • Originally published at areknawo.com

How to think and type in TypeScript

This post is taken from my blog, so be sure to check it out for more up-to-date content πŸ˜‰

A while ago I've written a 3 part tutorial on learning TypeScript. There, I discussed different types, syntax and more with a detailed description for each. But naturally, there's more to learning a programming language that just a raw syntax. You need to know how things interact with each other, how you should properly utilize them, and what are the best practices to follow. And that's something that I think is worth a deeper look. Let's dive in! πŸ˜ƒ

Syntax combinations

In the previously mentioned tutorial, I briefly reminded a few times even, about just how well different types work together. The cooperation of programming structures, techniques, and other things form the programming as we know it - it's nothing new. But, especially in TypeScript, this concept takes whole another level. πŸš€ And here's why.

TS falls into the category of what can be referred to as transpiled languages. The keyword here is transpilation. It's often used as a synonym to the compilation, but in reality, it operates on a different scope. You see, the term complied languages often refer to C, C++, Java and alike. Even if they target different outputs, e.g. JVM (byte code) or machine code, they both compile from a high level of abstraction (such as Java's syntax) to something as low-level as machine code. TS, on the other hand, is transpiled to JS, which in fact is a JIT-compiled/interpreted language. Abstraction to the power of two. πŸ˜…

What I'm trying to say here, is that TypeScript is limited by JavaScript. It can provide nothing more than JS can do on its own. But TS embraces this, what seems like a disadvantage, but being nothing more than ES-Next compliant language with a static type system. Yeah, I know I already have written that in previous articles, but it's probably the most important thing to understand here, especially for newcomers. That's the reason why choosing something like TS for your first programming language to learn is a dumb idea. Learn the JS well-enough first, so you can later come back to TypeScript. Same rules apply for this article. πŸ‘

Now, I said that TS is special when it comes to the level of putting stuff together to good use. That's because of its fundamental ideology - to provide a static type system for syntax designed for a dynamic language. Sometimes it might feel easy, but it can be really hard otherwise. To provide the same level of flexibility, just like a dynamically-typed language, TypeScript needs to have good design and architecture in place for its type system. And that of course, results in a number of complex type structures and variations. That's what forces you to think different - to think in TypeScript. πŸ”₯

Different groups

To make the best use of TypeScript type system, you first have to understand its inner structures... or rather their specific groups. It's exactly what we've done in the TS introduction series. So, here instead I would like to make a more general round-up. To take a look at all these types from their respective groups perspective. Because that's what will matter in our programming & thinking process. πŸ‘¨β€πŸ’»

Basic types

The most basic types are called primitive types and top types. These include types like number, string, object, any, void, null etc. Primitive types are the name referring to datatypes that are well-known even in standard JS community. They have names corresponding to their values, e.g. boolean, number, string, and nullable type. Top types, on the other hand, include only types that are more specific to TS and other statically-typed languages, e.g. object, unknown and infamous any. Both of these types groups provide you everything you need to properly type your code... at the basic level. They can also be used together with all following, more complex types groups.

Set types

Another type of types πŸ˜… we can distinguish is set types. Here, all the types that allow you to group other types can find their place. A whole variety of different types can be placed in this group. Interfaces, enums, unions - everything that in one way or another can collect other, basic and even set types! This lets you create really deep and complex structures which are must-have in any statically-typed language. Now, of course, you can't just like that group so different types, based on one aspect. All of the types in this group are unique. Each of them serves a different purpose and has its own distinctive properties. E.g. unions are completely different from interfaces and so on. It's only the single, similar grouping property that unites them in this particular group.

Utility types

The last group we can try to differentiate in TS can be called utility types. Into this category fall types, or should I say utils, like extends, keyof, typeof, index signature, mapped types and more. What's special about these is the lack of any similarities in syntax. They are all different from each other. They serve different purposes and, in most cases, they cannot be used without other, supportive types. That's why I called them utility types. Also, this group can be one of the hardest to properly use because of this uniqueness. πŸ€”

It's always good to have at least some order in place. Especially if this relates to things that you must properly utilize every day. The grouping above is just an example. Your own ordering can be more or less complex, or it can just not exist at all. Right now, with the structure above, it's time to put our skills to a real test!

Use-cases

Typing a function or creating an interface is an easy task. But what if, on your way to creating awesome code, a big obstacle appears? That's why I'd like to explore some real, complex bottlenecks that you can possibly stumble upon. This way you can achieve truly well-typed code! πŸŽ‰

Index signature with additional properties

Imagine a situation, in which you have to correctly type an object, that beyond its own, unique properties, stores some more general ones. For example, it can be a state object that has its own get and set methods but also stores all its values inside itself, as standard properties. We'd like to create a proper interface for that. First, we should definitely create an index signature. We'd like for our values to be only static, meaning - number, string, boolean or undefined.

type StaticValue = number | string | boolean | undefined;

interface State {
    [key: string]: StaticValue;
}

Enter fullscreen mode Exit fullscreen mode

Easy enough, huh? But what about our methods?

interface State {
    [key: string]: StaticValue;
    get(key: string): StaticValue; // error
    set(key: string, value: StaticValue): void; // error
}

Enter fullscreen mode Exit fullscreen mode

And here comes the catch! Index signature requires all properties of the given structure to have a declared type, which, unsurprisingly, results in an error when declaring our methods.

Now, I know that the example above can be not-so-convincing for everybody. You can even argue if it's not an anti-pattern to do such a thing. But, the reality is that you can very easily stumble upon this kind of problem in the near future. So, how to solve it?

In the current state of TS (v.3.3) it's impossible to do such a thing with an interface. Instead, we would have to use type alias and intersection type to achieve the desired result. πŸ‘ Here's how.

type State = {
    get(key: string): StaticValue;
    set(key: string, value: StaticValue): void;
} & {
    [key: string]: StaticValue;
}

Enter fullscreen mode Exit fullscreen mode

This can seem a little bit dirty, but it's the only possible way. In fact, intersection types, being such inconspicuous structures they are, they can solve a lot of everyday problems. Which takes us to the next case...

Functions with properties

Yet another uncommon, but possible pattern in JS is a function with additional properties. It sometimes goes by the name of callable object. Don't mistake it with a class, tho (although classes may sometimes be better for this kind of a job).

We know that TS allow for such constructs, because of its special, built-in interface syntax for defining function types. So, let's create an interface for our data, shall we?

interface CallableObject {
    (param: string): string;
    myNumberProp: number;
    myBooleanProp: boolean;
}

Enter fullscreen mode Exit fullscreen mode

There's no catch here - just a simple interface. Everything should be working correctly without any more effort. But, how to actually use such an interface? Well, we've got 2 options.

First, we can use the function expression (necessity) and type-casting. This allows us to later easily assign our previously listed properties, without any errors.

const callableObj = <CallableObject>((param) => param);

callableObj.myNumberProp = 10;
callableObj.myBooleanProp = true;
callableObj("str");

Enter fullscreen mode Exit fullscreen mode

Notice that in the example above, we needed to wrap our arrow function in brackets - normal function expressions don't require that. Also, you also don't have to type your function - type-casting has already done it for you! πŸ˜‰

The method above is pretty clear and completely fine, but what if you want to define your callable object in a single shot? Interestingly enough, there's yet another, maybe even more robust method. It requires us to use some ES6 goodness, namely Object.assign().

const callableObj = Object.assign(
    (param: string) => param, {
    myNumberProp: 10,
    myBooleanProp: true
});

Enter fullscreen mode Exit fullscreen mode

Worth noticing is the fact of how little typing we had to make here compared to the previous method. Object.assign() automatically returns a proper intersection type, with our function and its properties included. Also, in this way, we can define our properties more closely to the declaration step, which is neat! πŸ˜„ Of course, if you want to create a specific interface here, you can use this method with type-casting, just as well as it was with the previous one! But, IMHO, if you're doing an interface, the first method should be bit better for you. πŸ€”

Type inference & iteration

Next case brings us to loops and iteration. More specifically, to object iteration. In contrast to our previous examples, here we have a bit more common situation. Looping over an object can often be really useful! Let's create our object first.

const obj = {
    myNumberProp: 10,
    myStringProp: "str",
    myBooleanProp: true
};

Enter fullscreen mode Exit fullscreen mode

Here we're depending on the type-inference to correctly create a good object literal type for our object. Now, there's a number of ways to iterate over objects. One of which is the use of the for... in loop.

for (const key in obj) {
    const value = obj[key]; // error
}

Enter fullscreen mode Exit fullscreen mode

And here we have our catch! Accessing the object property with the given key would result in our value being of type any, or an error when in strict mode (by the way, I always use TS in strict mode and highly recommend it to anyone, to get most out of TS πŸ™‚). Let's investigate our problem further!

In the beginning, it's worth noticing that the type of our object is an object literal type. It has all its properties strictly defined and typed. It ensures us that we won't access a property that doesn't exist in this object. Next, taking a look at our loop, we need to remember the basic rule behind the TS type inference - best common type. It basically indicates that the inferred type should be as much generic and suitable as possible. Which means that the type of our keyvariable is a string. Thus, accessing the property of our object with key of the type as generic as a string results in the mentioned error. Properties of our object can only be accessed with a rightful string literal, e.g. "myNumberProp". So, how to fix this?

The best choice would be to properly type our variables ourselves. But, with in-loop variables like key, it's not an option. So, the only way out would be to know well about the possible consequences of our doings, and use type-casting. Our best bet would be to create a properly cast-typed function, instead of casting our key variable every time we want to access a property. Here's an example.

function loopObj<T extends object>(
    obj: T,
    callback: (key: keyof T, value: T[keyof T]) => void
) {
    for (const key in obj) {
        callback(key, obj[key]);
    }
}

loopObj(obj, (key, value) => {
    // code
});

Enter fullscreen mode Exit fullscreen mode

As you can see, we're making good use of generics and our good old friend - keyof. In this way, you can just put this function in your code, and enjoy a fine development experience with strictly typed code (using unions and string literals) and possibly even a bit nicer syntax.

One last note on this particular problem. If you for whatever reason use the Object.keys() or .values() or .entries() methods to iterate an object, the rules above still apply. It's just the nature of TS type-inference and best common type rule. With the above methods, you still have to use some form of type-casting to achieve the best possible result (although here it doesn't necessarily require a whole function). Also, type inference works the same when it comes to values (type inferred as number|string|boolean), which makes is natural and, in this case, makes complete sense. πŸ‘

Declaration merging

Lastly, I would like to talk about the declaration merging. It's not really any kind of problem, but it's such a complex topic, that some explanation might be worth our precious time.

Here, I have yet another real-use problem. I have a class with allows its user to register additional methods in the form of a collection of functions. These should be later accessible through the class, including the proper typing and intellisense. And, while such an advanced scenario may require some tweaks, it can still be done with the help of declaration merging. Let's first set up our class.

class MyClass {
    myNumberProp: number = 10;
    myStringProp: string = "str";

    registerExtension(ext: {[key: string]: Function}) {
        for (const key in ext) {
            // @ts-ignore
            this[key] = ext[key];
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

A pretty bad thing happens here. The use of the @ts-ignore comment, which disables the TS compiler checking for the next line. It's a necessity, as here we need to add a property to our class of the specified name, with given class not having the index signature we've discussed this problem earlier. Here we have an exception where there's no other way than to omit the check. We could be even more crafty and use the suppressImplicitAnyIndexErrors in our strict config. This effectively disables all kinds of index-signature-related errors in our TS code. But, to be honest, it's better to stay strict and omit to check on only one line rather than in the whole code. πŸ˜•

Now, let's create our extension object!

interface Extension {
    myNumberMethod(param: number): number;
    myStringMethod(param: string): string;
}
const ext: Extension = {
    myNumberMethod(param) {
        return param;
    },
    myStringMethod(param) {
        return param;
    }
}

Enter fullscreen mode Exit fullscreen mode

With the code above, we create our extension object and corresponding interface. Here, everything is pretty straight-forward. Now, it's time to glue it all together with the code below!

interface MyClass extends Extension {};

const instance = new MyClass();
instance.registerExtension(ext);

instance.myStringMethod("str");

Enter fullscreen mode Exit fullscreen mode

With the first line, we're creating the interface of the same name as our class. This activates the declaration merging process and merges our Extension interface with MyClass. Thus, the two methods of Extension interface are now part of MyClass. In the next two lines, we actually make that happen from the code-side, by registering our extension with the previously defined method. Finally, we have our auto-completion working and we put it to the test with the last line. Nice! πŸ‘

One last note about using this in such cases as above. You can easily make it happen by directly typing this parameter of the extension methods as MyClass. Then you just need to remember about binding your methods in the .registerExtension() method and boom! You've got your this ready to go! πŸŽ‰

Just think...

I really hope that my real-life use-cases above helped you at least a bit in improving your TypeScript thinking and programming process. Of course, I would be more than happy to hear your opinion about some TS-related problems, their respective solutions and this article as a whole in the comment section and from your reaction emoji 🀯 below. Also, if you have any TS problem you'd like some help with - hit me in the comments and we'll try to solve it together! 😁

As always, keep coding and stay strong with JS and TS! If you like this content, consider sharing it with others, following me on Twitter and on my Facebook page and checking out my personal blog!

Resources

Top comments (0)