Insights from deep-diving into TypeScript

This post summarizes some insights from my deep dive into TypeScript from when writing an appendix for my book. While I have been working with TypeScript for quite some time, most of the code I encountered was pretty trivial. The majority of the following aspects were new to me and helped me to understand the language better. Also, writing a lot of my book’s code again in TypeScript allowed me to identify potential downsides.

Class magic

TypeScript has a special support for the keyword. For every class within the global scope (of a module), it implicitly defines an instance type with the same name. This enables to write things like . Unfortunately, this mechanism does not work for dynamically created classes or plain constructors. In such cases, the behavior must be emulated with the utility and the keyword . Interestingly, and statements combine same-named values and types.

The following code illustrates this behavior:

class StaticClass {}
const a: StaticClass /*type*/ = new StaticClass(); /*constructor*/

const createClass = () => class {};
const DynamicClass = createClass(); /*no implicit type*/
// does not work yet: const b: DynamicClass = new DynamicClass();

type DynamicClass = InstanceType<typeof DynamicClass>; /*type*/
const b: DynamicClass /*type*/ = new DynamicClass(); /*constructor*/

export {StaticClass, DynamicClass};
/* exports both constructors and types */

The statement is logically equivalent to what TypeScript does automatically when encountering the keyword.

No type inference for members

For some implementations of an interface, the types of member attributes and member functions could be inferred. As example, when the interface defines the function , the implementation could just use the signature . TypeScript could infer that the function parameter is a string and the return value is . For different reasons, this is currently not supported. All member attributes and member functions must be typed explicitly, independent of interfaces or base classes.

The next example illustrates the potential repetition due to this circumstance:

interface Logger {
logInfo(message: String): void;
logWarning(message: String): void;
logError(message: String): void;
}

class ConsoleLogger implements Logger {
logInfo(message: String) { /* .. */ }
logWarning(message: String) { /* .. */ }
logError(message: String) { /* .. */ }
}

No partial type inference

TypeScript can infer the types for type parameters from their usage. For example, the function can be invoked without specifying the type parameter, such as . In this case, is inferred to be of type (which extends ). However, this does not work for multiple type parameters, where only some should be inferred. One possible workaround is to split a function into multiple, with one having all type parameters to infer.

The following code shows a generic function to create object factories with pre-filled data:

const createFactory1 = <R extends {}, P extends {}>(prefilled: P) =>
(required: R) => ({...required, ...prefilled});
// requires to specify second type parameter
const createAdmin1 =
createFactory1<{email: string}, {admin: true}>({admin: true});
const adminUser1 = createAdmin1({email: 'john@example.com'});

const createFactory2 = <R extends {}>() =>
<P extends {}>(prefilled: P) =>
(required: R) => ({...required, ...prefilled});
// first function specifies type parameter, second infers it
const createAdmin2 =
createFactory2<{email: string}>()({admin: true});
const adminUser2 = createAdmin2({email: 'jane@example.com'});

The function requires to specify both type parameters, even though the second one could be inferred. The operation eliminates this issue by splitting the function into two individual operations.

Discriminating Unions usage

Discriminating Unions are useful for working with heterogeneous sets of similar items, such as Domain Events. The mechanism allows to distinguish between multiple types using a discriminating field. Every item type uses a specific type for the field that makes it distinct. When processing an item with a union type, its type can be narrowed down based on the discriminating field. One downside of this mechanism is that it requires the code to be written in a specific way.

The next example compares a JavaScript implementation of an event handler to its TypeScript counterpart with Discriminating Unions:

// JavaScript
const handleEvent = ({type, data}) => { // early destructuring
if (type == 'UserRegistered')
console.log(`new user with username: ${data.username}`);
if (type == 'UserLoggedIn')
console.log(`user logged in from device: ${data.device}`);
};

// TypeScript
type UserRegisteredEvent =
{type: 'UserRegistered', data: {username: string}};
type UserLoggedInEvent =
{type: 'UserLoggedIn', data: {device: string}};
type UserEvent = UserRegisteredEvent | UserLoggedInEvent;

const handleEvent = (event: UserEvent) => {
if (event.type == 'UserRegistered')
console.log(`new user with username: ${event.data.username}`);
if (event.type == 'UserLoggedIn')
console.log(`user logged in from device: ${event.data.device}`);
};

When using TypeScript, a value with a Discriminating Union type must not be destructured before narrowing down its type.

Template Literal types

Template Literal types are essentially Template Literals on a type level. They can be used to create string literal types that are the result of evaluating a template literal. The article “Exploring Template Literal Types in TypeScript 4.1” by David Timms explains them in greater detail with advanced examples. One notable use case is the definition of message processing components, where individual message types are handled by specific operations.

The following example demonstrates this using the previous logger example:

type MessageType = 'Info' | 'Warning' | 'Error';

type Logger = {
[k in MessageType as `log${MessageType}`]: (msg: string) => void;
}

class ConsoleLogger implements Logger {
logInfo(msg: String) { /* .. */ }
logWarning(msg: String) { /* .. */ }
logError(msg: String) { /* .. */ }
}

The type definition iterates over the union type and defines one operation for each message type.

Don’t let TypeScript get into your way

TypeScript is a powerful statically typed language. Many times, it is referred to as a “superset of JavaScript”. However, for some functionalities, it forces to write code in a specific way. For one, Discriminating Unions influence how destructuring assignments can be used. Also, the lack of partial type inference can necessitate to split up one function into multiple ones. While the benefits of TypeScript likely outweigh its potential downsides, it is still important to be aware of them.

Discuss on Twitter

Originally published at https://www.alex-lawrence.com on March 3, 2021.

Full-Stack Developer, Technical Lead, Software Architect

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store