healqq
shyftplan TechBlog
Published in
6 min readJun 30, 2019

--

typescript types are easy

TypeScript type system is a very powerful tool that, If you use it to its full potential, will make your code better and with fewer potential errors. While getting used to types, I encountered concepts that were new to me as a JavaScript developer. In this article, I want to share these concepts, so you can have a better time typing your code.

Object types and dynamic keys

A lot of people at first have issues with object type (and its shortcut {}). What if we want to type this code:

const obj = {}
obj.someKey = 5;
const obj2: object = {};
obj.someKey = 5;
const obj3: Object = {};
obj.someKey = 5;
const obj4: {} = {};
obj.someKey = 5;
// Error: Property 'someKey' does not exist on type '{}'.

TypeScript defines {} as object with no properties, so trying to access property someKey throws an error. This can be solved if we type object correctly:

// notice ? as we're assigning an empty object
const obj: { someKey?: number } = {}
obj.someKey = 5;

But what if we want to fill object dynamically? Something like this:

const obj = {};
const otherObj = { a: 1, b: 2, c: 3};
Object.keys(otherObj).forEach(key =>
obj[key] = otherObj[key]);

If we know all the keys of otherObj we can easily type it, but if not, we can use special notation to define an index type that allow us to type dynamic property names:

type Dictionary = {
[key: string]: any
}
const dictionary: Dictionary = {};
Object.keys(otherObj).forEach(key =>
dictionary[key] = otherObj[key]);
// no error even if key doesn't exist
console.log(dictionary.f);

This will please our compiler and he will show us no errors. What we can also do is restrict what keys can that object have:

type PredefinedKeysDictionary = {
[key in 'key1' | 'key2']?: any
}
const predefinedKeysDictionary: PredefinedKeysDictionary = {};
// typehints
predefinedKeysDictionary.key1
predefinedKeysDictionary.key2

We can also use keys of interface or object type using keyof (also called index type query operator) and keys of enum using keyof typeof:

interface SomeInterface {
id: number;
name: string;
}
type DictionaryFromInterface = {
[key in keyof SomeInterface]?: SomeInterface[key];
}
const dictionaryFromInterface: DictionaryFromInterface = {};
// typehints
dictionaryFromInterface.id
dictionaryFromInterface.name
enum SomeEnum {
A,
B,
C
}
type DictionaryFromEnum = {
[key in keyof typeof SomeEnum]?: string
}
const dictionaryFromEnum: DictionaryFromEnum = {};
dictionaryFromEnum.A
dictionaryFromEnum.B
dictionaryFromEnum.C

Interface vs Type

In older TypeScript versions (prior to 2.x) there were some substantial differences between Interface and Type, but most in current version of them were eliminated. So now it’s mostly personal preference. One important distinction is how name collisions are resolved: for interfaces, declarations will be merged; for types, name collisions will cause an error. But it shouldn’t concern you too much, because in ES6 every file is considered a module, so you still can have same names in different files.

interface SomeInterface {
id: number;
}
interface SomeInterface {
name: string;
}
// works correctly
const foo: SomeInterface = { id: 1, name: 'Nick' };
type SomeType = {
id: number;
}
// Duplicate identifier 'SomeType'.ts(2300)
type SomeType = {
name: string;
}

Window object

Sometimes you need to declare some variables on global scope (Window object in browser or global variable in node.js environment). You can easily do that thanks to the fact that interfaces are extendable.

declare global {
interface Window {
someVariable: number,
}
}

Generics(Template types)

In TypeScript types, interfaces and functions can have type variables:

class Collection<T> {
private items: T[];

add(item: T) {
this.items = [...this.items, item];
}
get(): T[] {
return [...this.items];
}
}

Here T is a type parameter, and now we can pass some type as that parameter to get a Collection object of that type:

const numbersCollection = new Collection<number>();
numbersCollection.add(1);
//returns [1]
numbersCollection.get();
// Argument of type '"1"' is not assignable to parameter of type 'number'.
numbersCollection.add('1')

We actually get this code when we write Collection<number>:

class Collection<number> {
private items: number[];

add(item: number) {
this.items = [...this.items, item];
}
get(): number[] {
return [...this.items];
}
}

This is a very important feature, it allows us to work with polymorphic objects very efficiently. Let’s check next example:

type Shift = {
id: number;
}
type Absence = {
employmentId: number;
}

interface EventPart<T> {
event: T;
// some general property for all EventPart objects
id: number;
}
class ShiftPart implements EventPart<Shift> {
id: number;
event: Shift;
}
class AbsencePart implements EventPart<Absence> {
id: number;
event: Absence;
}
class TimeGridEvent<T extends EventPart<any>> {
private eventPart: T;
get event(): T['event'] {
return this.eventPart.event;
}
}
const shift = new TimeGridEvent<ShiftPart>();
const absence = new TimeGridEvent<AbsencePart>();
// typehints
shift.event.id
absence.event.employmentId

Here we can create a TimeGridEvent with different base event. If we access property .event, it will be correctly typed (you will also get typehints in IDE).

Useful standard generics

There are also standard generics provided by TypeScript. I found the most useful these three:

  • Partial<T> — define all properties of passed type T as optional
type Partial<T> = {
[P in keyof T]?: T[P];
}
type Person = {
id: number;
name: string;
}
// valid
const partialPerson: Partial<Person> = { id: 1 };
  • Record<K, T> — define object with given keys K and type T
type Record<K extends keyof any, T> = {     
[P in K]: T;
}
const dictionary: Record<string, number> = {
'a': 1,
'b': 2,
};
  • Pick<T, K> — define type that will pick keys K from type T
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
}
const allowedKeys: Pick<Person, 'id'> = { id: 1 };

Advanced types

In this section we will read and compose types that are harder to understand. Let’s try to build an opposite for Pick: type that will allow us to exclude properties from original type. We will start with declaring a helper function first.

type Diff<T extends string | number | symbol, U extends string> = (
{ [P in T]: P }
& { [P in U]: never }
& { [x: string]: never }
& { [x: number]: never }
)[T];

So what is going on here ? Let’s declare part of original type as a new type. So we can understand it better:

type Keys<T extends string> = { [K in T]: K };
const helper: Helper<'a' | 'b' | 'c'> = { a: 'a', b: 'b', c: 'c' };

This construction just generates an object with same key-value pairs. Now let’s look at Diff again. Union type produces an object with all keys that are in T, then adds all keys from U with type never and then for all keys that were not mentioned in T or U adds never type as well. Last statement is needed in case our original type had dynamic property names. So when we execute this part with:

T = 'a' | 'b' | 'c'
U = 'c' | 'd'

we will get

{
a: 'a',
b: 'b',
c: 'c', // will be overriden by next line
c: never,
d: never,
[x: string]: never,
[x: number]: never,
}

Last part of our original Diff type is taking index from this object:

{
a: 'a',
b: 'b',
c: never,
d: never,
[x: string]: never,
}['a' | 'b' | 'c']

And that basically equals to this:

{
a: 'a',
b: 'b',
c: never,
d: never,
[x: string]: never,
[x: number]: never,
}['a']
| {
a: 'a',
b: 'b',
c: never,
d: never,
[x: string]: never,
[x: number]: never,
}['b']
| {
a: 'a',
b: 'b',
c: never,
d: never,
[x: string]: never,
[x: number]: never,
}['c']
= 'a' | 'b'

So we managed to write Diff<T, U> type that returns all keys that are present in T but not in U. After that, it is pretty obvious how to write type Omit<T, U extends keyof T>. We just need to get all keys of T and then exclude all keys that are present in U.

type Omit<T, U extends string> = Pick<T, Diff<keyof T, U>>
type Omit2<T, U extends string> = Pick<T, Exclude<keyof T, U>>
type SomeIndexType = {
'1a': string,
'2a': number,
[key: number]: string
}
type DiffType = Omit<SomeIndexType, '1a'>;
type ExcludeType = Omit2<SomeIndexType, '1a'>;

The difference between Omit and Omit2 is that we use standard type Exclude, that leaves all index types in place, so ExcludeType will still have [key: number]: string type.

Omit is not a part of TypeScript standard right now, but Exclude is, so you can easily define Omit type for yourself when you need it. Or you can grab Diff type if you need to exclude index types as well. Also, Omit is provided by a lot of libraries, for example, Redux, so you might already have it.

Conclusion

Types in TypeScript are very powerful, and it is worth to invest some time into understanding how to write them, as it’ll make your life as a developer a lot easier. Getting better autosuggestions and static code analysis will save you a lot of time in the future, you just need to get used to TypeScript.

--

--

healqq
shyftplan TechBlog

Frontend developer at shyftplan. Data science enthusiast