Practical TypeScript in 2024

Kishan Nirghin
6 min readSep 20, 2023
TypeScript as a city.

This guide will cover the most practical and relevant TypeScript techniques anno 2024¹. I’ll demonstrate various tips, tricks and coding-practices that have proven useful during modern development.

The TypeScript docs

I’d recommend reading the docs at least once for everything you’re working with. If you need some convincing checkout my other post on why you should read docs.

Beyond the docs the interactive playground is a great way to debug, share, and get feedback quickly when trying out complex TypeScript constructs.

You can use shorthand initialisers for classes

When using access modifiers in the constructor TypeScript will automatically set them as part of the resulting Object.

class Example { 
name: string;

constructor(name: string) {
this.name = name;
};
}

// Identical - and much shorter.
class Example {
constructor(public name: string) {};
}

// name is only accessible from within the class
class Example {
constructor(private name: string) {};
}

Use strict types

As-strict-as-possible, when using generic objects that serve as a key-value store do not use the general Object type. Rather use Record<KeyType, ValueType> to have a more constraint object.

// BAD
const hashMap: Object= {};

// GOOD
const hashMap: Record<string, string> = {};

Imagine having a storage object. Objects are Record<string, string | number | boolean>

type PersonData = {
name: string,
age: number,
single: boolean
}

class Person {
constructor(public data: PersonData);

// TypeError
update(key: string, value: string | number | boolean) {
this.data[key] = value;
}

// TypeError
update(key: keyof PersonData, value: PersonData[keyof PersonData]) {
this.data[key] = value;
}

// BAD
update(key: string, value: string | number | boolean) {
this.data[key] = value as any;
}

// GOOD
update<K extends keyof PersonData>(key: K, value: PersonData[K]) {
this.data[key] = value;
}
}

The stricter the type definition, the more safety it will add during development. If the compiler is throwing unexpected type errors there probably are edge-cases that you as a dev are not considering.

Even stricter types

Adding even more strict-type checking e.g. when dealing with sensitive data is to use BrandTypes. Branding types is a pattern where you create more specific types to identify data throughout your code. A common typescript pattern to use Branded types is to create a helper type:

// Generic helper Brand type
type Brand<K, T> = K & { __brand: T }

// New specific data type
type Password = Brand<string, "password">

// Type cast it to the branded type
const password = "something" as Password;

function authUser(username: string, password: Password) {
// Do auth.
}

// WILL NOT WORK - "nirghin" is not of type Password
authUser("kishan", "nirghin")

// Satisfies the TypeScript compiler
authUser("kishan", password)

In the example above we can only use our password variable for methods that explicitly require a Password type parameter. Using a branded type will make it harder to send data to places it shouldn’t go.

Branded types are a form of very strict type checking, whereas it adds extra benefit, it comes with extra overhead. Therefore opting-in to using branded types should be a careful evaluation.

More on branded types here: https://egghead.io/blog/using-branded-types-in-typescript.

Satisfying the compiler: common patterns

Like the example above, then updating the value of a property in an object there’s a specific type defintion to add for TypeScript to understand what you’re trying to do.

type PersonData = {
name: string,
age: number,
single: boolean
}

class Person {
constructor(public data: PersonData);

// Syntax for letting TypeScript know that you're updating a <key, value>
// pair of the Record.
update<K extends keyof PersonData>(key: K, value: ParsonData[K]) {
this.data[key] = value;
}

// Syntax for overwriting a base type with a Partial of that type
extend(data: Partial<PersonData>) {
Object.assign(this.data, data);
}
// Syntax for overwriting a base type with a Partial of that type
extend2(data: Partial<PersonData>) {
this.data = [...this.data, ...data];
}

// Syntax for overwriting a base type using a loop
extend3(data: Partial<PersonData>) {
for (const [_key, value] of Object.entries(data)) {
// This cast is necessary for TS to not see _key as a string
const key = _key as keyof PersonType;

if (value !== undefined) {
// this.data[key] = value; // Cannot do this without type casting
update(key, value);
}
}
}
}

Write efficient code

Always be mindful of the code you’re writing. Use good patterns and data-structures that serve the need of your software. I’ll briefly show some examples of patterns to avoid and patterns to adapt.

Choose the right storage variables: Array or Object?

When choosing between an Array and an Object for a data structure to keep data in, think about the data that goes into it.

An array has the advantage that it preserves ordering of the entries and an Object has the advantage that it offers a lookup time of O(1) (versus O(n) for arrays).

A (very simplified) rule of thumb would be: do you need to preserve the order? use an Array. Do you want to have quick lookups? use an Object.

// Use arrays IF there has to be an order for the data
const users = [{
id: "hr239r3j2f",
name: "kwik"
}, {
id: "j4gs04gj44",
name: "kwek"
}, {
id: "sdfj32f933",
name: "kwak"
}]

// If there is no ordering required, use Objects instead
const users = {
hr239r3j2f: {
name: "kwik"
},
j4gs04gj44: {
name: "kwek",
},
sdfj32f933: {
name: "kwak"
}
}

Use hash-maps to speed up your code

When having to loop through an array multiple times it often proves worthy to create an intermediary temporary hash-map first.

type Vehicle = { userId: string, licencePlate: string }
type User = { id: string, name: string }

type UserWithLicencePlates = User & { licencePlates: string[] }

// BAD - performance is poor
function getUsersWithLicencePlates() {
const usersWithLicencePlates: UserWithLicencePlates = [];
const users: User[] = listUsers();
const vehicles: Vehicle[] = listVehicles();

for (const user of users) {
// The performance of this next call is poor, since we're looping over
// the vehicles array for every user.
// Will become problematic when the vehicles array becomes huge
const licencePlates = vehicles.filter(v => v.userId === user.id)
.map(v => v.licencePlate);

usersWithLicencePlates.push({...user, licencePlates });
}

return usersWithLicencePlates;
}

// GOOD - better performance
function getUsersWithLicencePlates() {
const usersWithLicencePlates: UserWithLicencePlates = [];
const users: User[] = listUsers();
const vehicles: Vehicle[] = listVehicles();

// In a single pass, index ALL licencePlates per userId gaining
// performance since we're looping exactly once through the vehicles array
const licencePlatesPerUserId: Record<string, string[]> = {}
for (const vehicle of vehicles) {
if (vehicle.userId in vehiclesPerUserId) {
vehiclesPerUserId[vehicle.userId].push(vehicle.licencePlate);
} else {
vehiclesPerUserId[vehicle.userId] = [vehicle.licencePlate];
}
}

for (const user of users) {
const licencePlates = licencePlatesPerUserId[user.id] ?? [];
usersWithLicencePlates.push({...user, licencePlates });
}

return usersWithLicencePlates;
}

Ofcourse there are other ways to solve the issue as demonstrated above — the example is solely to demonstrate a use-case of using temporary hash-maps to speed up code execution.

Be mindful of the event-loop

As you might’ve heard by now, JavaScript execution doesn’t do a lot of things in parallel. The majority of your code will run in a single thread. If you’re writing a slow synchronous function — it will fully block the rest of your code until the execution is finished.

In the language of JS we say that functions cannot be pre-empted: their execution cannot be paused/halted somewhere in the middle. Your program will only become responsive again once the function is finished.

function slowMethod() {
// Do some slow stuff
}

setInterval(() => {
console.log("second ticker");
}, 1000);

slowMethod();
console.log("done with slowMethod");

As much as you’d want the second ticker to print out a value every second, it won’t do it if the slowMethod is taking up more than 1000ms of execution time. As long as this method is being executed, no other function will be called.

Therefore prevent using synchronous methods where possible and use their async variant instead.

async function slowMethod() {
// Do some slow async stuff
}

setInterval(() => {
console.log("second ticker");
}, 1000);

slowMethod().then(() => {
console.log("done with slowMethod");
});

Conclusion: what to take away from this post?

The patterns and examples shown above hopefully fill become part of your toolbox as a TypeScript engineer. For each of these patterns only adapt them if they make sense for the code-base you’re working with. Adding stricter types makes your code safer to play around with, but adds more overhead for development.

In the end everything is a trade-off and finding the right balance has to be considered on a per case basis. Unfortunately there is no golden hammer that you can just use to bang away at any problem.

[1] By the end of 2023 we’ve seen a movement against TypeScript; whereas some projects have had good reasons for stepping away from TS, it still is a valuable tool for a lot of projects.

--

--