LEVEL 5 · LESSON 4 OF 21

Everyday types Foundation

Type inference in depth

See how TypeScript works out the types you never wrote (literals, widening, contextual typing, inferred returns) and decide where an annotation pays off.

  • 40 min to read and try
  • You need: Basic types
  • You build: An order-pricing module where every annotation is there for a reason, and every inferred type is one you can predict
Test yourself

BY THE END OF THIS LESSON YOU CAN

  • Predict the type TypeScript infers for a variable, object, array or function
  • Explain widening and stop it with const, as const or an annotation
  • Use contextual typing, and notice when a callback loses it
  • Decide which values to annotate and which to leave to inference
  • Choose string over String, and explain the difference at runtime

The order the compiler refused

A shop has a small order module. An order's status is one of three words, and ship only accepts orders with one of them. You build an order and pass it in:

ship.ts
type OrderStatus = "pending" | "paid" | "shipped";

function ship(order: { id: number; status: OrderStatus }): string {
  return `order #${order.id} was ${order.status}, now shipped`;
}

const order = { id: 1042, status: "paid" };
console.log(ship(order));
What npx tsc --noEmit prints
ship.ts:8:18 - error TS2345: Argument of type '{ id: number; status: string; }' is not assignable to parameter of type '{ id: number; status: OrderStatus; }'.
  Types of property 'status' are incompatible.
    Type 'string' is not assignable to type 'OrderStatus'.

8 console.log(ship(order));
                   ~~~~~


Found 1 error in ship.ts:8

That is odd. The status is "paid", which is allowed. And if you write the same object straight into the call, it compiles:

ship.ts
type OrderStatus = "pending" | "paid" | "shipped";

function ship(order: { id: number; status: OrderStatus }): string {
  return `order #${order.id} was ${order.status}, now shipped`;
}

console.log(ship({ id: 1042, status: "paid" }));
Output of npx tsx ship.ts and of the browser terminal
order #1042 was paid, now shipped

Same value, different result. The difference is not in the value but in how TypeScript worked out its type. In the first version, status was given the type string, which is wider than OrderStatus. In the second, it was given "paid". This lesson explains the rules that decide that, so this kind of error stops being a surprise. You met inference briefly in Why TypeScript exists and Basic types; here you learn how it really works.

Inferred and annotated types

Type inference is TypeScript working out a type from the code around it, usually from the value you assign. An annotation is a type you write yourself, after a colon. When a variable has an annotation, that is its declared type, and it wins:

  • With an annotation, TypeScript checks the value against it, and from then on the variable has the annotated type, even if the value was more specific.
  • Without one, TypeScript describes the value, and the description becomes the type.

Seeing an inferred type

In an editor, hold the mouse over a name and it shows the type. Without an editor, there is a trick worth knowing: assign the value to a variable of type never. Nothing can be assigned to never, so the compiler refuses, and its error message names the type it inferred. Here it reveals four types at once:

probe.ts
const currency = "NGN";
let fee = 50;
const declaredFee: number = 50;
const order = { id: 1042, status: "paid" };

const probe1: never = currency;
const probe2: never = fee;
const probe3: never = declaredFee;
const probe4: never = order;
What npx tsc --noEmit prints
probe.ts:6:7 - error TS2322: Type '"NGN"' is not assignable to type 'never'.

6 const probe1: never = currency;
        ~~~~~~

probe.ts:7:7 - error TS2322: Type 'number' is not assignable to type 'never'.

7 const probe2: never = fee;
        ~~~~~~

probe.ts:8:7 - error TS2322: Type 'number' is not assignable to type 'never'.

8 const probe3: never = declaredFee;
        ~~~~~~

probe.ts:9:7 - error TS2322: Type '{ id: number; status: string; }' is not assignable to type 'never'.

9 const probe4: never = order;
        ~~~~~~


Found 4 errors in the same file, starting at: probe.ts:6
  • currency is a const holding a string, so its type is the literal type "NGN": a type with exactly one value. A const can never change, so the most precise type is safe.
  • fee is a let. It may be changed later, so TypeScript gives it the general type number.
  • declaredFee holds the same 50 as a const would, but its annotation says number, and the annotation wins.
  • order is a const, but its properties are not: you can still write order.status = "refunded". So status becomes string. That is the answer to the opening problem.

TIP

Use the never probe while you learn, then delete it. The error text is the compiler's own description of your value, which is more reliable than any guess.

Literal types and widening

Turning a literal type such as "paid" into the general type string is called widening. TypeScript widens whenever a value might change later:

CodeInferred typeWhy
const c = "NGN""NGN"A const binding never changes
let c = "NGN"stringIt may be reassigned
const o = { status: "paid" }{ status: string }Properties can be reassigned
const a = ["NGN", "USD"]string[]Elements can be replaced and pushed
let n = null or let n;Follows each assignmentNothing to infer from (see below)

Widening is usually what you want: a let total = 0 that could only ever hold 0 would be useless. It goes wrong when the exact value matters, as with a status. There are three ways to keep it.

1. Annotate with the type you mean

Give the object a named type. The annotation is checked against the value, and status then has type OrderStatus:

fix.ts
type OrderStatus = "pending" | "paid" | "shipped";

interface Order {
  id: number;
  status: OrderStatus;
}

function ship(order: Order): string {
  return `order #${order.id} was ${order.status}, now shipped`;
}

const order: Order = { id: 1042, status: "paid" };
console.log(ship(order));

order.status = "shipped";
console.log(order.status);
Output of npx tsx fix.ts and of the browser terminal
order #1042 was paid, now shipped
shipped

This is the usual fix, and the best one when the type has a name in your domain. It also keeps status changeable, but only to valid values.

2. as const: freeze the value's type

Writing as const after a value is a const assertion. It stops widening at every level, and makes every property and array readonly:

as-const.ts
const order = { id: 1042, status: "paid" } as const;
const currencies = ["NGN", "USD"] as const;

const probe1: never = order;
const probe2: never = currencies;

order.status = "shipped";
currencies.push("GBP");
What npx tsc --noEmit prints
as-const.ts:4:7 - error TS2322: Type '{ readonly id: 1042; readonly status: "paid"; }' is not assignable to type 'never'.

4 const probe1: never = order;
        ~~~~~~

as-const.ts:5:7 - error TS2322: Type 'readonly ["NGN", "USD"]' is not assignable to type 'never'.

5 const probe2: never = currencies;
        ~~~~~~

as-const.ts:7:7 - error TS2540: Cannot assign to 'status' because it is a read-only property.

7 order.status = "shipped";
        ~~~~~~

as-const.ts:8:12 - error TS2339: Property 'push' does not exist on type 'readonly ["NGN", "USD"]'.

8 currencies.push("GBP");
             ~~~~


Found 4 errors in the same file, starting at: as-const.ts:4

currencies became a readonly tuple of two exact strings, and order an object whose properties are fixed. Use as const for values that really never change: lists of currencies, roles, config defaults. Do not use it for an order whose status is supposed to move on. as const is not a type assertion that lies (like as Order, which you meet in Type assertions); it only stops widening.

3. Let the expected type guide the literal

The inline call ship({ id: 1042, status: "paid" }) compiled because the object was written in a place where TypeScript already knew what it should be: a parameter of type { id: number; status: OrderStatus }. That is contextual typing, and it gets its own section below.

null, and variables without a value

Two more cases catch people. A let that starts as null, or starts with no value at all, has nothing useful to infer from. TypeScript then lets its type evolve: after each assignment, the variable has the type of what was last assigned. That works in straight-line code, but a function that reads the variable later cannot know which assignment happened, so there it becomes any:

nulls.ts
let approvedBy = null;

let total;
total = 2500;
total = "2,500";
const probe: never = total;

function report(): string {
  return `approved by ${approvedBy}, total ${total}`;
}
What npx tsc --noEmit prints
nulls.ts:1:5 - error TS7034: Variable 'approvedBy' implicitly has type 'any' in some locations where its type cannot be determined.

1 let approvedBy = null;
      ~~~~~~~~~~

nulls.ts:3:5 - error TS7034: Variable 'total' implicitly has type 'any' in some locations where its type cannot be determined.

3 let total;
      ~~~~~

nulls.ts:6:7 - error TS2322: Type 'string' is not assignable to type 'never'.

6 const probe: never = total;
        ~~~~~

nulls.ts:9:25 - error TS7005: Variable 'approvedBy' implicitly has an 'any' type.

9   return `approved by ${approvedBy}, total ${total}`;
                          ~~~~~~~~~~

nulls.ts:9:46 - error TS7005: Variable 'total' implicitly has an 'any' type.

9   return `approved by ${approvedBy}, total ${total}`;
                                               ~~~~~


Found 5 errors in the same file, starting at: nulls.ts:1

The probe shows that total quietly became a string after holding a number. And inside report, both variables are any, which strict reports (TS7034 and TS7005). Annotate both, so the declaration says what the variable may hold:

nulls.ts
let approvedBy: string | null = null;
approvedBy = "Ada";

let total: number;
total = 2500;
console.log(approvedBy, total);
Output of npx tsx nulls.ts and of the browser terminal
Ada 2500

Now total = "2,500" would be an error, and approvedBy says what it may hold: a name, or nothing yet.

Arrays, conditionals and mixed values

When an expression has several possible values, TypeScript combines their types. For an array, it looks at every element and builds the best common type: a type that fits them all, which is often a union. For a conditional expression (a ? b : c), the type is the union of both branches:

mixed.ts
const amounts = [2500, 700, 12000];
const lines = [2500, "delivery"];
const fee = Math.random() > 0.5 ? 10 : "free";
const pending = [];
pending.push({ id: 1, amount: 2500 });

const probe1: never = amounts;
const probe2: never = lines;
const probe3: never = fee;
const probe4: never = pending;
What npx tsc --noEmit prints
mixed.ts:7:7 - error TS2322: Type 'number[]' is not assignable to type 'never'.

7 const probe1: never = amounts;
        ~~~~~~

mixed.ts:8:7 - error TS2322: Type '(string | number)[]' is not assignable to type 'never'.

8 const probe2: never = lines;
        ~~~~~~

mixed.ts:9:7 - error TS2322: Type '"free" | 10' is not assignable to type 'never'.
  Type '"free"' is not assignable to type 'never'.

9 const probe3: never = fee;
        ~~~~~~

mixed.ts:10:7 - error TS2322: Type '{ id: number; amount: number; }[]' is not assignable to type 'never'.

10 const probe4: never = pending;
         ~~~~~~


Found 4 errors in the same file, starting at: mixed.ts:7
  • amounts: all numbers, so number[], widened as usual.
  • lines: a mix, so (string | number)[]. Every element must now be checked before use. If you meant a fixed pair, use a tuple type (Tuples).
  • fee: a union of both branches, and the literals are kept because the variable is a const. (TypeScript 7 may list the members of a union in a different order from the one you wrote; the order has no meaning.)
  • pending: an empty array starts as an "evolving" array, and its type grows with each push. Inside one function that works, but the type of a list should not depend on the first thing someone pushes. Write const pending: Transfer[] = [].

Objects returned from different branches are combined too, and the result can look strange:

shapes.ts
function check(balance: number, amount: number) {
  return balance >= amount ? { ok: true, left: balance - amount } : { ok: false, reason: "insufficient funds" };
}

const probe: never = check;
What npx tsc --noEmit prints
shapes.ts:5:7 - error TS2322: Type '(balance: number, amount: number) => { ok: boolean; left: number; reason?: undefined; } | { left?: undefined; ok: boolean; reason: string; }' is not assignable to type 'never'.

5 const probe: never = check;
        ~~~~~


Found 1 error in shapes.ts:5

TypeScript made both shapes the same by adding left?: undefined and reason?: undefined, and widened true and false to boolean. That type is hard to read and does not let callers tell the two cases apart by ok. When a function's result has cases, name them yourself as a union, as you will do in Union types in depth.

Contextual typing: types that flow inward

So far the type flowed from the value to the variable. Contextual typing is the other direction: the place where an expression is written already has an expected type, and TypeScript uses it to type the expression. You saw it with callbacks in Typing functions. It happens in many places:

context.ts
type OrderStatus = "pending" | "paid" | "shipped";

interface Order {
  id: number;
  status: OrderStatus;
  total: number;
}

const orders: Order[] = [
  { id: 1, status: "paid", total: 12000 },
  { id: 2, status: "pending", total: 4500 },
];

const paidTotal = orders.filter((o) => o.status === "paid").reduce((sum, o) => sum + o.total, 0);

const labels: Record<OrderStatus, (id: number) => string> = {
  pending: (id) => `#${id} waiting for payment`,
  paid: (id) => `#${id} ready to ship`,
  shipped: (id) => `#${id} on its way`,
};

console.log(paidTotal);
console.log(orders.map((o) => labels[o.status](o.id)));
Output of npx tsx context.ts and of the browser terminal
12000
[ '#1 ready to ship', '#2 waiting for payment' ]

Not one callback parameter is annotated, and all are checked:

  • The array elements are object literals written where an Order is expected, so "paid" stays a literal and is checked against OrderStatus. That is why the inline ship call at the top worked.
  • (o) => … inside filter, reduce and map get o: Order from the array's type, and sum gets number from the starting value 0.
  • Each handler in labels gets id: number from the Record type. And Record<OrderStatus, …> requires one handler per status: forget shipped and it is an error.

Where the context gets lost

Context only reaches an expression written in the place that has the expected type. Move a callback out into its own variable, and it has no context any more:

lost.ts
interface Order {
  id: number;
  total: number;
}

const orders: Order[] = [{ id: 1, total: 12000 }];

const isLarge = (o) => o.total > 10000;
console.log(orders.filter(isLarge));
What npx tsc --noEmit prints
lost.ts:8:18 - error TS7006: Parameter 'o' implicitly has an 'any' type.

8 const isLarge = (o) => o.total > 10000;
                   ~


Found 1 error in lost.ts:8

When isLarge is declared, TypeScript does not yet know it will be passed to filter. You have two good fixes: annotate the parameter, (o: Order) => …, or give the variable a function type, const isLarge: (o: Order) => boolean = (o) => …, which provides the context again.

Inferred return types

TypeScript infers a function's return type from its return statements. That is convenient, and it is also where inference can hide a bug. Here are two versions of a discount function; both forget orders under ₦5,000:

discount.ts
function discountInferred(total: number) {
  if (total >= 50000) return 0.1;
  if (total >= 5000) return 0.05;
}

function discountAnnotated(total: number): number {
  if (total >= 50000) return 0.1;
  if (total >= 5000) return 0.05;
}

const price = 3000;
const toPay = price - price * discountInferred(price);
What npx tsc --noEmit prints
discount.ts:6:44 - error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

6 function discountAnnotated(total: number): number {
                                             ~~~~~~

discount.ts:12:31 - error TS2532: Object is possibly 'undefined'.

12 const toPay = price - price * discountInferred(price);
                                 ~~~~~~~~~~~~~~~~~~~~~~~


Found 2 errors in the same file, starting at: discount.ts:6

Read where each error points:

  • discountInferred compiled. Its inferred return type silently became number | undefined, and the error appears at the caller, on the last line. In a real project, that caller may be in another file, written by someone else, who has no idea why undefined is possible.
  • discountAnnotated is reported inside the function, at the return type (TS2366, from Typing functions). The annotation is a promise, and the compiler held the function to it.

An inferred return type also changes whenever the body changes. If a teammate adds return "free" for VIP customers, the inferred type becomes number | "free" | undefined, and every caller in the project might break, or worse, keep compiling while doing the wrong thing. For an exported function, the return type is part of your public API; write it down so it changes only when you mean it to.

When an annotation makes things worse

Annotations are not automatically safer. An annotation that is wider than the value throws information away. A common case is a lookup table typed as Record<string, number>:

fees.ts
const fees: Record<string, number> = { transfer: 10, withdrawal: 25 };

const charged = fees.tranfer;
console.log(charged, 5000 + charged);
Output of npx tsx fees.ts and of the browser terminal
undefined NaN

No error, and a NaN in your accounts. Record<string, number> says "any string key gives a number", so the typo tranfer is a perfectly good key as far as the compiler knows. Two better options:

  • No annotation. const fees = { transfer: 10, withdrawal: 25 } is inferred with exactly those two keys, so fees.tranfer is an error.
  • satisfies. Checks the value against a type without replacing the inferred type. You get both: the check that every fee is a number, and the exact keys.
fees.ts
const fees = { transfer: 10, withdrawal: "25" } satisfies Record<string, number>;

const charged = fees.tranfer;
What npx tsc --noEmit prints
fees.ts:1:30 - error TS2322: Type 'string' is not assignable to type 'number'.

1 const fees = { transfer: 10, withdrawal: "25" } satisfies Record<string, number>;
                               ~~~~~~~~~~

fees.ts:3:22 - error TS2551: Property 'tranfer' does not exist on type '{ transfer: number; withdrawal: string; }'. Did you mean 'transfer'?

3 const charged = fees.tranfer;
                       ~~~~~~~

  fees.ts:1:16 - 'transfer' is declared here.
    1 const fees = { transfer: 10, withdrawal: "25" } satisfies Record<string, number>;
                     ~~~~~~~~~~~~


Found 2 errors in the same file, starting at: fees.ts:1

Both mistakes are caught: the fee written as text, and the misspelt key. Advanced inference compares satisfies, annotations and as in depth.

Deciding what to annotate

REASON IT OUT

Annotate or infer?

You are writing pricing.ts for the shop. Before you read the answer, decide for each of these whether you would write a type, and why:

  1. The parameters of the exported priceOrder(items, coupon).
  2. Its return value, a { subtotal, discount, total } object.
  3. const VAT_RATE = 0.075 at the top of the file.
  4. const lines = [], filled in a loop later.
  5. The callback in items.map((item) => item.price * item.quantity).
  6. A table of coupon codes to percentages, used as coupons[code].
Show the reasoning
  1. Annotate. Parameters have no value to infer from (without context, TypeScript reports TS7006). They are also the contract with every caller.
  2. Annotate, with a named interface such as OrderPrice. It is exported, so its type is public API. The annotation catches a missing field inside the function, and a later change to the body cannot silently change what callers receive.
  3. Infer. The value says it all. The inferred type is the literal 0.075, which is even more precise than number.
  4. Annotate: const lines: InvoiceLine[] = []. An empty array gives inference nothing to work with.
  5. Infer. Contextual typing gives item the element type of items. An annotation would only repeat it.
  6. It depends on the question you ask. For a fixed set of codes known at compile time, infer (or use satisfies) so typos are caught. For codes loaded from a database at runtime, the keys really are any string: annotate Record<string, number>, and handle the missing case, because coupons[code] may be undefined even though the type says number.

The rule of thumb from the answers: annotate at boundaries, infer inside. Boundaries are where your code meets other code or other people: function parameters, exported functions' return types, public class members, and empty containers. Inside a function body, let inference do the work.

SituationAnnotate?Reason
Function parametersYesNothing to infer from; it is the contract
Return type of an exported functionYesErrors stay inside the function; the API cannot change by accident
Return type of a small local helperOptionalInference is fine when every caller is on the same screen
const with a valueNoInference is exact
let starting with null, or with no valueYesOtherwise the type changes with each assignment, and is any inside functions
Empty array or new Map()YesNothing to infer from
Object that must match a domain typeYes (or satisfies)Stops widening, catches missing and extra properties
Callback passed inlineNoContextual typing does it

Here is the pricing module written that way. Count the annotations: every one is at a boundary.

pricing.ts
export interface OrderItem {
  name: string;
  price: number;
  quantity: number;
}

export interface OrderPrice {
  subtotal: number;
  discount: number;
  vat: number;
  total: number;
}

const VAT_RATE = 0.075;
const coupons = { WELCOME10: 0.1, STAFF25: 0.25 } satisfies Record<string, number>;
type CouponCode = keyof typeof coupons;

export function priceOrder(items: readonly OrderItem[], coupon?: CouponCode): OrderPrice {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const discount = coupon === undefined ? 0 : Math.round(subtotal * coupons[coupon]);
  const vat = Math.round((subtotal - discount) * VAT_RATE);
  return { subtotal, discount, vat, total: subtotal - discount + vat };
}

const basket: OrderItem[] = [
  { name: "Rice 5kg", price: 9500, quantity: 1 },
  { name: "Palm oil 1L", price: 2800, quantity: 2 },
];

console.log(priceOrder(basket));
console.log(priceOrder(basket, "WELCOME10"));
Output of npx tsx pricing.ts and of the browser terminal
{ subtotal: 15100, discount: 0, vat: 1133, total: 16233 }
{ subtotal: 15100, discount: 1510, vat: 1019, total: 14609 }

keyof typeof coupons turns the table's keys into the union "WELCOME10" | "STAFF25", so priceOrder(basket, "WELCOM10") would not compile (Advanced and utility types explains keyof and typeof). That only works because coupons was not annotated with Record<string, number>.

string, not String

You were told in Basic types to write string, not String. Here is why. JavaScript has primitive strings, numbers and booleans, and also wrapper objects for each, made with new String(…), new Number(…) and new Boolean(…). The wrappers behave differently in ways that cause real bugs:

wrappers.js
const code = "NGN";
const wrapped = new String("NGN");

console.log(typeof code, typeof wrapped);
console.log(code === "NGN", wrapped === "NGN");
console.log(new String("NGN") === new String("NGN"));

const blocked = new Boolean(false);
if (blocked) console.log("account treated as blocked!");
Output of node wrappers.js and of the browser terminal
string object
true false
false
account treated as blocked!

A wrapper is an object, so === compares identity, not text, and every object is truthy, even a Boolean that holds false. In TypeScript, the lowercase string is the primitive type, and the capitalised String is the type of the wrapper object (and also of anything that has the string methods, which includes primitives). So the two are not the same:

wrappers.ts
function formatCode(code: String): string {
  return code.toUpperCase();
}

const fromForm = new String("ngn");
const cleaned: string = fromForm;
console.log(formatCode("usd"), cleaned);
What npx tsc --noEmit prints
wrappers.ts:6:7 - error TS2322: Type 'String' is not assignable to type 'string'.
  'string' is a primitive, but 'String' is a wrapper object. Prefer using 'string' when possible.

6 const cleaned: string = fromForm;
        ~~~~~~~


Found 1 error in wrappers.ts:6

A String parameter accepts both primitives and wrapper objects, so wrapper objects can flow into your code unnoticed. A string parameter accepts only real strings, and the compiler even explains the difference. The rule has no exceptions in everyday code: string, number, boolean, bigint, symbol, always lowercase. The same goes for Object, which Special types covers next to object and {}.

Testing inferred types

Inferred types change when code changes. When a type matters, for example that an order's status stays a union and does not quietly widen to string, you can pin it down with a type test. You met // @ts-expect-error in What the TypeScript compiler does; here it guards inference:

order.types.ts
type OrderStatus = "pending" | "paid" | "shipped";

interface Order {
  id: number;
  status: OrderStatus;
}

function createOrder(id: number) {
  return { id, status: "pending" as OrderStatus };
}

const order = createOrder(1);

const statusIsNarrow: OrderStatus = order.status;
const fitsOrder: Order = order;

// @ts-expect-error: "refunded" is not a status
order.status = "refunded";

console.log(statusIsNarrow, fitsOrder.id);
Output of npx tsx order.types.ts and of the browser terminal
pending 1

If someone later removes as OrderStatus from createOrder, the status widens to string. Then statusIsNarrow fails to compile, and the @ts-expect-error becomes unused and fails too. npm run check catches the change before anyone ships it. (Here as OrderStatus is safe because "pending" really is a status; it would be cleaner still to annotate the return type as Order, which is the lesson of the previous section.)

In production code

  • Annotate every exported function's parameters and return type. The isolatedDeclarations setting in tsconfig.json enforces this, so that tools can write .d.ts files for each file without running the whole checker. Large monorepos turn it on for faster builds.
  • Name your domain types (Order, OrderStatus) and annotate objects that must match them, instead of relying on a literal staying narrow.
  • Do not annotate what inference already says exactly. const total: number = subtotal + vat adds noise and can hide a more precise type.
  • Be careful with Record<string, …> for data whose keys you know. It turns typos into undefined at runtime.
  • Inference never looks at runtime data. The type of JSON.parse(body) is any however carefully you annotate around it. Validation is a separate job (Runtime validation).

Practice

TRY IT YOURSELF

Make the config compile

This payment config does not compile when it is passed to connect. Explain why, then fix it in two different ways: once with an annotation, once without one.

config.ts
type Provider = "paystack" | "flutterwave";

interface GatewayConfig {
  provider: Provider;
  timeoutMs: number;
}

function connect(config: GatewayConfig): string {
  return `${config.provider} (${config.timeoutMs}ms)`;
}

const config = { provider: "paystack", timeoutMs: 5000 };
console.log(connect(config));
What npx tsc --noEmit prints
config.ts:13:21 - error TS2345: Argument of type '{ provider: string; timeoutMs: number; }' is not assignable to parameter of type 'GatewayConfig'.
  Types of property 'provider' are incompatible.
    Type 'string' is not assignable to type 'Provider'.

13 console.log(connect(config));
                       ~~~~~~


Found 1 error in config.ts:13
Show a solution

config is inferred on its own line, before TypeScript knows it will go to connect, so provider widens to string. Fix it by annotating, or by keeping the literal with as const:

config.ts
type Provider = "paystack" | "flutterwave";

interface GatewayConfig {
  provider: Provider;
  timeoutMs: number;
}

function connect(config: GatewayConfig): string {
  return `${config.provider} (${config.timeoutMs}ms)`;
}

const annotated: GatewayConfig = { provider: "paystack", timeoutMs: 5000 };
const frozen = { provider: "flutterwave", timeoutMs: 8000 } as const;

console.log(connect(annotated));
console.log(connect(frozen));
Output of npx tsx config.ts and of the browser terminal
paystack (5000ms)
flutterwave (8000ms)

The annotation is the better default: the object is checked where it is written, including missing and misspelt properties. as const suits a value that never changes, and also makes it readonly.

TRY IT YOURSELF

Move the error to where the bug is

This compiles, but prints something wrong. Find the bug, then add the one annotation that makes the compiler point at it, and fix it.

shipping.ts
function shippingFee(state: string, weightKg: number) {
  if (state === "Lagos") return weightKg <= 5 ? 1500 : 3000;
  if (state === "Abuja") return weightKg <= 5 ? 2500 : 4500;
}

const fee = shippingFee("Kano", 3);
console.log(`Shipping: ₦${fee}`);
Output of npx tsx shipping.ts and of the browser terminal
Shipping: ₦undefined
Show a solution

For every state except Lagos and Abuja the function falls off the end, so its inferred return type is number | undefined, and the template string happily prints undefined. Annotate the return type as number and TS2366 appears inside shippingFee. Then add the missing case:

shipping.ts
function shippingFee(state: string, weightKg: number): number {
  if (state === "Lagos") return weightKg <= 5 ? 1500 : 3000;
  if (state === "Abuja") return weightKg <= 5 ? 2500 : 4500;
  return weightKg <= 5 ? 3500 : 6000;
}

const fee = shippingFee("Kano", 3);
console.log(`Shipping: ₦${fee}`);
Output of npx tsx shipping.ts and of the browser terminal
Shipping: ₦3500

TRY IT YOURSELF

Handlers without annotations

Write a notifiers object with one function per channel, "sms", "email" and "push", each taking (to: string, message: string) and returning a string. Type the object once, so that none of the three functions needs a parameter annotation, and so that forgetting a channel is an error.

Show a solution
notify.ts
type Channel = "sms" | "email" | "push";

const notifiers: Record<Channel, (to: string, message: string) => string> = {
  sms: (to, message) => `SMS to ${to}: ${message.slice(0, 20)}`,
  email: (to, message) => `Email to ${to}: ${message}`,
  push: (to, message) => `Push to device ${to}: ${message}`,
};

const channels: Channel[] = ["sms", "email"];
for (const channel of channels) {
  console.log(notifiers[channel]("Ada", "Your transfer of ₦25,000 was successful"));
}
Output of npx tsx notify.ts and of the browser terminal
SMS to Ada: Your transfer of ₦25
Email to Ada: Your transfer of ₦25,000 was successful

The Record<Channel, …> annotation gives every function its parameter types through contextual typing, and requires exactly one entry per channel. Here Record is the right choice, unlike the fee table earlier, because the keys are a fixed union, not string.

Recap

  • Inference describes a value; an annotation declares a type, checks the value against it, and wins. Use a never probe or your editor to see an inferred type.
  • const primitives keep literal types. let variables, object properties and array elements widen, because they can change.
  • Stop widening with an annotation of a named type (usually best), with as const (for values that never change), or by writing the value where an expected type exists.
  • Contextual typing gives inline callbacks and object literals their types. A callback moved into its own variable loses that context.
  • Inferred return types move errors to callers and change with the body. Annotate exported functions' return types.
  • Annotate at boundaries (parameters, exports, empty containers, null starts), infer inside. An annotation that is too wide, like Record<string, number>, throws information away; satisfies checks without widening.
  • Write string, number and boolean, never the wrapper types String, Number and Boolean.

Next: Typing functions, where parameters, return types and callbacks get the full treatment.

Test yourself

Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.