7 Tips That Make You a Better Typescript Programmer

7 Things to Consider While Choosing iOS Developer for Your Business
15th February 2023
Accepting Things As They Are: Why and How to Do It
25th February 2023
7 Things to Consider While Choosing iOS Developer for Your Business
15th February 2023
Accepting Things As They Are: Why and How to Do It
25th February 2023

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? Better Typescript Programmer

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;

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 IntersectionMeasure & 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;

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

#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 {
    ...
  }
}

#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!;
}

The non-null assertions (when accessing radiuswidth, 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;
}

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[];

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);

#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[];

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

type Foo = ToArray<string|number>;

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>;

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');
  }
}

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');
  }
}

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.

How does 3D animation work In Unity?

What is ChatGPT?