6 advanced TypeScript tricks

6 advanced TypeScript tricks

Published at 8 June 2025

TypeScript is sometimes ridiculed for being too strict, noisy, and overall slowing down development. I believe this sentiment comes mostly from a lack of experience. If you’re not used to typing your code, it can be tedious to please the TypeScript compiler. But besides making your code less prone to errors (which on its own is a huge benefit), TS has great potential to make development easier and improve your developer experience. Today we’ll go over 6 loosely related TypeScript concepts and tricks that can improve your codebase.

Today we’ll talk a lot about type-level programming. While in “normal” programming we operate on data directly, in type-level programming we operate on types of this data. This type-level program is executed during compilation (or type check). TypeScript’s type system is Turing-complete which means you can do all sorts of crazy things with it. In my article about writing typesafe router I already mentioned chess engine, adventure game, and SQL database. But here are a couple of more recent examples: Japanese grammar expressed as TS types, and, finally, DOOM (it needs 12 days to generate one frame, but still).

Today we won’t be porting any games to the TS type system, but if it can play DOOM, we sure can squeeze something useful for our codebase too.

Table of contents

Conditional types

Let’s start with a simple, but still very useful one: conditional types. By using the extends keyword you can make your generic type conditional.

type StringifyType<T> = T extends string 
  ? 'string' 
  : T extends number
    ? 'number'
    : 'other'
 
type X = StringifyType<42>
//   ^? type X = "number"
type Y = StringifyType<"hello!">
//   ^? type Y = "string"

Try in TypeScript playground

The biggest disadvantage is that you’re forced to use the ternary operator for it, and thus any complex branching quickly starts looking like a mess.

Conditional types have many practical uses. Often they are combined with infer (which we’ll cover in a minute!), but even without it you still can use them. For example, in React you can use conditional types to mark some of props optional depending on value (or rather type) of another prop:

import React from 'react';
 
type VariableSelectProps<Value, Nullable extends boolean> = Nullable extends true 
  ? { value: Value | null, onClear?: () => void }
  : { value: Value }
 
type SelectProps<Value, Nullable extends boolean> = {
  nullable: Nullable,
  options: Value[],
  onChange?: (nextValue: Value) => void
} & VariableSelectProps<Value, Nullable>
 
function Select<Value, Nullable extends boolean>(props: SelectProps<Value, Nullable>) {
  // Do some React stuff and return some JSX
  return null
}
 
const valid = <Select nullable={true} value={1} options={[1, 2, 3]} onClear={() => console.log('Cleared')} />
 
// TS tells us that we can't pass onClear to component because it's not nullable
const invalid = <Select nullable={false} value={1} options={[1, 2, 3]} onClear={() => console.log('Cleared')} />

Try in TypeScript playground

With a bit more conditional types you can make nullable prop optional. Try doing it as an exercise (answer).

Infer

infer allows you to, well, infer the type of type parameters from other types. It’s always used inside a conditional type.

type ExtractElementType<T> = T extends Array<infer V> ? V : never
 
type X = ExtractElementType<Array<number>>
//   ^? type X = number
type Y = ExtractElementType<string>
//   ^? type Y = never
type Z = ExtractElementType<[1, "test", 5]>
//   ^? type Z = 1 | "test" | 5
 
// It can also infer argument or return type of function
type SecondArg<F> = F extends ((arg1: any, arg2: infer A) => any) ? A : never
 
function createUser(name: string, age: number) {}
type Q = SecondArg<typeof createUser>
//   ^? type Q = number

Try in TypeScript playground

Getting the type of array’s elements is trivial and often you don’t even need infer or a separate type utility for that. However, add a bit of recursion, and infer becomes a lot more versatile.

// This is equivalent to built-in Awaited type
type UnwrapPromise<T> = T extends Promise<infer V> ? UnwrapPromise<V>: T;
 
type NestedPromise = Promise<Promise<Promise<Promise<{data: string}>>>>
type X = UnwrapPromise<NestedPromise>
//   ^? type X = { data: string; }
 
type FlattenArray<T> = T extends Array<infer V> ? FlattenArray<V>: T;
type NestedArray = number[][][][][]
type Y = FlattenArray<NestedArray>
//   ^? type Y = number

Try in TypeScript playground

One of cooler TS features is inferring parts of string literal type. Combined with recursive types, this allows, for example, to parse parameters from a route and write typesafe createUrl function.

// Routes always start with root /
type Route = `/${string}`
 
// This is roughly type-level equivalent of (x) => x.startsWith('/') ? [x.slice(1)] : [x]
type StripLeadingSlash<S> = S extends `/${infer T}` 
  ? T extends '' 
    ? [] 
    : [T] 
  : [S];
 
// This utility function splits string route like "/blog/:year/:month/" into tuple
// of segments like ["blog", ":year", ":month"]
type RouteToSegments<T> = T extends `/${infer S}/${infer R}` 
  ? [...StripLeadingSlash<S>, ...RouteToSegments<`/${R}`>] 
  : [...StripLeadingSlash<T>]
 
// This one filters out static segments and keeps only parameters (and removes leading : from them)
type ExtractParametersFromSegments<T> = T extends [infer Head, ...infer Tail] 
  ? Head extends `:${infer Param}`
    ? [Param, ...ExtractParametersFromSegments<Tail>] 
    : [...ExtractParametersFromSegments<Tail>]
  : T;
 
// And this type build a castle from lego blocks we just made. It converts route into
// segments, extracts parameters, and converts tuple into union
// "/blog/:year/:month/" -> ["blog", ":year", ":month"] -> ["year", "month"] -> "year" | "month"
// And finally constructs an object with parameters as keys and strings as values
type ParametersMap<R extends Route> = {
  [key in ExtractParametersFromSegments<RouteToSegments<R>>[number]]: string
}
 
function createUrl<R extends Route>(route: R, params: ParametersMap<R>) { 
  /* Imagine it actually constructs and URL */
}
 
// Valid example 
createUrl("/blog/:year/:month/", {year: '2025', month: 'april'})
 
// Oh no, we made a typo in "month". Thankfully, TS now can check it
createUrl("/blog/:year/:month/", {year: '2025', moth: 'april'})
 
// Can't have extra params either
createUrl("/blog/:year/:month/", {year: '2025', month: 'april', date: '28'})

Try in TypeScript playground

Example above is quite complex and it can be tricky to wrap your head around it at first. But if concept of typesafe router sounds interesting, check out my article about making your own!

This example heavily uses tuple types (arrays of fixed length where each element can have its own type) because they allow us to take arbitrary number of elements from head or tail (T extends [infer Head, ...infer Tail]), which is very useful when doing all sort of type shenanigans.

We won’t go into much details about them here. These tricks with tuples are kind of low-level, you can think of them like writing in low-level language. As you saw, code to solve even simple task is quite verbose. Fortunately, we have an equivalent of high-level language for TS types too.

HOTScript

I’m a huge fan of HOTScript. Unfortunately, it doesn’t look like it’s being actively developed, but even then it has so many handy utilities for type-level programming. It provides high-level type utilities to perform common operations. It includes ready-made types for popular operations (e.g., translating from snake_case to camelCase), as well as simpler building blocks to write your own types (e.g., utilities for mapping over tuples, or adding numbers). For example, our createUrl example made with HOTScript is so much shorter and a lot easier to read:

import { Pipe, Tuples, Strings } from "hotscript";
 
// Routes always start with root /
type Route = `/${string}`
 
type RouteParam<R extends Route> = Pipe<
  R,
  [
    Strings.Split<"/">,
    Tuples.Filter<Strings.StartsWith<":">>,
    Tuples.Map<Strings.TrimLeft<":">>,
    Tuples.ToUnion
  ]
>
 
type ParametersMap<R extends Route> = {
  [key in RouteParam<R>]: string
}
 
// ... rest as is

Try in TypeScript playground

Pretty neat, huh? Type-level programming is needed only from time to time, but when you’re able to solve problem with it (or improve DX) it feels amazing. On one of my projects at Deepnote I had to make React hooks for certain imperative library. The library provided an object with bunch of getter methods (e.g. getPage) and I needed to create a hook for each getter method.

I was able to solve this by using Proxy: it wrapped original object, intercepted access of object properties, and if user tried to access .useSomething and there happens to be method .getSomething it generated reactive hook from the method & returned it to the user. This works wonders, but there is a problem: TypeScript has no idea about these hooks, and so you couldn’t use them without TS yelling at you.

We could of course define each possible hooks by hand, but come on. Here is simplified version of type utility that does this for us.

// This type creates object with reactive API only
type ReactiveApi<T> = {
  // Iterate over keys of passed object T
  [Key in keyof T as 
    // If it looks like `get<Thing>`
    Key extends `get${infer Thing}` 
      // ... replace it with `use<Thing>`
      ? `use${Thing}` 
      // otherwise omit it
      : never
  // For each matched key, check if associated value is a function
  ]: T[Key] extends (...args: any[]) => any
    // If so, keep it as is. It's possible to change function 
    // signature here, but it so happens that
    // our hooks have same signature as original getters
    ? T[Key]
    // If it's not a function we set its type to `never`.
    // It will still appear in autocomplete (you can get rid of 
    // it with a bit more type magic), but can't be used anywhere,
    // so it's still type-safe and good enough for us
    : never
}
 
type Table = {
  getPage: () => number;
  setPage: (page: number) => void;
  id: string;
}
 
type ReactiveTable = ReactiveApi<Table> & Table;
 
declare const myReactiveTable: ReactiveTable;
 
myReactiveTable.usePage()
// ^^ .usePage is shown as autocomplete option and has correct return type

Try in TypeScript playground

Distributive types

One surprising feature of conditional types is that generic types used inside them become distributive if a union is passed as a parameter. Let me show you an example:

type Foo = string | number
 
type FooArray = Array<Foo>

In this code FooArray will be exactly what you expect: Array<string | number>. In other words, Array treats union as single type and “wraps” it. But what if instead we want to accept either array of string or array of numbers, but not a mixed one? Something like this

type DistributedArray<T> = /* Some magic to make it happen */ T;
 
type Foo = string | number
 
type DistributedFooArray = DistributedArray<Foo>
//       ^? type DistributedFooArray = Array<string> | Array<numbers>

And that’s something conditional types can help with. Funny thing, we often don’t need an actual condition. E.g., in our example, we always want to wrap Foo, without any additional branching. But we still have to use a conditional to make the type distributive, so often it means using a “pointless” condition like this:

type DistributedArray<T> = T extends any ? Array<T> : never;
 
type Foo = string | number
 
type DistributedFooArray = DistributedArray<Foo>
//       ^? type DistributedFooArray = Array<string> | Array<numbers>

Nominal types

TypeScript implements a structural type system. In a structural type system, the shape of the object matters, not its name. For example, if a function accepts a UserAccount object, you can as well pass it an instance of ServiceAccount, as long as ServiceAccount has all properties from the User type. This has nothing to do with inheritance, ServiceAccount doesn’t need to inherit from User, they don’t even need to be class instances, they can be plain objects.

In contrast to this, in nominal type systems (for example in Java), if a function accepts User, the compiler won’t let you pass ServiceAccount to it, even if it implements everything User does. It has to be User or its subclass.

Personally, I like structural type systems more, they give more flexibility and allow you to take shortcuts if needed. But in some cases it might be useful to enforce the strictness of a nominal system. Imagine this situation: you’re working on a blog platform which allows users to use raw HTML in blog posts and comments. Even though you allow some HTML, you still need to sanitize user input from <script> and other problematic elements. You decided to do it before saving the post to the DB, so you write code like this.

function savePost(body: string) {
  db.insert({ body });
}
 
function sanitize(raw: string): string {
  // Just for demonstration, don't do it in real apps 🙈
  return raw.replace('<', '&lt').replace('>', '&gt');
}
 
app.post("/posts", (req, res) => {
  const raw = req.body.content;
  const sanitized = sanitize(raw);
  savePost(sanitized);
  res.sendStatus(201);
});

In a couple of months there are news that a major blogging platform is closing down, so you decide to ride this wave. You quickly create a bulk import endpoint in your app which allows users to import multiple posts from the dying platform at once.

app.post("/import", async (req, res) => {
  const incoming: { title: string; content: string }[] = req.body.posts;
 
  for (const post of incoming) {
    savePost(post.content);
  }
 
  res.sendStatus(201);
});

Implementing it in a rush, it totally slipped your mind to sanitize the input. For TS everything looks legit: function expects a string, it got a string, all good. To prevent such problems in the future we can use nominal types (or rather mimic their behavior with some TS trickery).

type Branded<Base, Brand> = Base & {__brand__: Brand}
 
type SanitizedString = Branded<string, 'SanitizedString'>;
 
function savePost(body: SanitizedString) {
  db.insert({ body });
}
 
function sanitize(raw: string): SanitizedString {
  // Just for demonstration, don't do it in real apps 🙈
  return raw.replace('<', '&lt').replace('>', '&gt') as SanitizedString;
}
 
 
app.post("/import", async (req, res) => {
  const incoming: { title: string; content: string }[] = req.body.posts;
 
  for (const post of incoming) {
    // This will fail with 
    // Argument of type 'string' is not assignable to parameter of type 'SanitizedString'
    savePost(post.content);
 
    // But this will work
    savePost(sanitize(post.content));
  }
 
  res.sendStatus(201);
});

This pattern is called “branded types”. Here we create a new type which uses string as a base, so it will be accepted anywhere where a string is accepted, autocomplete on its methods will work, etc. But we also attach a new __brand__ property to it, so if a function wants to get SanitizedString, TS won’t let you pass a plain string to it (as it doesn’t have the __brand__ property).

We then update sanitize to return SanitizedString. Please note, that we don’t actually add the __brand__ field to the string at runtime, branding only lives in TS types, we explicitly change the type of value by using type assertion. You could actually attach the property if you wanted, but usually it’s kept on the type level.

Another popular use for branded types is to distinguish between different identifiers. Often in apps different entities have some kind of id. Even though all these ids are likely to be of the same type (number or string), usually you don’t want to pass a post id to a function which works with user records. To add an extra layer of protection, you can use branded types like this:

type UserId = Branded<string, 'UserId'>;
type PostId = Branded<string, 'PostId'>;
 
function deletePost(id: PostId) {
  // Now TS will warn you if you ever try to pass 
  // UserId (or any unbranded string) to this function
}

assertUnreachable utility function

Let’s imagine we’re writing a bot for social media. It could look like this:

type PostAction = { type: 'POST'; content: string };
type LikeAction = { type: 'LIKE'; postId: string };
type FollowAction = { type: 'FOLLOW'; userId: string };
 
type UserAction = PostAction | LikeAction | FollowAction;
 
function executeScenario(actions: UserAction[]): void {
  for (const action of actions) {
    if (action.type === 'POST') {
      // Call social media API to create new post
    } else if (action.type === 'LIKE') {
      // Call social media API to like post
    } else if (action.type === 'FOLLOW') {
      // Call social media API to follow user
    }
  }
}
 
executeScenario([
  { type: 'POST', content: 'Just had an amazing day! 🌞' },
  { type: 'FOLLOW', userId: 'user123' },
  { type: 'LIKE', postId: 'post456' }
]); 

So far so good. In a couple of months you realize that your bot is not annoying enough and you decide to teach it to comment on user’s posts. You create a new CommentAction type, add the action to the scenario, and hit run. But nothing happens? Oh, right, you forgot to handle it in executeScenario. You might be asking “how tf could I forget that, it’s literally right here”, and I’ll answer “that’s just an extremely simplified example to show the point, it won’t be fun if I put a real 10,000 lines bot right here, would it?”.

Anyway, in a real project you’re likely to have much, much more code. And you won’t be able to remember all the code that works with bot actions from the top of your head. So why don’t we outsource keeping track of this to TypeScript? In our code inside the loop we intend to handle all action types, so if we add an else clause to code inside the loop, it should be unreachable. And TS knows about this too, it understands that the type of action inside else will be never. It’s a special type which denotes that a variable, according to types, can’t contain a valid value at this stage.

All left for us is to check that the variable has the type of never.

function assertUnreachable(val: never, msg: string): never {
  throw Error(`reached unexpected code path: ${msg}`);
}

If you’d like to assert only on the type level, you can skip the helper function altogether and just use action satisfies never. But throwing an error at runtime helps to catch cases when a value doesn’t correspond to its defined types. For example, this might be the case when the API unexpectedly returns data of a different type.

Updated example looks like this. Now TS will yell at you if you forget to handle some action type:

function assertUnreachable(val: never, msg: string): never {
  throw Error(`reached unexpected code path: ${msg}`);
}
 
type PostAction = { type: 'POST'; content: string };
type LikeAction = { type: 'LIKE'; postId: string };
type FollowAction = { type: 'FOLLOW'; userId: string };
type CommentAction = { type: 'COMMENT'; postId: string, content: string };
 
type UserAction = PostAction | LikeAction | FollowAction | CommentAction;
 
function executeScenario(actions: UserAction[]): void {
  for (const action of actions) {
    if (action.type === 'POST') {
      // Call social media API to create new post
    } else if (action.type === 'LIKE') {
      // Call social media API to like post
    } else if (action.type === 'FOLLOW') {
      // Call social media API to follow user
    } else {
      // Argument of type 'CommentAction' is not assignable to parameter of type 'never'.
      assertUnreachable(action, `Unknown action type ${action}`)
    }
  }
}
 
executeScenario([
  { type: 'POST', content: 'Just had an amazing day! 🌞' },
  { type: 'FOLLOW', userId: 'user123' },
  { type: 'LIKE', postId: 'post456' },
  { type: 'COMMENT', postId: 'post456', content: "So insightful!" },
]); 

Try in TypeScript playground

Types performance

With great knowledge comes great responsibility. Namely, to not fuck up compiler performance in your project. It might be tempting to write crazy types to make autocomplete a bit better, or to typecheck some very specific edge cases. But some types require a lot of resources (relatively speaking) to typecheck. So if you have quite a bit of them in your project, it will increase compilation time. In addition to increasing build time (which is tolerable in most cases), it can significantly slow down features like autocomplete or hover info in your code editor. And waiting 5 seconds for a variable type to appear when you move your mouse over it is not a pleasant experience at all.

Unfortunately, tooling for profiling types is very limited. It might be hard to spot a problematic type in an already slow project. TypeScript has some docs on performance, I recommend you check it out, they cover both debugging existing builds, as well as writing easy to compile types in the first place and is a good place to start.

If you want to go further, you can implement types performance monitoring as part of your CI. When speaking about types performance, one of quantifiable metrics is type instantiations. Instantiation is when TypeScript takes some generic type and creates its instance with certain type argument (or combination of) applied. More instantiations → more types → more work for compiler to do. By comparing this number between builds you can spot problematic commits early. Or you can go even further and test specific types performance as part of your test suite by using @ark/attest. Learn more about tracking type instantiations and type tests in wonderful article from Scott Trinh.

Published at 8 June 2025
Was this interesting or useful?
I publish a newsletter with articles I found interesting and announces of my new posts. You can leave your email and get new issues delivered to your inbox!
Alternatively you can subscribe to my RSS feed to know about new posts.
Or follow me on Twitter, where I sometimes post about new articles, my pet projects, and web dev in general.
If you really like the article, you can give me monies, and I'll buy myself tasty coffee to write even more.

You may also like: