Variables#

Bindings work the same as JavaScript (const, let, var plus block / function scope; see Variables). What TypeScript adds is inference at the binding site and narrowing through control flow.

Inference#

The compiler infers a type at the declaration site from the initializer.

const port = 8080;            // const: literal type `8080`
let   port2 = 8080;           // let:   widened to `number`
const config = {host: "0.0.0.0", port: 80};
//    ^? { host: string; port: number }

const tuple = [1, 2];          // number[], not [number, number]
const tuple2 = [1, 2] as const; // readonly [1, 2]

const keeps the literal type for primitives; let widens to the base type because reassignment must be allowed. For arrays and objects the operator adds as const to keep literal precision.

Annotations#

Annotate when the inferred type would be too wide, when the operator wants to fix the type at the declaration site, or when the initializer is missing.

const handlers: Record<string, Handler> = {};
let result: Result<User, Error>;

function load(): Promise<Config> {
  // ...
}

For function parameters and return types of public APIs, the operator annotates by default. The annotation freezes the contract; inference can shift if internals change.

Narrowing#

The compiler narrows a binding’s type as control flows through checks. After typeof x === "string", the compiler treats x as string in that branch.

function display(x: string | number) {
  if (typeof x === "string") {
    x.toUpperCase();        // x: string here
  } else {
    x.toFixed(2);           // x: number here
  }
}

The compiler also narrows on truthiness, instanceof, in, discriminant checks on a union, and user-defined type guards.

type Shape = {kind: "circle"; r: number} | {kind: "rect"; w: number; h: number};

function area(s: Shape): number {
  switch (s.kind) {
    case "circle": return Math.PI * s.r ** 2;
    case "rect":   return s.w * s.h;
  }
}

const vs let#

Pick const by default; let only when the binding genuinely changes. The type system gains precision when the operator commits to const.

const status = "ok";          // status: "ok" (literal)
let   status2 = "ok";         // status2: string (widened)

if (Math.random() > 0.5) {
  status2 = "fail";           // allowed because let
}

const assertion#

as const freezes the binding and every nested member. Useful for option objects and lookup tables.

const COLORS = ["red", "green", "blue"] as const;
type Color = (typeof COLORS)[number];      // "red" | "green" | "blue"

const ROLES = {admin: 0, user: 1, guest: 2} as const;
type Role = keyof typeof ROLES;            // "admin" | "user" | "guest"

Destructuring with annotations#

Annotations apply to the binding as a whole, not the individual names.

const {host, port}: {host: string; port: number} = config;
const [first, ...rest]: [string, ...number[]] = list;

The clearer form is usually to annotate the source.

const config: Config = load();
const {host, port} = config;        // inferred per the source

Globals and ambient#

Globals live on globalThis. The operator declares ambient structures inside a .d.ts file so the compiler knows about them.

// env.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      readonly DATABASE_URL: string;
      readonly API_KEY?: string;
    }
  }
}
export {};                            // make this a module

References#