Skip to content

Effective TypeScript

Kyle Coberly edited this page Mar 30, 2024 · 3 revisions

Effective TypeScript

  • The TS compiler typechecks and transpiles
  • Code with compiler errors generally still compiles
  • Types don't exist at runtime (but classes do)
  • Types are "erasable", meaning they're removed at compilation
  • Type operations can't convert runtime types
  • TS types don't affect runtime performance
  • TS's types are "open", not "sealed"/"precise". This means functions can be called with more than what's defined, including objects with additional properties.

Structural typing advantage: Define a narrower interface than you'll likely use to make it easier to mock in testing.

type Database = {
  runQuery: (query: string) => unknown[];
}
function listBooks(database: Database){ // Use this instead of the full type with everything it might have.
  return database.runQuery(someQuery);
}
  • any is bad because it's:
    • Not type-checked
    • Turns off type-checking for anything that uses the value (allowing it to break contracts)
    • Turns off language server features for text editors
    • Hides your type design
  • TypeScript comes with two executables: tsc (the compiler) and tsserver (the language server editors use)
  • Use tsserver to help figure out what type TS thinks something is. This is done by mousing over in VS Code or using the show documentation command in other editors.
  • When you want to know what types something takes, use go to definition
  • A type is a set of possible values (domain). never has no values, literals (also called "unit types") have one value, unknown is the universal set
  • "Assignable" either means "member of" (for values) or "subset of" (for types). "Extends" also means "subset of."
  • An intersection type is the union of the properties of the objects

Differences between type and value code:

  • Symbols after type or interface are usually type space code, symbols after const or let value space code. Sometimes you can alternate between them (:, as).
  • instanceof is value-level code
  • typeof only works on values, but works in type-level or value-level code. In type-level code, it takes a value and returns its TS type. With value-level code, it returns its JS type.
  • class is a type and a value
  • The property accessor [] in value-level code gives you the value of a key, in type-level code it gives you the type of a key.
  • & and | are bitwise operators in value-level, intersection and union in type-level
  • this is the current context in JavaScript, it's the TS type of the this value
  • as const narrows the inferred types of an object
  • extends subclasses in value space, but either makes something a subset or places a constraint on a generic (eg list<SomeResponse extends Response> in type-level code
  • in is either looping over keys in value-level code or creating a mapped type in type-level code
  • ! as a prefix is boolean negation, ! as a suffix is a non-null assertion and takes null out of the type

  • Type declarations and type assertions achieve the same thing, but one makes your code typesafe and the other overrules the typechecker
  • Use return types to cast a return value as a type, not as!
  • as will cast if one type is a subset of another, but not otherwise.
  • Excess property checking only happens if you create an object literal and then try to widen it. This is to help catch things like property misspellings, but isn't normally part of structural type checking. To get around this when needed, you can create the wider object and then assign it to a narrower type. You can also opt out of this by specifying additional unknown properties:
const someObject = {
  someKnownProperty: number;
  someOptionalProperty?: boolean;
  [someUnknownProperty: string]: string;
}
  • TS's type-system isn't "sound" (prevents all run-time errors). Reason and Elm are.
  • Use function signatures for function expressions instead of function declarations with inline types
  • Use typeof to extend types:
const fetchPokemon: typeof fetch = fetch(whatever)
  • Don't use the I prefix for interfaces
  • The difference between type aliases and interfaces:
    • Only type aliases can be compound (unions and intersections)
    • Only type aliases can use advanced features like conditional and mapped types
    • Only interfaces can use "declaration merging" (declaring the same interface multiple times to augment it). This gets used if someone needs to add something to your interface.
  • To factor out repetition in your code, use named types, unions, intersections, mapped types:
type SomeObjectType = {
  [T in "someProperty" | "someOtherProperty"]: SomeOtherObjectType[T];
}
// or
type SomeObjectType = Pick<SomeOtherObjectType, "someProperty" | "someOtherProperty">
  • Mapped types are the equivalent of looping over a type. Pick is the built-in for that.
  • Indexed types pull only the values out of a type:
type AllPossibleValues = SomeObjectType["someProperty"]
  • You can get a union of all of the keys of a type with keyof:
type SomeObjectType = {
  [T in keyof SomeOtherObjectType]?: SomeOtherObjectType[T]; // Makes all of the types optional
}
  • Generics are functions for types. You can constrain them with extends. Do this to ensure that the type is at least something. It shouldn't be possible to pass in an illegal generic.
  • Types should be as DRY as code
  • An index signature is used for dynamic properties of objects:
type SomeType = {
  [someProperty: string]: string | boolean | whatever;
}
  • There are almost always better alternatives to index signatures. They're always optional, they're very widely typed, the language server can't help you much. Use them for truly unknown dynamic data (like reading a CSV).
  • For associative arrays, use the built-in Map.
  • For generic structured collections of data, use Record
  • Don't use numbers as index types, they always get coerced to strings at runtime. Use arrays instead.
  • If an array shouldn't be mutable, make it readonly: const ages: readonly number[] = [1, 2, 3]
  • Function parameters that aren't mutated should be marked as readonly
  • DeepReadonly is in ts-essentials
  • Mapped types can be used to keep values and types that evolve over time synchronized
  • Rely on type inference as much as possible. Places TS can't infer the type:
    • Function parameters where the function type isn't declared (either explicitly or because it's a callback)
    • Function parameters that don't have a default type
  • Places you should annotate the type anyway:
    • Declaring object literals (takes advantage of excess property checking)
    • Return types (Helps consumers type correctly, protects against implementation errors)
  • In TS, a variable's value can change but its type generally does not. If you're in a situation where you would need to change the type or make it a union, you should probably just make a new variable.
  • You usually have to check what a union type is before you can do anything with it
  • At runtime, a variable has a single value; at static type-checking time, it has a set of possible values
  • When you give a variable a value, TS needs to decide what set of values should be in its type. This process is called widening.
  • const makes something a literal type, but that doesn't work on arrays and objects. Those are inferred pretty widely (as if they were defined with let) unless you use as const. as const can be used on individual values or on the entire object.
  • Types are narrowed (such as removing null from a type) with conditionals. Throwing or returning if something is a type also narrows it for the rest of the body. Tools for narrowing:
    • instanceof - if (someObject instanceof SomeJSType)
    • typeof - if (typeof someObject === SomeJSType)
    • in - if (someKey in someObject)
    • Discriminated/tagged unions - if (someObject.kind === "some category")
    • User-defined type guard (used to preserve narrowing in functional chains) - function isSomething(maybeSomething: Anything): maybeSomething is Something { return maybeSomething.kind === "something" }
  • Watch out for trying to narrow by asserting a falsy value (eg if (!someVariable)). 0, "", and false are all falsy, so you can't do this to get number, string, or boolean out of a type.
  • Don't build objects in steps - build them all at once, using tools like spread as needed
  • You can build objects with conditional types with boolean operators and spread {...(someFlag ? { someProperty: true } : {})}
  • Use async for any async function, even if you're not using await
  • Types are generally determined when they're introduced, not when they're used
  • Try to make errors happen at the definition site, not the call site (eg, by annotating return types of functions)
  • Lodash's Dictionary<SomeType> type is the same as Record<string, string>
  • Don't allow any invalid types (eg there can be a combination of properties that result in illegal values). Use discriminated unions to model discrete correct states instead.
  • It's normal to be looser with input types to a function but stricter with output types
  • Don't put type information in comments, it can and will get out of sync

Clone this wiki locally