Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

《Effective TypeScript》 —— Dan Vanderkam #59

Open
thzt opened this issue Jul 8, 2021 · 0 comments
Open

《Effective TypeScript》 —— Dan Vanderkam #59

thzt opened this issue Jul 8, 2021 · 0 comments

Comments

@thzt
Copy link
Owner

thzt commented Jul 8, 2021

Item 1: Understand the Relationship Between TypeScript and JavaScript

The root cause of these exceptions is that TypeScript’s understanding of a value’s type and reality have diverged. A type system which can guarantee the accuracy of its static types is said to be sound. TypeScript’s type system is very much not sound, nor was it ever intended to be. If soundness is important to you, you may want to look at other languages like Reason or Elm. While these do offer more guarantees of runtime safety, this comes at a cost: neither is a superset of JavaScript, so migration will be more complicated.

Item 2: Know Which TypeScript Options You’re Using

$ tsc --init
$ tsc --noImplicitAny program.ts

noImplicitAny controls whether variables must have known types.
TypeScript is the most helpful when it has type information, so you should be sure to set noImplicitAny whenever possible. Once you grow accustomed to all variables having types, TypeScript without noImplicitAny feels almost like a different language.

strictNullChecks controls whether null and undefined are permissible values in every type.

Item 3: Understand That Code Generation Is Independent of Types

Because code output is independent of type checking, it follows that code with type errors can produce output!
This can be quite surprising if you’re coming from a language like C or Java where type checking and output go hand in hand. You can think of all TypeScript errors as being similar to warnings in those languages: it’s likely that they indicate a problem and are worth investigating, but they won’t stop the build.

This is likely the source of some sloppy language that is common around TypeScript. You’ll often hear people say that their TypeScript “doesn’t compile” as a way of saying that it has errors. But this isn’t technically correct! Only the code generation is “compiling.” So long as your TypeScript is valid JavaScript (and often even if it isn’t), the TypeScript compiler will produce output. It’s better to say that your code has errors, or that it “doesn’t type check.

TypeScript can get quite confusing when your runtime types don’t match the declared types, and this is a situation you should avoid whenever you can. But be aware that it’s possible for a value to have types other than the ones you’ve declared.

interface Square {
  kind: 'square';
  width: number;
}
interface Rectangle {
  kind: 'rectangle';
  height: number;
  width: number;
}
type Shape = Square | Rectangle;

function calculateArea(shape: Shape) {
  if (shape.kind === 'rectangle') {
    shape;  // Type is Rectangle
    return shape.width * shape.height;
  } else {
    shape;  // Type is Square
    return shape.width * shape.width;
  }
}

The Shape type here is an example of a “tagged union”. Because they make it so easy to recover type information at runtime, tagged unions are ubiquitous in TypeScript.

TypeScript types are not available at runtime. To query a type at runtime, you need some way to reconstruct it. Tagged unions and property checking are common ways to do this. Some constructs, such as class, introduce both a TypeScript type and a value that is available at runtime.

Item 4: Get Comfortable with Structural Typing

JavaScript is inherently duck typed: if you pass a function a value with all the right properties, it won’t care how you made the value. It will just use it. (“If it walks like a duck and talks like a duck…”) TypeScript models this behavior, and it can sometimes lead to surprising results because the type checker’s understanding of a type may be broader than what you had in mind.

A structural type system (or property-based type system) is a major class of type system in which type compatibility and equivalence are determined by the type's actual structure or definition and not by other characteristics such as its name or place of declaration.

Item 5: Limit Use of the any Type

TypeScript’s type system is gradual and optional: gradual because you can add types to your code bit by bit and optional because you can disable the type checker whenever you like.

As you start using TypeScript, it’s tempting to use any types and type assertions (as any) when you don’t understand an error, think the type checker is incorrect, or simply don’t want to take the time to write out type declarations. In some cases this may be OK, but be aware that any eliminates many of the advantages of using TypeScript. You should at least understand its dangers before you use it.

There Are No Language Services for any Types. TypeScript’s motto is “JavaScript that scales.” A key part of “scales” is the language services, which are a core part of the TypeScript experience (see Item 6). Losing them will lead to a loss in productivity, not just for you but for everyone else working with your code.

any Hides Your Type Design. As Chapter 4 explains, good type design is essential for writing clean, correct, and understandable code. With an any type, your type design is implicit. This makes it hard to know whether the design is a good one, or even what the design is at all.

The any type effectively silences the type checker and TypeScript language services. It can mask real problems, harm developer experience, and undermine confidence in the type system. Avoid using it when you can!

Item 6: Use Your Editor to Interrogate and Explore the Type System

Item 7: Think of Types as Sets of Values

But before your code runs, when TypeScript is checking it for errors, it just has a type. This is best thought of as a set of possible values. This set is known as the domain of the type. For instance, you can think of the number type as the set of all number values. 42 and -37.25 are in it, but 'Canada' is not. Depending on strictNullChecks, null and undefined may or may not be part of the set.

The smallest set is the empty set, which contains no values. It corresponds to the never type in TypeScript. Because its domain is empty, no values are assignable to a variable with a never type.
The next smallest sets are those which contain single values. These correspond to literal types in TypeScript, also known as unit types.

The word “assignable” appears in many TypeScript errors. In the context of sets of values, it means either “member of” (for a relationship between a value and a type) or “subset of” (for a relationship between two types).

The sets for these types are easy to reason about because they are finite. But most types that you work with in practice have infinite domains. Reasoning about these can be harder. You can think of them as either being built constructively.

The & operator computes the intersection of two types. What sorts of values belong to the PersonSpan type? On first glance the Person and Lifespan interfaces have no properties in common, so you might expect it to be the empty set (i.e., the never type). But type operations apply to the sets of values (the domain of the type), not to the properties in the interface. And remember that values with additional properties still belong to a type. So a value that has the properties of both Person and Lifespan will belong to the intersection type. Of course, a value could have more than those three properties and still belong to the type! The general rule is that values in an intersection type contain the union of properties in each of its constituents.

interface Person {
  name: string;
}
interface Lifespan {
  birth: Date;
  death?: Date;
}
type PersonSpan = Person & Lifespan;

const ps: PersonSpan = {
  name: 'Alan Turing',
  birth: new Date('1912/06/23'),
  death: new Date('1954/06/07'),
};  // OK
keyof (A&B) = (keyof A) | (keyof B)
keyof (A|B) = (keyof A) & (keyof B)

If types are best thought of as sets of values, that means that two types with the same sets of values are the same. And indeed this is true. Unless two types are semantically different and just happen to have the same domain, there’s no reason to define the same type twice.

Finally, it’s worth noting that not all sets of values correspond to TypeScript types. There is no TypeScript type for all the integers, or for all the objects that have x and y properties but no others.

TypeScript term Set term
never ∅ (empty set)
Literal type Single element set
Value assignable to T Value ∈ T (member of)
T1 assignable to T2 T1 ⊆ T2 (subset of)
T1 extends T2 T1 ⊆ T2 (subset of)
T1 T2
T1 & T2 T1 ∩ T2 (intersection)
unknown Universal set

Think of types as sets of values (the type’s domain). These sets can either be finite (e.g., boolean or literal types) or infinite (e.g., number or string).

Type operations apply to a set’s domain. The intersection of A and B is the intersection of A’s domain and B’s domain. For object types, this means that values in A & B have the properties of both A and B.

Think of “extends,” “assignable to,” and “subtype of” as synonyms for “subset of.”

Item 8: Know How to Tell Whether a Symbol Is in the Type Space or Value Space

A symbol in TypeScript exists in one of two spaces: Type space, Value space. This can get confusing because the same name can refer to different things depending on which space it’s in.

There are many operators and keywords that mean different things in a type or value context. In a type context, typeof takes a value and returns its TypeScript type. You can use these as part of a larger type expression, or use a type statement to give them a name. In a value context, typeof is JavaScript’s runtime typeof operator. It returns a string containing the runtime type of the symbol. This is not the same as the TypeScript type!

JavaScript’s runtime type system is much simpler than TypeScript’s static type system. In contrast to the infinite variety of TypeScript types, there have historically only been six runtime types in JavaScript: “string,” “number,” “boolean,” “undefined,” “object,” and “function.”

typeof always operates on values. You can’t apply it to types. The class keyword introduces both a value and a type, so what is the typeof a class? It depends on the context:

const v = typeof Cylinder;  // Value is "function"
type T = typeof Cylinder;  // Type is typeof Cylinder

declare let fn: T;
const c = new fn();  // Type is Cylinder

type C = InstanceType<typeof Cylinder>;  // Type is Cylinder

this in value space is JavaScript’s this keyword (Item 49). As a type, this is the TypeScript type of this, aka “polymorphic this.” It’s helpful for implementing method chains with subclasses.
In value space & and | are bitwise AND and OR. In type space they are the intersection and union operators.
const introduces a new variable, but as const changes the inferred type of a literal or literal expression (Item 21).
extends can define a subclass (class A extends B) or a subtype (interface A extends B) or a constraint on a generic type (Generic<T extends number>).
in can either be part of a loop (for (key in object)) or a mapped type (Item 14).

Item 9: Prefer Type Declarations to Type Assertions

The first (alice: Person) adds a type declaration to the variable and ensures that the value conforms to the type. The latter (as Person) performs a type assertion. This tells TypeScript that, despite the type it inferred, you know better and would like the type to be Person.

In general, you should prefer type declarations to type assertions. The type declaration verifies that the value conforms to the interface. Since it does not, TypeScript flags an error. The type assertion silences this error by telling the type checker that, for whatever reason, you know better than it does.

So when should you use a type assertion? Type assertions make the most sense when you truly do know more about a type than TypeScript does, typically from context that isn’t available to the type checker.

Used as a prefix, ! is boolean negation. But as a suffix, ! is interpreted as an assertion that the value is non-null. You should treat ! just like any other assertion: it is erased during compilation, so you should only use it if you have information that the type checker lacks and can ensure that the value is non-null. If you can’t, you should use a conditional to check for the null case.

Item 10: Avoid Object Wrapper Types (String, Number, Boolean, Symbol, BigInt)

In addition to objects, JavaScript has seven types of primitive values: strings, numbers, booleans, null, undefined, symbol, and bigint. The first five have been around since the beginning. The symbol primitive was added in ES2015, and bigint is in the process of being finalized.

Primitives are distinguished from objects by being immutable and not having methods.

While a string primitive does not have methods, JavaScript also defines a String object type that does. JavaScript freely converts between these types. When you access a method like charAt on a string primitive, JavaScript wraps it in a String object, calls the method, and then throws the object away.

> x = "hello"
> x.language = 'English'
'English'
> x.language
undefined

Now you know the explanation: x is converted to a String instance, the language property is set on that, and then the object (with its language property) is thrown away.

There are object wrapper types for the other primitives as well: Number for numbers, Boolean for booleans, Symbol for symbols, and BigInt for bigints (there are no object wrappers for null and undefined).

So string is assignable to String, but String is not assignable to string.

Avoid TypeScript object wrapper types. Use the primitive types instead: string instead of String, number instead of Number, boolean instead of Boolean, symbol instead of Symbol, and bigint instead of BigInt.

Item 11: Recognize the Limits of Excess Property Checking

When you assign an object literal to a variable with a declared type, TypeScript makes sure it has the properties of that type and no others.

interface Room {
  numDoors: number;
  ceilingHeightFt: number;
}
const r: Room = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: "present",
  // ~~~~~~~~~~~~~~~~~~ Object literal may only specify known properties,
  //                    and 'elephant' does not exist in type 'Room'
};
const obj = {
  numDoors: 1,
  ceilingHeightFt: 10,
  elephant: "present",
};
const r: Room = obj; // OK

In the first you’ve triggered a process known as “excess property checking,” which helps catch an important class of errors that the structural type system would otherwise miss. But this process has its limits, and conflating it with regular assignability checks can make it harder to build an intuition for structural typing. Recognizing excess property checking as a distinct process will help you build a clearer mental model of TypeScript’s type system.

Excess property checking tries to rein this in without compromising the fundamentally structural nature of the type system. It does this by disallowing unknown properties specifically on object literals. (It’s sometimes called “strict object literal checking” for this reason.)

Excess property checking does not happen when you use a type assertion. This is a good reason to prefer declarations to assertions.

const o = { darkmode: true, title: 'Ski Free' } as Options;  // OK

If you don’t want this sort of check, you can tell TypeScript to expect additional properties using an index signature. Item 15 discusses when this is and is not an appropriate way to model your data.

interface Options {
  darkMode?: boolean;
  [otherOptions: string]: unknown;
}
const o: Options = { darkmode: true }; // OK

A related check happens for “weak” types, which have only optional properties. From a structural point of view, the LineChartOptions type should include almost all objects. For weak types like this, TypeScript adds another check to make sure that the value type and declared type have at least one property in common. Much like excess property checking, this is effective at catching typos and isn’t strictly structural. But unlike excess property checking, it happens during all assignability checks involving weak types. Factoring out an intermediate variable doesn’t bypass this check.

interface LineChartOptions {
  logscale?: boolean;
  invertedYAxis?: boolean;
  areaChart?: boolean;
}
const opts = { logScale: true };
const o: LineChartOptions = opts;
// ~ Type '{ logScale: boolean; }' has no properties in common
//   with type 'LineChartOptions

Excess property checking is an effective way of catching typos and other mistakes in property names that would otherwise be allowed by the structural typing system. It’s particularly useful with types like Options that contain optional fields. But it is also very limited in scope: it only applies to object literals. Recognize this limitation and distinguish between excess property checking and ordinary type checking. This will help you build a mental model of both.

When you assign an object literal to a variable or pass it as an argument to a function, it undergoes excess property checking. Be aware of the limits of excess property checking: introducing an intermediate variable will remove these checks.

Item 12: Apply Types to Entire Function Expressions When Possible

An advantage of function expressions in TypeScript is that you can apply a type declaration to the entire function at once, rather than specifying the types of the parameters and return type individually.

type DiceRollFn = (sides: number) => number;
const rollDice: DiceRollFn = sides => { /* ... */ };

Libraries often provide types for common function signatures. For example, ReactJS provides a MouseEventHandler type that you can apply to an entire function rather than specifying MouseEvent as a type for the function’s parameter. If you’re a library author, consider providing type declarations for common callbacks.

const checkedFetch: typeof fetch = async (input, init) => {
  const response = await fetch(input, init);
  if (!response.ok) {
    throw new Error('Request failed: ' + response.status);
  }
  return response;
}

We’ve changed from a function statement to a function expression and applied a type (typeof fetch) to the entire function. This allows TypeScript to infer the types of the input and init parameters. The type annotation also guarantees that the return type of checkedFetch will be the same as that of fetch.

In addition to being more concise, typing this entire function expression instead of its parameters has given you better safety. When you’re writing a function that has the same type signature as another one, or writing many functions with the same type signature, consider whether you can apply a type declaration to entire functions, rather than repeating types of parameters and return values.

Consider applying type annotations to entire function expressions, rather than to their parameters and return type.
If you’re writing the same type signature repeatedly, factor out a function type or look for an existing one. If you’re a library author, provide types for common callbacks.
Use typeof fn to match the signature of another function.

Item 13: Know the Differences Between type and interface

An interface can extend a type (with some caveats, explained momentarily), and a type can extend an interface.

interface IStateWithPop extends TState {
  population: number;
}
type TStateWithPop = IState & { population: number; };

Again, these types are identical. The caveat is that an interface cannot extend a complex type like a union type. If you want to do that, you’ll need to use type and &.

There are union types but no union interfaces.

type AorB = 'a' | 'b';

Extending union types can be useful. If you have separate types for Input and Output variables and a mapping from name to variable.

type Input = { /* ... */ };
type Output = { /* ... */ };

interface VariableMap {
  [name: string]: Input | Output;
}
type NamedVariable = (Input | Output) & { name: string };

This type cannot be expressed with interface. A type is, in general, more capable than an interface. It can be a union, and it can also take advantage of more advanced features like mapped or conditional types.

An interface does have some abilities that a type doesn’t, however. One of these is that an interface can be augmented. Going back to the State example, you could have added a population field in another way.

interface IState {
  name: string;
  capital: string;
}
interface IState {
  population: number;
}
const wyoming: IState = {
  name: 'Wyoming',
  capital: 'Cheyenne',
  population: 500_000
};  // OK

This is known as “declaration merging”, and it’s quite surprising if you’ve never seen it before. This is primarily used with type declaration files (Chapter 6), and if you’re writing one, you should follow the norms and use interface to support it. The idea is that there may be gaps in your type declarations that users need to fill, and this is how they do it.

TypeScript uses merging to get different types for the different versions of JavaScript’s standard library. The Array interface, for example, is defined in lib.es5.d.ts. By default this is all you get. But if you add ES2015 to the lib entry of your tsconfig.json, TypeScript will also include lib.es2015.d.ts. This includes another Array interface with additional methods like find that were added in ES2015. They get added to the other Array interface via merging. The net effect is that you get a single Array type with exactly the right methods.

Merging is supported in regular code as well as declarations, and you should be aware of the possibility. If it’s essential that no one ever augment your type, then use type.

Returning to the question at the start of the item, should you use type or interface? For complex types, you have no choice: you need to use a type alias. But what about the simpler object types that can be represented either way? To answer this question, you should consider consistency and augmentation. Are you working in a codebase that consistently uses interface? Then stick with interface. Does it use type? Then use type. For projects without an established style, you should think about augmentation. Are you publishing type declarations for an API? Then it might be helpful for your users to be able to be able to merge in new fields via an interface when the API changes. So use interface. But for a type that’s used internally in your project, declaration merging is likely to be a mistake. So prefer type.

Item 14: Use Type Operations and Generics to Avoid Repeating Yourself

Duplication in types has many of the same problems as duplication in code.

interface Person {
  firstName: string;
  lastName: string;
}

interface PersonWithBirthDate {
  firstName: string;
  lastName: string;
  birth: Date;
}

You can eliminate the repetition by making one interface extend the other.

interface Person {
  firstName: string;
  lastName: string;
}

interface PersonWithBirthDate extends Person {
  birth: Date;
}

You can also use the intersection operator (&) to extend an existing type, though this is less common. This technique is most useful when you want to add some additional properties to a union type (which you cannot extend). For more on this, see Item 13.

type PersonWithBirthDate = Person & { birth: Date };

You can also go the other direction. What if you have a type, State, which represents the state of an entire application, and another, TopNavState, which represents just a part?

interface State {
  userId: string;
  pageTitle: string;
  recentFiles: string[];
  pageContents: string;
}
interface TopNavState {
  userId: string;
  pageTitle: string;
  recentFiles: string[];
}

You can remove duplication in the types of the properties by indexing into State.

type TopNavState = {
  userId: State['userId'];
  pageTitle: State['pageTitle'];
  recentFiles: State['recentFiles'];
};

While it’s longer, this is progress: a change in the type of pageTitle in State will get reflected in TopNavState. But it’s still repetitive. You can do better with a mapped type.

type TopNavState = {
  [k in 'userId' | 'pageTitle' | 'recentFiles']: State[k]
};

Mapped types are the type system equivalent of looping over the fields in an array. This particular pattern is so common that it’s part of the standard library, where it’s called Pick.

type Pick<T, K> = { [k in K]: T[k] };
type TopNavState = Pick<State, 'userId' | 'pageTitle' | 'recentFiles'>;

Pick is an example of a generic type. Continuing the analogy to removing code duplication, using Pick is the equivalent of calling a function. Pick takes two types, T and K, and returns a third, much as a function might take two values and return a third.

Another form of duplication can arise with tagged unions. What if you want a type for just the tag?

interface SaveAction {
  type: 'save';
  // ...
}
interface LoadAction {
  type: 'load';
  // ...
}
type Action = SaveAction | LoadAction;
type ActionType = 'save' | 'load';  // Repeated types!

You can define ActionType without repeating yourself by indexing into the Action union.

type ActionType = Action['type'];  // Type is "save" | "load

This type is distinct from what you’d get using Pick, which would give you an interface with a type property.

“type ActionRec = Pick<Action, 'type'>;  // {type: "save" | "load"}
interface Options {
  width: number;
  height: number;
  color: string;
  label: string;
}
interface OptionsUpdate {
  width?: number;
  height?: number;
  color?: string;
  label?: string;
}
class UIWidget {
  constructor(init: Options) { /* ... */ }
  update(options: OptionsUpdate) { /* ... */ }
}

You can construct OptionsUpdate from Options using a mapped type and keyof.

type OptionsUpdate = {[k in keyof Options]?: Options[k]};

keyof takes a type and gives you a union of the types of its keys.

type OptionsKeys = keyof Options;
// Type is "width" | "height" | "color" | "label"

You may also find yourself wanting to define a type that matches the shape of a value.

const INIT_OPTIONS = {
  width: 640,
  height: 480,
  color: '#00FF00',
  label: 'VGA',
};
interface Options {
  width: number;
  height: number;
  color: string;
  label: string;
}
type Options = typeof INIT_OPTIONS;

Similarly, you may want to create a named type for the inferred return value of a function or method.

function getUserInfo(userId: string) {
  // ...
  return {
    userId,
    name,
    age,
    height,
    weight,
    favoriteColor,
  };
}
// Return type inferred as { userId: string; name: string; age: number, ... }
type UserInfo = ReturnType<typeof getUserInfo>;

Note that ReturnType operates on typeof getUserInfo, the function’s type, rather than getUserInfo, the function’s value.

type Pick<T, K> = {
  [k in K]: T[k]
     // ~ Type 'K' is not assignable to type 'string | number | symbol'
};

K is unconstrained in this type and is clearly too broad: it needs to be something that can be used as an index, namely, string | number | symbol. But you can get narrower than that —— K should really be some subset of the keys of T, namely, keyof T.

type Pick<T, K extends keyof T> = {
  [k in K]: T[k]
};  // OK

Thinking of types as sets of values (Item 7), it helps to read “extends” as “subset of” here.

As you work with increasingly abstract types, try not to lose sight of the goal: accepting valid programs and rejecting invalid ones.

Repetition and copy/paste coding are just as bad in type space as they are in value space. The constructs you use to avoid repetition in type space may be less familiar than those used for program logic, but they are worth the effort to learn. Don’t repeat yourself!

Item 15: Use Index Signatures for Dynamic Data

const rocket = {
  name: 'Falcon 9',
  variant: 'Block 5',
  thrust: '7,607 kN',
};

type Rocket = {[property: string]: string};
const rocket: Rocket = {
  name: 'Falcon 9',
  variant: 'v1.0',
  thrust: '4,940 kN',
};  // OK

The [property: string]: string is the index signature.

If your type has a limited set of possible fields, don’t model this with an index signature. For instance, if you know your data will have keys like A, B, C, D, but you don’t know how many of them there will be, you could model the type either with optional fields or a union.

interface Row1 { [column: string]: number }  // Too broad
interface Row2 { a: number; b?: number; c?: number; d?: number }  // Better
type Row3 =
    | { a: number; }
    | { a: number; b: number; }
    | { a: number; b: number; c: number;  }
    | { a: number; b: number; c: number; d: number };

If the problem with using an index signature is that string is too broad, then there are a few alternatives.
One is using Record. This is a generic type that gives you more flexibility in the key type. In particular, you can pass in subsets of string.

type Vec3D = Record<'x' | 'y' | 'z', number>;
// Type Vec3D = {
//   x: number;
//   y: number;
//   z: number;
// }

Another is using a mapped type. This gives you the possibility of using different types for different keys.

type Vec3D = {[k in 'x' | 'y' | 'z']: number};
// Same as above
type ABC = {[k in 'a' | 'b' | 'c']: k extends 'b' ? string : number};
// Type ABC = {
//   a: number;
//   b: string;
//   c: number;
// }

Prefer more precise types to index signatures when possible: interfaces, Records, or mapped types.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant