Special types and narrowing Foundation
Special types: any, unknown, never and friends
Learn what any, unknown, never, void, object, {} and Object really mean, watch one any spread, and use unknown at your program's boundaries.
BY THE END OF THIS LESSON YOU CAN
- Place any, unknown and never in the type hierarchy and predict what can be assigned to each
- Trace how a single any spreads, and find where it enters
- Use unknown at every boundary and check it before use
- Explain void, including why a void callback may return a value
- Choose between object, {}, Object and Record<string, unknown>
One any, and a customer is credited ₦100,005,000
A payment provider calls your server whenever a customer tops up their wallet. The request body is JSON. Here is a handler that compiles with strict on and zero errors:
const balances = new Map<string, number>([["WAL-17", 10000]]);
function credit(walletId: string, amount: number): number {
const next = (balances.get(walletId) ?? 0) + amount;
balances.set(walletId, next);
return next;
}
function handleWebhook(body: string): void {
const event = JSON.parse(body);
const newBalance = credit(event.data.walletId, event.data.amount);
console.log(`credited ${event.data.walletId}, balance now ${newBalance}`);
}
handleWebhook('{"type": "topup", "data": {"walletId": "WAL-17", "amount": "5000"}}');
npx tsx webhook.ts and of the browser terminalcredited WAL-17, balance now 100005000
The provider sent the amount as the string "5000". 10000 + "5000" is the string "100005000", and the wallet now "holds" a hundred million naira. credit clearly says amount: number, so how did a string get in without a single error?
The answer is one word: any. JSON.parse is declared to return any, and any tells the compiler to stop checking. Everything computed from it is any too, and any is accepted by a number parameter. This lesson is about the handful of special types that sit outside the everyday ones: any, unknown, never, void, object, {} and Object. You met the first four briefly in Basic types; now you learn exactly how each behaves.
The top and the bottom of the type system
In Union types in depth you learned to see a type as a set of values. Two of the special types are the extremes of that picture:
unknownis the set of every value. Anything can be assigned to it. It is called the top type.neveris the empty set. Nothing can be assigned to it (except anothernever). It is called the bottom type.
unknown every value; you must check before use
┌─────────┼──────────────────┐
string number boolean object null undefined …
│ │ │
"NGN" 42 { id: 1 }, [1, 2], () => 1
└─────────┼──────────────────┘
never no value at all
any: not in the picture. It switches the checks off in both directions.
any does not fit in the picture. It behaves like the top type when you assign to it (anything goes in) and like the bottom type when you assign from it (it goes anywhere, even into a number parameter). That is not a place in the hierarchy; it is an exit from it.
| Can receive any value? | Can be passed where a number is expected? | Can you call methods on it? | |
|---|---|---|---|
unknown | Yes | No, check it first | No, check it first |
any | Yes | Yes, unchecked | Yes, unchecked |
never | No | Yes (there is never a value to pass) | No: "Property … does not exist on type 'never'" |
any, and how it spreads
Look again at the webhook, and ask the compiler what types it inferred. Use the never probe from Type inference in depth:
function amountOf(body: string) {
const event = JSON.parse(body);
return event.data.amount;
}
const amount = amountOf('{"data": {"amount": "5000"}}');
const doubled = amount * 2;
const label = amount.toFixed(2);
const probe1: never = amount;
const probe2: never = doubled;
const probe3: never = label;
npx tsc --noEmit printsspread.ts:10:7 - error TS2322: Type 'any' is not assignable to type 'never'.
10 const probe1: never = amount;
~~~~~~
spread.ts:11:7 - error TS2322: Type 'number' is not assignable to type 'never'.
11 const probe2: never = doubled;
~~~~~~
spread.ts:12:7 - error TS2322: Type 'any' is not assignable to type 'never'.
12 const probe3: never = label;
~~~~~~
Found 3 errors in the same file, starting at: spread.ts:10JSON.parse returned any. Reading .data.amount from an any gives any. The function's inferred return type became any, so a caller in another file now has an any without ever seeing JSON.parse. And amount.toFixed(2) compiled, although the value is a string and will throw at runtime. Only arithmetic produced a real type: amount * 2 is always a number in JavaScript.
That is how any spreads: every property you read, every method you call and every function that returns it passes it on, silently, to code that has no idea it is unchecked.
Where any comes from
| Source | How to stop it |
|---|---|
JSON.parse(text) | const data: unknown = JSON.parse(text) |
await response.json() (the Fetch API) | const data: unknown = await response.json() |
| A parameter without a type | strict (noImplicitAny) makes it an error, TS7006 |
A let with no value, or an empty array, read in another function | Annotate it (TS7034 and TS7005 flag it) |
| A library with no types, or poor ones | Install its @types/… package, or wrap it in a small typed module |
Writing any or as any yourself | Do not; a linter rule (no-explicit-any in typescript-eslint) can forbid it |
strict only stops the implicit cases. An any that comes from a declaration, like JSON.parse's, is explicit, and the compiler accepts it without a word. You have to stop it yourself, at the point where it enters.
Is any ever acceptable?
Rarely, and always in a small, fenced place: while converting an old JavaScript file to TypeScript one piece at a time, or in a test that deliberately passes bad input to check that a function rejects it. Even there, unknown usually works: a test can pass "5000" as unknown as number, which at least says out loud that it is lying.
unknown at the boundaries
A boundary is a place where data enters your program from outside: request bodies, webhooks, files, environment variables, database rows, replies from other services, messages from a queue. The compiler never saw that data; it cannot know its type. unknown says exactly that, and then makes you prove what the value is before you use it:
const event: unknown = JSON.parse('{"data": {"walletId": "WAL-17", "amount": "5000"}}');
const amount = event.data.amount;
console.log(event.toString());
npx tsc --noEmit printsboundary.ts:3:16 - error TS18046: 'event' is of type 'unknown'.
3 const amount = event.data.amount;
~~~~~
boundary.ts:4:13 - error TS18046: 'event' is of type 'unknown'.
4 console.log(event.toString());
~~~~~
Found 2 errors in the same file, starting at: boundary.ts:3You cannot read a property, call a method or pass it on as a number. The only way forward is to check. The checks are ordinary JavaScript: typeof, === null, the in operator and Array.isArray, and TypeScript narrows the type after each one. Narrowing covers every form in detail; here is the pattern you will use most:
interface TopUp {
walletId: string;
amount: number;
}
function readTopUp(input: unknown): TopUp | string {
if (typeof input !== "object" || input === null || !("data" in input)) return "body is not an event";
const data = input.data;
if (typeof data !== "object" || data === null) return "data is not an object";
if (!("walletId" in data) || typeof data.walletId !== "string") return "walletId must be a string";
if (!("amount" in data) || typeof data.amount !== "number") return "amount must be a number";
if (!Number.isInteger(data.amount) || data.amount <= 0) return "amount must be a positive whole number";
return { walletId: data.walletId, amount: data.amount };
}
for (const body of [
'{"data": {"walletId": "WAL-17", "amount": 5000}}',
'{"data": {"walletId": "WAL-17", "amount": "5000"}}',
'[1, 2, 3]',
'null',
]) {
console.log(readTopUp(JSON.parse(body)));
}
npx tsx boundary.ts and of the browser terminal{ walletId: 'WAL-17', amount: 5000 }
amount must be a number
body is not an event
body is not an eventEach if removes a possibility, and by the last line TypeScript knows that data.walletId is a string and data.amount a number. The returned object is built only from checked values, so the TopUp type is now true, not merely hoped for. The string "5000" is rejected with a reason instead of being added to a balance.
Writing these checks by hand gets long for big objects; Runtime validation shows libraries that generate them from a schema. The principle stays: outside data enters as unknown, and leaves the boundary as a checked type.
unknown in catch
Anything can be thrown in JavaScript: an Error, a string, a number, undefined. So with strict on (its useUnknownInCatchVariables flag), the variable in catch (error) is unknown, and you check it like any other boundary:
function describeError(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
return `unexpected value thrown: ${JSON.stringify(error)}`;
}
for (const thrown of [new Error("gateway timeout"), "card declined", 402]) {
try {
throw thrown;
} catch (error) {
console.log(describeError(error));
}
}
npx tsx catch.ts and of the browser terminalgateway timeout card declined unexpected value thrown: 402
never: the empty type
never has no values, and that makes it useful in three ways.
1. Functions that never return
A function that always throws, or loops forever, has the return type never. Because it never returns, TypeScript knows that code after a call to it cannot run, and narrows accordingly:
function fail(message: string): never {
throw new Error(message);
}
function walletBalance(balances: Map<string, number>, walletId: string): number {
const balance = balances.get(walletId);
if (balance === undefined) fail(`unknown wallet ${walletId}`);
return balance;
}
const balances = new Map([["WAL-17", 10000]]);
console.log(walletBalance(balances, "WAL-17"));
try {
walletBalance(balances, "WAL-99");
} catch (error) {
console.log(String(error));
}
npx tsx fail.ts and of the browser terminal10000 Error: unknown wallet WAL-99
After the if, balance is a number: the only path where it was undefined ended in fail, which never comes back.
2. What is left after every check
When you have narrowed away every member of a union, the type that remains is never. That is what powers the exhaustive checks from Union types in depth. It also explains why never disappears from unions: string | never is just string, because adding the empty set to a set changes nothing.
3. The never[] surprise
You will meet never in an error message sooner or later, usually like this:
const cart = { customer: "Ada", items: [] };
cart.items.push({ sku: "RICE-5KG", price: 9500 });
npx tsc --noEmit printscart.ts:3:17 - error TS2345: Argument of type '{ sku: string; price: number; }' is not assignable to parameter of type 'never'.
3 cart.items.push({ sku: "RICE-5KG", price: 9500 });
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Found 1 error in cart.ts:3An empty array inside an object literal has nothing to infer an element type from, and it cannot "evolve" the way a standalone let array does, so its type is never[]: an array that can hold nothing. The fix is the one from Type inference in depth: give the empty container a type, for example with an interface for the cart (items: CartItem[]).
void: "the result is not for you"
void is the return type of a function whose result should not be used. For a function you write, it means "returns nothing", and the compiler holds you to it. For a function type, such as a callback parameter, it means something looser: "I will ignore whatever you return".
function logTransfer(reference: string): void {
return reference.length;
}
type OnSent = (reference: string) => void;
const sentLog: string[] = [];
const remember: OnSent = (reference) => sentLog.push(reference);
const result = remember("TRF-1");
const count: number = result;
npx tsc --noEmit printsvoid.ts:2:3 - error TS2322: Type 'number' is not assignable to type 'void'.
2 return reference.length;
~~~~~~
void.ts:10:7 - error TS2322: Type 'void' is not assignable to type 'number'.
10 const count: number = result;
~~~~~
Found 2 errors in the same file, starting at: void.ts:2logTransferis declared: voidand tries to return a number: an error.rememberis typedOnSent, and its arrow function returns a number (pushreturns the new length). That is allowed: a function that returns something can be used where the result will be ignored. But the result's type isvoid, so the compiler stops you from using it as a number.
The looser rule is what lets you write transfers.forEach((t) => sentLog.push(t)) even though forEach expects a callback returning void. It also hides a classic async bug. An async function returns a promise, and a promise is "something", so it fits a void callback, and nothing waits for it:
async function pay(name: string, amount: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(`paid ${name} ₦${amount}`);
}
const payouts: [string, number][] = [["Ada", 5000], ["Bola", 7500]];
payouts.forEach(async ([name, amount]) => {
await pay(name, amount);
});
console.log("all payouts done");
npx tsx payouts.ts and of the browser terminalall payouts done paid Ada ₦5000 paid Bola ₦7500
"All payouts done" is printed before any payout happens. If the next line closed the database connection, the payouts would fail. forEach ignores the promises its callback returns; that is exactly what void allowed. Use a for…of loop with await (one after another), or await Promise.all(payouts.map(…)) (all at once):
async function pay(name: string, amount: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(`paid ${name} ₦${amount}`);
}
const payouts: [string, number][] = [["Ada", 5000], ["Bola", 7500]];
for (const [name, amount] of payouts) {
await pay(name, amount);
}
console.log("all payouts done");
npx tsx payouts.ts and of the browser terminalpaid Ada ₦5000 paid Bola ₦7500 all payouts done
Linters can catch the first version (typescript-eslint's no-misused-promises rule). The compiler alone cannot, because by the rules of void, the code is correct.
object, {} and Object
Three types sound like "an object", and only one of them means it:
object(lowercase): any non-primitive value: plain objects, arrays, functions, dates, maps. Not strings, numbers, booleans,nullorundefined.{}(the empty object type): any value exceptnullandundefined. That includes42and"hello", because they have no required properties to be missing. It does not mean "an empty object".Object(capital O): nearly the same as{}. It is the type of JavaScript's built-inObject, and likeStringin Type inference in depth, you should not write it.
And the type you usually want is a fourth one: Record<string, unknown>, "an object with string keys whose values I have not checked yet". See what each accepts:
const values = { amount: 5000, currency: "NGN", missing: null, items: [1, 2], wallet: { id: "WAL-17" }, pay: () => true };
const a1: object = values.amount;
const a2: object = values.items;
const a3: object = values.pay;
const b1: {} = values.amount;
const b2: {} = values.currency;
const b3: {} = values.missing;
const c1: Record<string, unknown> = values.wallet;
const c2: Record<string, unknown> = values.items;
const c3: Record<string, unknown> = values.pay;
npx tsc --noEmit printsobjects.ts:3:7 - error TS2322: Type 'number' is not assignable to type 'object'.
3 const a1: object = values.amount;
~~
objects.ts:9:7 - error TS2322: Type 'null' is not assignable to type '{}'.
9 const b3: {} = values.missing;
~~
objects.ts:12:7 - error TS2322: Type 'number[]' is not assignable to type 'Record<string, unknown>'.
Index signature for type 'string' is missing in type 'number[]'.
12 const c2: Record<string, unknown> = values.items;
~~
objects.ts:13:7 - error TS2322: Type '() => boolean' is not assignable to type 'Record<string, unknown>'.
Index signature for type 'string' is missing in type '() => boolean'.
13 const c3: Record<string, unknown> = values.pay;
~~
Found 4 errors in the same file, starting at: objects.ts:3| Value | object | {} / Object | Record<string, unknown> | unknown |
|---|---|---|---|---|
5000, "NGN" | No | Yes | No | Yes |
null, undefined | No | No | No | Yes |
{ id: "WAL-17" } | Yes | Yes | Yes | Yes |
[1, 2] | Yes | Yes | No | Yes |
() => true | Yes | Yes | No | Yes |
The practical rules:
- For "a value I have not checked", use
unknown. - For "a plain object with string keys", use
Record<string, unknown>, or better, a real interface. - For "anything that is not a primitive" (rare: a cache key that must be an object, for example in a
WeakMap), useobject. - Never write
{}orObjectto mean "some object". A functionsave(data: {})happily acceptssave(42).
Build: a webhook handler without any
REASON IT OUT
Before you write the handler
Your wallet service will receive top-up and refund webhooks from a payment provider. Think before you code:
- What do you actually know about the request body when it arrives?
- Which value in this flow is the first one that could be
any? - What should happen when an event type you do not know arrives, such as a new
"chargeback"? - What should the handler return, so the caller can reply to the provider correctly?
Show the reasoning
- Only that it is a string of text, if even that. It may not be JSON, may be JSON of the wrong shape, may be a replayed old event, and may not even come from the provider. (Checking the provider's signature is a security job covered in the ZudoJS security lesson; this lesson handles the shape.)
- The result of
JSON.parse. Annotate it asunknownon the same line, andanynever gets a chance to spread. - Do not guess. Reject it with a clear reason, and make sure the compiler forces a decision when you add a new type yourself: an exhaustive
switchwithnever. - A result union, not a thrown error:
{ ok: true, … }or{ ok: false, reason }. A rejected webhook is an expected outcome, and the reason helps whoever reads the logs.
export type WalletEvent =
| { type: "topup"; walletId: string; amount: number }
| { type: "refund"; walletId: string; amount: number; originalReference: string };
export type ParseResult = { ok: true; event: WalletEvent } | { ok: false; reason: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function parseEvent(body: string): ParseResult {
let raw: unknown;
try {
raw = JSON.parse(body);
} catch {
return { ok: false, reason: "body is not JSON" };
}
if (!isRecord(raw) || !isRecord(raw.data)) return { ok: false, reason: "missing data object" };
const { data } = raw;
const { walletId, amount } = data;
if (typeof walletId !== "string" || walletId === "") return { ok: false, reason: "walletId must be a non-empty string" };
if (typeof amount !== "number" || !Number.isInteger(amount) || amount <= 0) {
return { ok: false, reason: "amount must be a positive whole number of kobo" };
}
if (raw.type === "topup") return { ok: true, event: { type: "topup", walletId, amount } };
if (raw.type === "refund") {
if (typeof data.originalReference !== "string") return { ok: false, reason: "refund needs originalReference" };
return { ok: true, event: { type: "refund", walletId, amount, originalReference: data.originalReference } };
}
return { ok: false, reason: `unsupported event type ${JSON.stringify(raw.type)}` };
}
isRecord is a small type guard: its return type value is Record<string, unknown> tells TypeScript that when it returns true, the value may be treated as an object with string keys and unchecked values. Narrowing explains type guards fully. Every property read from it is unknown and gets checked. Now the part that changes balances:
import type { WalletEvent } from "./events.js";
function assertNever(value: never): never {
throw new Error(`Unhandled event: ${JSON.stringify(value)}`);
}
export function apply(balances: Map<string, number>, event: WalletEvent): number {
const current = balances.get(event.walletId) ?? 0;
let next: number;
switch (event.type) {
case "topup":
case "refund":
next = current + event.amount;
break;
default:
return assertNever(event);
}
balances.set(event.walletId, next);
return next;
}
import { parseEvent } from "./events.js";
import { apply } from "./wallet.js";
const balances = new Map<string, number>([["WAL-17", 1000000]]);
const bodies = [
'{"type": "topup", "data": {"walletId": "WAL-17", "amount": 500000}}',
'{"type": "topup", "data": {"walletId": "WAL-17", "amount": "5000"}}',
'{"type": "refund", "data": {"walletId": "WAL-17", "amount": 25000}}',
'{"type": "chargeback", "data": {"walletId": "WAL-17", "amount": 25000}}',
'not json at all',
];
for (const body of bodies) {
const result = parseEvent(body);
if (result.ok) {
console.log(`${result.event.type}: balance now ${apply(balances, result.event)} kobo`);
} else {
console.log(`rejected: ${result.reason}`);
}
}
npx tsx main.ts and of the browser terminaltopup: balance now 1500000 kobo rejected: amount must be a positive whole number of kobo rejected: refund needs originalReference rejected: unsupported event type "chargeback" rejected: body is not JSON
Search these three files for any: there is none, not even an inferred one. The string "5000" that caused the ₦100 million bug is refused, the refund without its reference is refused, the unknown "chargeback" is refused with its name in the message, and the amounts are whole kobo, so no floating-point money either.
Testing boundaries and hunting any
A boundary function is the easiest code in the program to test, because it is pure: text in, result out. Feed it the inputs an attacker or a buggy provider might send, not just the happy path:
import { parseEvent } from "./events.js";
const cases: [label: string, body: string, ok: boolean][] = [
["valid top-up", '{"type": "topup", "data": {"walletId": "W1", "amount": 100}}', true],
["amount as text", '{"type": "topup", "data": {"walletId": "W1", "amount": "100"}}', false],
["negative amount", '{"type": "topup", "data": {"walletId": "W1", "amount": -100}}', false],
["fractional kobo", '{"type": "topup", "data": {"walletId": "W1", "amount": 10.5}}', false],
["data is an array", '{"type": "topup", "data": [1, 2]}', false],
["body is null", "null", false],
["empty wallet id", '{"type": "topup", "data": {"walletId": "", "amount": 100}}', false],
];
for (const [label, body, expected] of cases) {
const result = parseEvent(body);
console.log(`${result.ok === expected ? "PASS" : "FAIL"} ${label}${result.ok ? "" : ` (${result.reason})`}`);
}
npx tsx events.test.ts and of the browser terminalPASS valid top-up PASS amount as text (amount must be a positive whole number of kobo) PASS negative amount (amount must be a positive whole number of kobo) PASS fractional kobo (amount must be a positive whole number of kobo) PASS data is an array (missing data object) PASS body is null (missing data object) PASS empty wallet id (walletId must be a non-empty string)
You can also test that a function does not return any, which is what quietly happens when someone removes the unknown annotation. Assign the result to an unrelated type under // @ts-expect-error. A real type makes that an error, as expected. An any is assignable to everything, so the directive becomes unused and the check fails:
import { parseEvent } from "./events.js";
// @ts-expect-error: parseEvent must return a typed result, never any
const notAString: string = parseEvent("{}");
console.log(typeof notAString);
npx tsx no-any.types-test.ts and of the browser terminalobject
In production
- Annotate every boundary as
unknown:JSON.parse,response.json(), message payloads, file contents,catchvariables. Keep theunknownfor as few lines as possible, then return a checked type. - Keep
stricton. It includesnoImplicitAnyanduseUnknownInCatchVariables. tsconfig in depth covers each flag. - Let a linter find the explicit ones. typescript-eslint's
no-explicit-anyand theno-unsafe-*rules (assignment, member access, call, return) report every place ananyis used, which the compiler never will. - Treat untyped libraries as boundaries. Wrap them in one small module with honest types, so their
anystops at the wrapper. - Know the
voidcallback trap, and do not pass async functions toforEach, event emitters or anything else that ignores their promise. - Replace
{}andObjectin parameter types withunknown,Record<string, unknown>or a real interface.
Practice
TRY IT YOURSELF
Stop the spread
This loader compiles, but every caller gets any. Change it so that it returns a checked { name: string; limitKobo: number } or throws an Error explaining what is wrong. Do not use any or as.
function loadLimits(json: string) {
const config = JSON.parse(json);
return config.tier;
}
const tier = loadLimits('{"tier": {"name": "gold", "limitKobo": "50000000"}}');
console.log(tier.name, tier.limitKobo * 2);
npx tsx limits.ts and of the browser terminalgold 100000000
Show a solution
interface Tier {
name: string;
limitKobo: number;
}
function loadLimits(json: string): Tier {
const config: unknown = JSON.parse(json);
if (typeof config !== "object" || config === null || !("tier" in config)) throw new Error("missing tier");
const tier = config.tier;
if (typeof tier !== "object" || tier === null) throw new Error("tier must be an object");
if (!("name" in tier) || typeof tier.name !== "string") throw new Error("tier.name must be a string");
if (!("limitKobo" in tier) || typeof tier.limitKobo !== "number") throw new Error("tier.limitKobo must be a number");
return { name: tier.name, limitKobo: tier.limitKobo };
}
for (const json of ['{"tier": {"name": "gold", "limitKobo": 50000000}}', '{"tier": {"name": "gold", "limitKobo": "50000000"}}']) {
try {
const tier = loadLimits(json);
console.log(tier.name, tier.limitKobo * 2);
} catch (error) {
console.log(String(error));
}
}
npx tsx limits.ts and of the browser terminalgold 100000000 Error: tier.limitKobo must be a number
The original printed a doubled limit that happened to be right, because "50000000" * 2 converts the string. A + anywhere would have concatenated instead. The checked version refuses the string outright.
TRY IT YOURSELF
The empty cart
Fix this so it compiles without changing the order of the lines: the cart starts empty and items are added later.
const cart = { customer: "Ada", items: [] };
cart.items.push({ sku: "RICE-5KG", price: 9500, quantity: 1 });
cart.items.push({ sku: "OIL-1L", price: 2800, quantity: 2 });
console.log(cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0));
npx tsc --noEmit printscart.ts:3:17 - error TS2345: Argument of type '{ sku: string; price: number; quantity: number; }' is not assignable to parameter of type 'never'.
3 cart.items.push({ sku: "RICE-5KG", price: 9500, quantity: 1 });
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
cart.ts:4:17 - error TS2345: Argument of type '{ sku: string; price: number; quantity: number; }' is not assignable to parameter of type 'never'.
4 cart.items.push({ sku: "OIL-1L", price: 2800, quantity: 2 });
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
cart.ts:5:57 - error TS2339: Property 'price' does not exist on type 'never'.
5 console.log(cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0));
~~~~~
cart.ts:5:70 - error TS2339: Property 'quantity' does not exist on type 'never'.
5 console.log(cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0));
~~~~~~~~
Found 4 errors in the same file, starting at: cart.ts:3Show a solution
interface CartItem {
sku: string;
price: number;
quantity: number;
}
interface Cart {
customer: string;
items: CartItem[];
}
const cart: Cart = { customer: "Ada", items: [] };
cart.items.push({ sku: "RICE-5KG", price: 9500, quantity: 1 });
cart.items.push({ sku: "OIL-1L", price: 2800, quantity: 2 });
console.log(cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0));
npx tsx cart.ts and of the browser terminal15100
Without a type, items: [] is never[], which is why every push was refused and why item in the reduce had no properties. The Cart interface gives the empty array its element type.
TRY IT YOURSELF
Wait for every notification
This sends three SMS messages and then prints a summary, but the summary comes first. Explain why the compiler allowed it, then fix it so all three are sent at the same time and the summary comes last.
async function sendSms(to: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(`sms sent to ${to}`);
}
const phones = ["0803 000 1111", "0805 222 3333", "0807 444 5555"];
phones.forEach(async (phone) => {
await sendSms(phone);
});
console.log(`${phones.length} messages sent`);
npx tsx sms.ts and of the browser terminal3 messages sent sms sent to 0803 000 1111 sms sent to 0805 222 3333 sms sent to 0807 444 5555
Show a solution
forEach expects a callback returning void, and a function that returns a promise is allowed where the result is ignored. So forEach starts three sends and returns immediately. Promise.all waits for all of them, which run at the same time:
async function sendSms(to: string): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 10));
console.log(`sms sent to ${to}`);
}
const phones = ["0803 000 1111", "0805 222 3333", "0807 444 5555"];
await Promise.all(phones.map((phone) => sendSms(phone)));
console.log(`${phones.length} messages sent`);
npx tsx sms.ts and of the browser terminalsms sent to 0803 000 1111 sms sent to 0805 222 3333 sms sent to 0807 444 5555 3 messages sent
Recap
unknownis the top type: every value fits, and you must check before use.neveris the bottom type: no value fits.anyis an exit from checking in both directions.anyspreads through every property, call and return. It enters fromJSON.parse,response.json(), untyped libraries and explicitany;strictonly stops the implicit kinds.- Give outside data the type
unknownwhere it enters, check it, and return a checked type.catchvariables areunknowntoo. neveris the return type of functions that cannot return, the leftover after exhaustive narrowing, and the element type of an empty array literal inside an object.voidon a declaration means "returns nothing"; on a function type it means "the result is ignored", which lets async callbacks run unwaited.objectmeans non-primitive;{}andObjectaccept almost anything, including numbers. Useunknown,Record<string, unknown>or a real interface instead.
Next: Narrowing, every way to turn a wide type such as unknown into a precise one.
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.