This post is taken from my blog, so be sure to check it out for more up-to-date content π
Here, we are continuing the grand TypeScript introduction. If you haven't already, be sure to check out part I and II for getting started with TS and knowing what's going on in this tutorial. π In this article, we're going to finally explore generics, some complex types and declaration files. After this part of the series, you should most likely have enough knowledge about TS to write really complex stuff. So, yup, enjoy! π
Generics
Let's start with something big! Generics, because we'll be talking about them, are really important in TypeScript and some other statically-typed languages that include them. But, what are they exactly?
It can be safely assumed that the word generics has been created from the word general, which in this context means something same. Take a look at the function below.
function myFunction(arg: any): any {
return arg;
}
Our function takes an argument of any type and simply returns it (I know, not really useful π
). As we already know, any type isn't very type-safe. It also doesn't indicate, that the return type is the same as argument type (although that can be read from the code, but not to the compiler). We would like to indicate that these types are exactly the same. No unions, aliases and other stuff - strictly the same! That's where the generics come into play.
function myGenericFunction<T>(arg: T): T {
return arg;
}
Well, here's our generic function... and a bit of new syntax too. π With the use of angle brackets (<>
), just before type arguments declaration section, we declare a T
type (T is the most commonly used name for generic types, generally single letters are preferred over long names). Then we indicate that the argument and return type are the same, but using this T
type. And that's really generic π because the same variable type is used in multiple places.
But what's the T
type? Is it string
, number
, etc.? Well, it can be any of those. There are two ways of invoking a generic function.
myGenericFunction<string>('str');
The first method requires you to directly specify the real type in place of T
type. Here we're using string
. We indicate this with the similar angle bracket syntax (it's used very often throughout generics). This way, the type of required argument changes to string
, as well as the return type. This is clearly a better and more type-safe solution than any
or even union types.
myGenericFunction(10);
The second, more commonly used method takes advantage of TS type inference and more specific argument type inference. This is where generics clearly shine. Our T
type, inferred from our 10
argument, takes the type of number
. This choice can be later seen in all places, where T
type was used.
At this point, you should have a pretty good understanding of what generics are. But, with the example above, I know that you can have doubts about their usefulness. Here, take my word as granted - you'll need to use generics sooner or later (if you'll code in TS obviously π) and then you'll discover their potential. This is especially true when combined with some complex types, which we'll learn more about later on or type guards which allow you to utilize generics to much greater extent.
Also, remember about positioning the generic type in functions. It should always be before round brackets (()
) aka arguments section. The same goes for arrow functions. Even more general answer would be to set them in place where you can later safely put angle brackets when invoking. You'll most likely get used to it.
Generic world
So, yeah, there are generic functions, but did you know that generics are spread all over TS type system? You can use them pretty much everywhere they fit. Most importantly classes and interfaces.
class MyGenericClass<T, U> {
myProperty: T;
myProperty2: U;
constructor(arg: T) {
this.myProperty = arg;
}
}
As you can see, classes work really well with generics. Just like in functions, generic type is available anywhere in declared context. Did I mention that you can declare more than 1 generic types? It applies to all places where generics can be used. Simply separate your generic types' names with a comma (,
), and you're good to go.
interface MyGenericInterface<T> {
myProperty: T;
myProperty2: T[];
}
Above is the example of using generics with interfaces. It's looking just like with classes. Notice that the second property is an array of T type. I just wanted to yet again demonstrate how well all TS type system components work together.
As classes and interfaces are different from functions, you cannot use argument type inference to call them. You're left with the first method - passing the specific types directly. Otherwise, T will be equal to an empty object literal.
interface MyGenericInterface<T> {
myProperty: T
}
class MyGenericClass <U> {
myProperty: MyGenericInterface<U>;
constructor(arg: U) {
this.myProperty = {
myProperty: arg
}
}
}
This example also showcases how you can nest and make even better use of generics. Notice how we pass class generic type U
to MyGenericInterface
in myProperty
.
Another array
To finalize the generics section, there's yet one more thing. Remember how we used a special syntax to specify array type e.g. string[]
. Well, there's yet another method of doing the same thing. You can use built-in generic Array interface and easily achieve the same result with Array<string>
. It's a very common practice. You can see it throughout the official TS standard library (typings/declaration files for all JS features, Web APIs & more) and also in other popular declaration files (we'll cover them later), such as React's.
Complex types
With generics, a whole new level of possibilities open for you. Now we can explore types that when combined with generic give you much finer control. With them, you can express pretty interesting structures. Nevertheless, it's time to discover them too! π
Extended types
You already know the extends
keyword that can be used with classes and interfaces. But in TypeScript, it also has its use-case with generics. Here, you can use it to limit/specify the typethat generic type should extend from. Let me explain this with an example.
function myGenericFunction<T extends string>(arg: T): T {
return arg;
}
Here we directly specify that our generic type should extend string type. Naturally, it would most likely mean that it should be just string
. But, when you specify the type as some kind of class, its derivatives will be assignable too. Generally, it allows you to better specify your generic type and what properties it should have, just like extends
with classes and interfaces.
Conditional types
Conditional types are quite new to TS type system. Introduced in TypeScript v2.8, they let you choose the right type, based on a conditional check. Checks can be performed with well-known to us extends
keyword and simple syntax:
type MyType<T> = T extends string ? boolean : number;
Above we have type alias (also can be generic) with a conditional type assigned to it. We check if our generic T type extends string type. If it does, we resolve to boolean, and number otherwise. Naturally, you can use this technique with other types, as well as nest multiple if statements (they're types anyway π).
Index types
Index signature
We already covered what to do when you want to declare a property in a class, interface or object literal. But what about a situation, where you want to create an object of an unspecified number of keys, each of which having the same type? Naturally, TS has a solution for that! π―
interface MyInterface {
[key: string]: number;
}
This feature is called index signature and can be used in interfaces, classes and object literals. The syntax consists of square brackets ([]
), with a general name for property key and its type inside (generally string, optionally number). After that comes the type of property value. You can read it as every property (named key of type string in this example) should have a value of type number.
Remember that TS types can be mixed together, so you can freely use index signature with tricks like an optional indicator or default value. Also, when creating a structure that besides index signature has other properties, keep in mind that they have to be assignable to the declared signature too!
Keyof
Let's say you've got an object, interface or whatever, and want to create a function that takes your object's property name as an argument and returns its value. Naturally, you could just declare argument type as a string, but you wouldn't get as much IDE support as you would with a union of string literals. And that's where the keyof
operator comes in.
const myObject = {
a: 1,
b: 2,
c: 3
}
function getProperty<T extends keyof (typeof myObject)>(propertyName: T): (typeof myObject)[T] {
return myObject[propertyName];
}
Here we've got some complex typing! Take a moment and analyze it yourself. It basically let us specifically type the argument as a union type 'a'|'b'|'c'
with the addition of truly specific return type declaration.
Indexed access
In the previous example, you should have seen the return type using what seems similar to JS square bracket notation for accessing object properties. And that's pretty much exactly what we do here, but with types!
interface MyInterface {
myStringProperty: string
}
type MyString = MyInterface['myStringProperty'];
Here we're accessing the myStringProperty
of MyInterface
and assigning it to MyString
type alias, which in the result is equal to string. Understandable, right? π
Mapped types
Mapped types as their name suggest allow to map/transform your types into different forms. With them, you can process given type and change it in any possible way you want.
type Readonly<T> = {
readonly [P in keyof T]: T[P];
}
Here we have a practical example. Our generic Readonly
type takes T
type and transforms it, so every property is now read-only. The syntax resembles the one of index signature, but with a slight difference. Instead of standard property name and it's type pair, we've got an in
keyword. This allows us to iterate (a reference to for... in loop) over the union of type T
keys, defining P
type (string literal). Generally speaking, we iterate over T type properties and change them to create a new type. Just like the .map()
method of JS array. π
Declaration files
TypeScript being a superset of JavaScript can easily benefit from JS's great ecosystem and set of libraries. But type inference can't help with everything. In this case, any type is used, which results in inferior type-safety. To deal with this problem TS provides an option to create so-called declaration files (aka typings). Usually ending with .d.ts extension, these files provide information to the TS compiler about types in JS code. This allows using JS libraries in TS with high-quality type safety.
A great number of popular JS libraries already provide their own typings either bundled within the NPM package or separately as a part of DefinitelyTyped repository. But, if there's no declaration files for your library-of-choice you can quickly create your own based on the documentation and other resources about the particular tool.
Creating your own typings is not that much harder than writing TS code, just without the JS part, meaning types only. Also, you'd have to often use the declare
keyword before functions and variables to declare them. Official TS documentation provides a great read on this topic, so check it out if you're interested.
Declaration merging
Declaration merging is an important concept in TypeScript that lets you merge multiple declarations of the given structure into one. Here's an example of merging 2 same interface declarations.
interface MyInterface {
myStringProperty: string;
}
interface MyInterface {
myNumberProperty: number;
}
Resulting interface under the name of MyInterface
will have both, separately declared properties. The same practice can be used with some other TS structures like classes (partially), enums and namespaces.
Module augmentation
In cases where you need to augment/change given value across multiple JS modules, to provide the sufficient type-safety you need to use module augmentation. You can achieve it by using the declare module
keywords pair.
import MyClass from './classes';
declare module './classes` {
interface MyClass {
myBooleanProperty: boolean;
}
}
MyClass.prototype.myBooleanProperty = true;
That's it?
With this article, we covered pretty much everything needed to create professional TypeScript code. There still are some more features like namespaces and mixins, but coding for almost 2 years, I don't really find them so needed or even useful for that matter.
With that said, I think it's the end of this TypeScript introduction. Naturally, be sure to read the first two parts if you want. Maybe you'd like to see some more TS stuff on this blog? Maybe something like a full overview of the TS configuration file or a tutorial on how to utilize the knowledge learned in this series? Let me know in the comments or with your reaction below. π
As always, follow me on Twitter and on my Facebook page for more content. Also, consider consider checking out my personal blog. π
Resources
- TypeScript - A Tour of Generics from "dotnetcurry.com";
- Migrating to Typescript: Write a declaration file for a third-party NPM module from "medium.com";
- How to master advanced TypeScript patterns from "medium.freecodecamp.org";
Top comments (0)