Advanced Typescript Techniques: State Tracking as an Example

Dmitry Tikhonov
ITNEXT
Published in
8 min readApr 30, 2023

--

Without a doubt, Typescript provides a wide range of possibilities for describing object types. In fact, the range is so extensive that even experienced developers may find it challenging to comprehend what the possibilities are for. For a long time, I didn’t understand why Typescript has such a complex type system until I encountered a practical task where Typescript’s capabilities proved to be extremely useful.

I am developing a state management library that create an immutable snapshot of the properties of some class when at least one of its properties has been changed. This snapshot is compared with the previous one, and if the library detects that a property associated with some modifier (essentially a static function) has changed, then that modifier is called with the current snapshot as an argument. The result of this call applies to the properties of the class. Here is an example of how it can look like:

class Component {
constructor(
readonly $userId: Observable<number>,
readonly userService: UserService) {

initializeImmediateStateTracking<Component>(this);
}

firstName: string;

lastName: string;

readonly greetings = new Subject<string>();

@With<Component>('firstName', 'lastName')
static greet(state: ComponentState<Component>): StateDiff<Component> {
return {
greetings: `Hi, ${state.firstName} ${state.lastName}`
};
}

@WithAsync<Component>('$userId')
static async loadUser(getState: AsyncContext<Component>)
: Promise<StateDiff<Component>> {

const state: ComponentState<Component> = getState();

if(state.$userId) {
const userData = await state.userService.getById(state.$userId);

return {
firstName: userData.firstName,
lastName: userData.lastName
};
}
return null;
}
}

The most interesting part (from the point of view of the topic) is the types ComponentState<T> and StateDiff<T> which transform the original class T in the following way:

ComponentState<T>
• All methods are filtered out.
• All subjects and observables are unwrapped.
• All remaining properties are marked as read-only.
so that

type ComponentState<Component> = {
readonly $userId: number;
readonly userService: UserService;
readonly firstName: string;
readonly lastName: string;
readonly greetings: string;
}

StateDiff<T>
• All methods are filtered out.
• All observables are filtered out.
• All subjects are unwrapped.
• All remaining properties are marked as optional.
so that

type StateDiff<Component> = {
userService? UserService;
firstName? string;
lastName? string;
greetings? string;
}

Note: Typescript does not perform the transformation it just reflects that the library does

Next, I will show how such type conversions can be performed.

First of all, I want to share my remark that Typescript actually consists of two languages: the first is a well-known JavaScript extension that adds typing capabilities, the second is a type conversion language with its own rules and constructions that do not affect the JavaScript runtime in any way. In what follows, only this second “language” will be discussed.

Typescript provides a list of type conversion (or type manipulation) features which are well described in its documentation. Here I will just briefly overview the most important of them:

keyof T

This operator generates a union of literal types where each type corresponds to a property key of the type.

type T = keyof { p1: string, p2: number, p3: ()->void } 
//equals
type T = 'p1' | 'p2'| 'p3'

Type Indexing

If we take a look at a typical type definition, it can resemble a JSON initialization of a JavaScript object where the value of a property is its type:

type User = {
userId: number;
firstName: string;
lastName: string;
dob: Date;
}

Typescript takes advantage of this similarity and enables you to obtain the type of a property by indexing it as if it were a regular JavaScript object:

type T1 = User['userId']
//equals
type T1 = 'number'

type T2 = User['firstName']
//equals
type T2 = 'string'

type T3 = User['dob']
//equals
type T3 = Date

However, this type indexing has more capabilities than the JavaScript one, allowing you to index multiple items simultaneously and receive a union of types:

type T = User['userId'|'firstName'|'lastName'|'dob']
//equals
type T = User[keyof User]

//equals
type T = number|string|Date

Conditional types

Typescript’s data manipulation language includes a ternary operator that can be used in the following way:

TypeA extends TypeB ? TypeExpression1 : TypeExpression2

The TypeA extends TypeB operator can be thought of as a boolean expression that verifies whether the following statements are true: TypeA is equivalent to TypeB, or TypeB is assignable to TypeA, since TypeB is a subset of TypeA.

TypeExpression can be an exact type or any other type manipulation expression, such as another conditional type.

Example:

type TypeA = {
typeAid: string;
}

type TypeB = {
typeBid: number;
}

function getId<T extends TypeA|TypeB>(obj: T): T extends TypeA
? T['typeAid']
: T extends TypeB
? T['typeBid']
: never {

return ((obj as TypeA).typeAid ?? (obj as TypeB).typeBid) as any;
}

const idS: string = getId({typeAid: 'id5'});
const idN: number = getId({typeBid: 5});

The function’s resulting type depends on an object that is passed as an argument.

Mapped Types

The type mapping expression is the most powerful feature of the type manipulation language. It allows you to generate almost any type using a union of types as an input argument. In pseudo language, this expression can be represented as a pure function like this:

TOutput = <mapping expression>(<some union of types>, … other types or generic parameters)

or more precisely

TOutput = {
<+-readonly> fk(x1, ...other types)<+-?>: ft(x1, ...other types)
<+-readonly> fk(x2, ...other types)<+-?>: ft(x2, ...other types)
...
<+-readonly> fk(xN, ...other types)<+-?>: ft(xN, ...other types)
}

where

  • x — is an item of the union of types
  • fk — is an expression that returns a type property key that should be on the following types: symbol, string, number or never * (in this case the output type will not contain a property for the item of the union of types)
  • ft — is an expression that returns a type property key

The type mapping expression has the following syntax in Typescript:

{  
<optional key readonly modifier>
[<type tag> in <union of types> as <property key expression>]
<key optionality modifier>: <property type expression>
}

where

  • <union of types> is any expression that returns a type union e.g.:
'p1'|'p2'|'p3'
//or
keyof T
//or
Dog|Cat
  • <type tag> is an identifier that represent an item in the union of types (it corresponds to “x” in the pseudo language definition)
  • <property key expression> is an expression that generates a property name from an item of the input union of types represented by the type tag (it corresponds to “fk” function in the pseudo language definition). In this expression you can use conditional types and string interpolation (see the examples below). It is optional unless an item of the input union is not string literal type, in this case the expression must return some string literal type.
  • <property type expression> is an expression that generates a property type from an item of the input union of types represented by the type tag (it corresponds to “fk” function in the pseudo language definition). In this expression you can use any Typescript expression that returns a type (e.g. generics, exact types, conditional types etc.)
  • <key readonly modifier> can be +readonly or -readonly. It ads or removes readonly modifier for a property key
  • <key optionality modifier> can be +? or -? . It ads or removes “?” modifier for a property key.

Example #1

type T = { [K in 'p1'|'p2'|'p3' ]: number }

//equals

type T = {
p1: number;
p2: number;
p3: number;
}

where

  • <union of types> is ‘p1’|’p2'|’p3'
  • <type tag> is K
  • <property key expression> is omitted since type tag K already represents the required string literal type.
  • <property type expression> is number

Example #2

type T = { [K in keyof User as `set${Capitalize<K>}` ]: (value: User[K]) => void }

//equals

type T2 = {
setUserId: (value: number) => void;
setUserFirstName: (value: string) => void;
setUserLastName: (value: string) => void;
setUserBob: (value: string) => void;
}

where

  • <union of types> is keyof User which is the same as ‘userId’|‘fistName’|‘lastName’|‘dob’
  • <type tag> is K
  • <property key expression> is `set${Capitalize<K>}`, which is an example of using string interpolation and intrinsic string manipulation functions to generate property keys of the resulting type.
  • <property type expression> is (value: User[K]) => void, which is an example of type indexing.

Example #3

type WithTypeTag = {
$type: string;
}

type Cat = {
$type: 'cat';
}

type Dog = {
$type: 'dog';
}

type Visitor<T> = {
[K in T as K extends WithTypeTag ? `visit${Capitalize<K['$type']>}` : never]
: (value: K) => void
}

type T = Visitor<Cat|Dog|User>;

//equals

type T = {
visitCat: (value: Cat) => void;
visitDog: (value: Dog) => void;
}
  • <union of types> is Cat|Dog|User (through the generic parameter). Unlike the previous examples, this union is comprise of regular types, but not string literals.
  • <type tag> is still K which is one of Cat|Dog|User.
  • <property key expression> is K extends WithTypeTag ? `visit${Capitalize<K[‘$type’]>}` : never. It checks if a type has the type tag property. If the property is found then its type is used to generate a visitor method name, otherwise nothing will be emitted into the resulting type (the keyword is never).
  • <property type expression> is (value: K) => void. Since the union is comprise of regular types then it is possible to use the tag as it is (the type indexing is not required here).

Solutions

Now that we have a basic understanding of type manipulation in Typescript, we can use this knowledge to solve the tasks that were introduced at the beginning of this post:

ComponentState<T>
• All methods are filtered out:

type WithoutFunctions<T> = 
{[K in keyof T as T[K] extends Function ? never : K]: T[K]}

• All subjects and observables are unwrapped:

type Unwrap<T> = {
[K in keyof T]: T[K] extends Observable<infer TO>
? TO
: (T[K] extends Subject<infer TS> ? TS : T[K])
};

• All remaining properties are marked as read-only:

type Readonly<T> = {
+readonly [K in keyof T]: T[K]
};

All together:

type ComponentState<T> = {
+readonly [K in keyof T as T[K] extends Function ? never : K]
: T[K] extends Observable<infer TO>
? TO
: T[K] extends Subject<infer TS> ? TS : T[K]
};

type T = ComponentState<Component>;

//equals

type T = {
readonly $userId: number;
readonly userService: UserService;
readonly firstName: string;
readonly lastName: string;
readonly greetings: string;
}

Similar for StateDiff<T>:

type StateDiff<T> = {
-readonly [
K in keyof T as
T[K] extends Function ? never
: T[K] extends Observable<any> ? never: K
]+?: T[K] extends Subject<infer TS> ? TS : T[K]
};

type T = StateDiff<Component>;

//equals

type T = {
userService?: UserService;
firstName?: string;
lastName?: string;
greetings?: string;
}

While Typescript’s type manipulation language can be challenging to understand, it can be very useful in certain situations. For example, if you’re creating a library that other developers will use and may not be familiar with your codebase, TypeScript can help ensure that your code is more robust and less error-prone. It’s important to note that the type manipulation language does not affect JavaScript output and is only used during the compilation process.

Links:

ng-set-stateis the state management library described in this post

--

--