DEV Community

Cover image for 11 Tips That Make You a Better Typescript Programmer
ymc9 for ZenStack

Posted on • Edited on

11 Tips That Make You a Better Typescript Programmer

Learning Typescript is often a rediscovery journey. Your initial impression can be pretty deceptive: isn't it just a way of annotating Javascript, so the compiler helps me find potential bugs?


By r/mevlix@reddit

Although this statement is generally true, as you move on, you'll find the most incredible power of the language lies in composing, inferring, and manipulating types.

This article will summarize several tips that help you use the language to its full potential.

#1 Think in {Set}

Type is an everyday concept to programmers, but it’s surprisingly difficult to define it succinctly. I find it helpful to use Set as a conceptual model instead.

For example, new learners find Typescript’s way of composing types counter-intuitive. Take a very simple example:



type Measure = { radius: number };
type Style = { color: string };

// typed { radius: number; color: string }
type Circle = Measure & Style;


Enter fullscreen mode Exit fullscreen mode

If you interpret the operator & in the sense of logical AND, you may expect Circle to be a dummy type because it’s a conjunction of two types without any overlapping fields. This is not how typescript works. Instead, thinking in Set is much easier to deduce the correct behavior:

  • Every type is a Set of values.
  • Some Sets are infinite: string, object; some finite: boolean, undefined, …
  • unknown is Universal Set (including all values), while never is Empty Set (including no value).
  • Type Measure is a Set for all objects that contain a number field called radius. The same with Style.
  • The & operator creates an Intersection: Measure & Style denotes a Set of objects containing both radius and color fields, which is effectively a smaller Set, but with more commonly available fields.
  • Similarly, the | operator creates a Union: a larger Set but potentially with fewer commonly available fields (if two object types are composed).

Set also helps understand assignability: an assignment is only allowed if the value’s type is a subset of the destination’s type:



type ShapeKind = 'rect' | 'circle';
let foo: string = getSomeString();
let shape: ShapeKind = 'rect';

// disallowed because string is not subset of ShapeKind
shape = foo;

// allowed because ShapeKind is subset of string
foo = shape;


Enter fullscreen mode Exit fullscreen mode

The following article provides an excellent elaborated introduction to thinking in Set.

TypeScript and Set Theory | Iván Ovejero

How does set theory help to understand type assignability and resolution in TypeScript?

favicon ivov.dev

#2 Understand declared type and narrowed type

One extremely powerful typescript feature is automatic type narrowing based on control flow. This means a variable has two types associated with it at any specific point of code location: a declaration type and a narrowed type.



function foo(x: string | number) {
  if (typeof x === 'string') {
    // x's type is narrowed to string, so .length is valid
    console.log(x.length);

    // assignment respects declaration type, not narrowed type
    x = 1;
    console.log(x.length); // disallowed because x is now number
  } else {
    ...
  }
}


Enter fullscreen mode Exit fullscreen mode

#3 Use discriminated union instead of optional fields

When defining a set of polymorphic types like Shape, it’s easy to start with:



type Shape = {
  kind: 'circle' | 'rect';
  radius?: number;
  width?: number;
  height?: number;
}

function getArea(shape: Shape) {
  return shape.kind === 'circle' ? 
    Math.PI * shape.radius! ** 2
    : shape.width! * shape.height!;
}


Enter fullscreen mode Exit fullscreen mode

The non-null assertions (when accessing radius, width, and height fields) are needed because there’s no established relationship between kind and other fields. Instead, discriminated union is a much better solution:



type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;

function getArea(shape: Shape) {
    return shape.kind === 'circle' ? 
        Math.PI * shape.radius ** 2
        : shape.width * shape.height;
}


Enter fullscreen mode Exit fullscreen mode

Type narrowing has eliminated the need for coercion.

#4 Use type predicate to avoid type assertion

If you use typescript in the right way, you should rarely find yourself using explicit type assertion (like value as SomeType); however, sometimes you’ll still feel an impulsion, like:



type Circle = { kind: 'circle'; radius: number };
type Rect = { kind: 'rect'; width: number; height: number };
type Shape = Circle | Rect;

function isCircle(shape: Shape) {
  return shape.kind === 'circle';
}

function isRect(shape: Shape) {
  return shape.kind === 'rect';
}

const myShapes: Shape[] = getShapes();

// error because typescript doesn't know the filtering
// narrows typing
const circles: Circle[] = myShapes.filter(isCircle);

// you may be inclined to add an assertion:
// const circles = myShapes.filter(isCircle) as Circle[];


Enter fullscreen mode Exit fullscreen mode

A more elegant solution is to change isCircle and isRect to return type predicate instead, so they help Typescript further narrow down types after the filter call:



function isCircle(shape: Shape): shape is Circle {
    return shape.kind === 'circle';
}

function isRect(shape: Shape): shape is Rect {
    return shape.kind === 'rect';
}

...
// now you get Circle[] type inferred correctly
const circles = myShapes.filter(isCircle);


Enter fullscreen mode Exit fullscreen mode

#5 Control how union types are distributed

Type inference is Typescript’s instinct; most of the time, it works silently for you. However, you may need to intervene in subtle cases of ambiguities. Distributive conditional types is one of these cases.

Suppose we have a ToArray helper type that returns an array type if the input type is not already one:



type ToArray<T> = T extends Array<unknown> ? T: T[];


Enter fullscreen mode Exit fullscreen mode

What do you think should be inferred for the following type?



type Foo = ToArray<string|number>;


Enter fullscreen mode Exit fullscreen mode

The answer is string[] | number[]. But this is ambiguous. Why not (string | number)[] instead?

By default, when typescript encounters a union type (string | number here) for a generic parameter (T here), it distributes into each constituent, and that’s why you get string[] | number[]. This behavior can be altered by using a special syntax and wrapping T in a pair of [], like:



type ToArray<T> = [T] extends [Array<unknown>] ? T : T[];
type Foo = ToArray<string | number>;


Enter fullscreen mode Exit fullscreen mode

Now Foo is inferred as type (string | number)[].

#6 Use exhaustive checking to catch unhandled cases at compile time

When switch-casing over an enum, it’s a good habit to actively err for the cases that are not expected instead of ignoring them silently as you do in other programming languages:



function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rect':
      return shape.width * shape.height;
    default:
      throw new Error('Unknown shape kind');
  }
}


Enter fullscreen mode Exit fullscreen mode

With Typescript, you can let static type checking find the error earlier for you by utilizing the never type:



function getArea(shape: Shape) {
  switch (shape.kind) {
    case 'circle':
      return Math.PI * shape.radius ** 2;
    case 'rect':
      return shape.width * shape.height;
    default:
      // you'll get a type-checking error below          
      // if any shape.kind is not handled above
      const _exhaustiveCheck: never = shape;
      throw new Error('Unknown shape kind');
  }
}


Enter fullscreen mode Exit fullscreen mode

With this, it’s impossible to forget to update the getArea function when adding a new shape kind.

The rationale behind the technique is that the never type cannot be assigned with anything except for never. If all candidates of shape.kind are exhausted by the case statements, the only possible type reaching default is never; however, if any candidate is not covered, it'll leak to the default branch and result in an invalid assignment.

#7 Prefer type over interface

In typescript, type and interface are very similar constructs when used for typing objects. Though maybe controversial, my recommendation is to consistently use type in most cases and only use interface when either of the following is true:

  • You want to take advantage of the "merging" feature of interface.

  • You have OO style code involving class/interface hierarchies.

Otherwise, always using the more versatile type construct results in more consistent code.

#8 Prefer tuple over array whenever appropriate

Object types are the common way of typing structured data, but sometimes you may desire a terser representation and use simple arrays instead. E.g., our Circle can be defined like:



type Circle = (string | number)[];
const circle: Circle = ['circle', 1.0];  // [kind, radius]


Enter fullscreen mode Exit fullscreen mode

But this typing is unnecessarily loose, and you can easily make an error by creating something like ['circle', '1.0']. We can make it stricter by using Tuple instead:



type Circle = [string, number];

// you'll get an error below
const circle: Circle = ['circle', '1.0'];


Enter fullscreen mode Exit fullscreen mode

A good example of Tuple usage is React’s useState.



const [name, setName] = useState('');


Enter fullscreen mode Exit fullscreen mode

It’s both compact and type-safe.

#9 Control how general or specific the inferred types are

Typescript uses sensible default behavior when making type inference, which aims to make writing code easy for common cases (so types don’t need to be explicitly annotated). There’re a few ways you can tweak its behavior.

  • Use const to narrow down to the most specific type


let foo = { name: 'foo' }; // typed: { name: string }
let Bar = { name: 'bar' } as const; // typed: { name: 'bar' }

let a = [1, 2]; // typed: number[]
let b = [1, 2] as const; // typed: [1, 2]

// typed { kind: 'circle; radius: number }
let circle = { kind: 'circle' as const, radius: 1.0 };

// the following won't work if circle wasn't initialized
// with the const keyword
let shape: { kind: 'circle' | 'rect' } = circle;


Enter fullscreen mode Exit fullscreen mode
  • Use satisfies to check typing without affecting the inferred type

Consider the following example:



type NamedCircle = {
    radius: number;
    name?: string;
};

const circle: NamedCircle = { radius: 1.0, name: 'yeah' };

// error because circle.name can be undefined
console.log(circle.name.length);


Enter fullscreen mode Exit fullscreen mode

We’ve got an error because according to circle's declaration type NamedCircle, name field can indeed be undefined, even though the variable initializer provided a string value. Of course we can drop the : NamedCircle type annotation, but we’ll loose type checking for the validity of the circle object. Quite a dilemma.

Fortunately, Typescript 4.9 introduced a new satisfies keyword which allows you to check type without altering the inferred type:



type NamedCircle = {
    radius: number;
    name?: string;
};

// error because radius violates NamedCircle
const wrongCircle = { radius: '1.0', name: 'ha' }
    satisfies NamedCircle;

const circle = { radius: 1.0, name: 'yeah' }
    satisfies NamedCircle;

// circle.name can't be undefined now
console.log(circle.name.length);


Enter fullscreen mode Exit fullscreen mode

The modified version enjoys both benefits: the object literal is guaranteed to conform to NamedCircle type, and the inferred type has a non-nullable name field.

#10 Use infer to create extra generic type parameters

When designing utility functions and types, you’ll often feel the need to use a type that’s extracted out of the given type parameter. The infer keyword comes handy in this situation. It helps you infer a new type parameter on the fly. Here’re two simple examples:



// gets the unwrapped type out of a Promise;
// idempotent if T is not Promise
type ResolvedPromise<T> = T extends Promise<infer U> ? U : T;
type t = ResolvedPromise<Promise<string>>; // t: string

// gets the flattened type of array T;
// idempotent if T is not array
type Flatten<T> = T extends Array<infer E> ? Flatten<E> : T;
type e = Flatten<number[][]>; // e: number


Enter fullscreen mode Exit fullscreen mode

How infer keyword works in T extends Promise<infer U> can be understood like: assuming T is compatible with some instantiated generic Promise type, improvise a type parameter U to make it work. So, if T is instantiated as Promise<string>, the solution of U will be string.

#11 Stay DRY by being creative with type manipulation

Typescript provides powerful type manipulation syntaxes and a set of very useful utilities to help you reduce code duplication to a minimum. Here’re just a few ad-hoc examples:

  • Instead of duplicating field declarations:


type User = {
    age: number;
    gender: string;
    country: string;
    city: string
};
type Demographic = { age: number: gender: string; };
type Geo = { country: string; city: string; };


Enter fullscreen mode Exit fullscreen mode

, use Pick utility to extract new types:



type User = {
    age: number;
    gender: string;
    country: string;
    city: string
};
type Demographic = Pick<User, 'age'|'gender'>;
type Geo = Pick<User, 'country'|'city'>;


Enter fullscreen mode Exit fullscreen mode
  • Instead of duplicating function’s return type


function createCircle() {
    return {
        kind: 'circle' as const,
        radius: 1.0
    }
}

function transformCircle(circle: { kind: 'circle'; radius: number }) {
    ...
}

transformCircle(createCircle());


Enter fullscreen mode Exit fullscreen mode

, use ReturnType<T> to extract it:



function createCircle() {
    return {
        kind: 'circle' as const,
        radius: 1.0
    }
}

function transformCircle(circle: ReturnType<typeof createCircle>) {
    ...
}

transformCircle(createCircle());


Enter fullscreen mode Exit fullscreen mode
  • Instead of synchronizing shapes of two types (typeof config and Factory here) in parallel:


type ContentTypes = 'news' | 'blog' | 'video';

// config for indicating what content types are enabled
const config = { news: true, blog: true, video: false }
    satisfies Record<ContentTypes, boolean>;

// factory for creating contents
type Factory = {
    createNews: () => Content;
    createBlog: () => Content;
};


Enter fullscreen mode Exit fullscreen mode

, use Mapped Type and Template Literal Type to automatically infer the proper factory type based on the shape of config:



type ContentTypes = 'news' | 'blog' | 'video';

// generic factory type with a inferred list of methods
// based on the shape of the given Config
type ContentFactory<Config extends Record<ContentTypes, boolean>> = {
    [k in string & keyof Config as Config[k] extends true
        ? `create${Capitalize<k>}`
        : never]: () => Content;
};

// config for indicating what content types are enabled
const config = { news: true, blog: true, video: false }
    satisfies Record<ContentTypes, boolean>;

type Factory = ContentFactory<typeof config>;
// Factory: {
//     createNews: () => Content;
//     createBlog: () => Content; 
// }


Enter fullscreen mode Exit fullscreen mode

Use your imagination and you’ll find endless potential to explore.

Wrap up

This post covered a set of relatively advanced topics in Typescript language. In practice, you’ll probably find it not common to apply them directly; however, such techniques are heavily used by libraries specifically designed for Typescript: like Prisma and tRPC. Getting to know the tricks can help you gain a better insight into how these tools work their magic under the hood.

Did I miss something important? Leave a comment below, and let's chat!


P.S. We're building ZenStack — a toolkit for building secure CRUD apps with Next.js + Typescript. Our goal is to let you save time writing boilerplate code and focus on building what matters — the user experience.

Top comments (32)

Collapse
 
balazssoltesz profile image
Balazs Soltesz • Edited

Types have a higher performance impact compared to interfaces so in larger codebases prioritizing interfaces over types should be considered.

Collapse
 
ymc9 profile image
ymc9

Good point. I've never measured the compiler's performance for using interface vs. type. I see people saying interface inheritance is easier to type-check than type interception, which sounds reasonable.

I think if there's quite a lot of inheritance in the code base, it makes good sense to prefer interfaces that look more object-oriented.

I'll do some more research and maybe update the post later. Thank you!

Collapse
 
joshuakb2 profile image
Joshua Baker

Are you sure that's true? I recently heard that that's actually a myth.

Collapse
 
joshkel profile image
Josh Kelley

If I understand github.com/microsoft/TypeScript/wi... correctly, types aren't universally slower, but once you start doing more complex type unions and intersections, it can be faster to use interfaces.

Thread Thread
 
ymc9 profile image
ymc9

That's my understanding too.

I believe it's mainly due to this statement: "A final noteworthy difference is that when checking against a target intersection type, every constituent is checked before checking against the "effective"/"flattened" type." Though it feels like a limitation of the current tsc implementation, I don't see why checking of type intersection has to be done repeatedly instead of cached.

Would love to see some benchmarks.

Collapse
 
mariamarsh profile image
Maria 🍦 Marshmallow

Incredible article 👏
It will be very cool if you write something similar for other languages.

Collapse
 
ymc9 profile image
ymc9

Thank you @mariamarsh ! I'll see what to learn for the new year :D.

Collapse
 
mikec711g profile image
Michael Casile

I'm not all the way thru, but this a 5 star article. Most important thing I've learned from it so far is that I don't know TS well enough. Thanks for the great info ... that revelation will have me following you and looking for more great articles.

Collapse
 
ymc9 profile image
ymc9

Thank you for the recognition, @mikec711g . It's a great pleasure!

TS is fantastic. I've been using languages created by Anders Hejlsberg in my career, and they're just consistently awesome! True genius.

I'll strive to create content that matters.

Collapse
 
tylim88 profile image
Acid Coder

this is a great post, author has very good understanding on TS

Collapse
 
rafo profile image
Rafael Osipov

dev.to needs more articles like that.

Thank you.

Collapse
 
qq449245884 profile image
qq449245884

Dear ZenStack,may I translate your article into Chinese?I would like to share it with more developers in China. I will give the original author and original source.

Collapse
 
ymc9 profile image
ymc9

Hey @qq449245884 , I'm glad you find it helpful. Please feel free to translate and share. Thanks!

Collapse
 
bobbyconnolly profile image
Bobby Connolly

I really appreciate the time you took to make this article. Thank you!

Collapse
 
isalahyt profile image
iSalah-YT

Thank you so much 🥰🥰💕🥰🥰

Collapse
 
guillaumelagrange profile image
Guillaume Lagrange

Thanks for the article.

Very small typo in #9:
NamedCircle's name property should not be optional.

Collapse
 
ymc9 profile image
ymc9 • Edited

Thanks, @guillaumelagrange . The "name" field is intentionally optional to demonstrate the effect of the satisfies keyword. The NamedCircle type gives a "looser" contract than inferred by the variable declaration.

Does it make sense, or maybe I missed something?

Collapse
 
alessioferrine profile image
alessioferrine

It's great tips, thanks

Some comments have been hidden by the post's author - find out more