The primitive types are the easy part of TypeScript and take about five minutes. The useful part of this lesson is the other half: knowing which annotations to leave out, because writing types the compiler already knows is the most common way to make a codebase worse while feeling productive.
The primitives
| Type | Example | Note |
|---|---|---|
string | 'Cheese' | quotes, template literals, all the same type |
number | 10.99 | there is no int. All numbers are doubles. |
boolean | true | |
bigint | 9007199254740993n | rare; integers beyond number's safe range |
symbol | Symbol('key') | rarer still |
null | null | a type as well as a value |
undefined | undefined | likewise |
They are spelled in lowercase. There are also capitalised String,
Number and Boolean types — these refer to the wrapper objects, they are
almost never what you want, and using one produces confusing errors. Write string.
The syntax is a colon after the name:
let productName: string = 'Margherita';
let price: number = 10.99;
let isActive: boolean = true;Now here is the thing: do not write that.
Inference does most of the work
TypeScript reads the initialiser and works the type out. All three annotations above are redundant, and the app writes the same thing like this:
export const TAX_RATE = 0.085;
export const DELIVERY_FEE = 3.99;Hover over TAX_RATE and the editor says 0.085. It is fully typed. The
annotation would have added a second place for the truth to live, and the only thing a second place
can do is disagree with the first.
This applies to expressions of any complexity:
const toppingsTotal = item.toppings.reduce((sum, t) => sum + t.price, 0);toppingsTotal is a number, and so is sum, and so is
t.price — TypeScript worked all of that out from toppings's type and the
0 at the end. Annotating any of it would be noise.
const and let infer differently
This trips up everyone once, and understanding it early explains a lot of later behaviour.
let size = 'MEDIUM'; // type is: string
const other = 'MEDIUM'; // type is: 'MEDIUM'A let can be reassigned, so the useful type is the general one —
string. This is called widening. A const can never be
reassigned, so TypeScript keeps the exact value as the type: 'MEDIUM', a
literal type whose only member is that one string.
That matters the moment a function is fussy about which strings it accepts:
// This does not compile.
let orderType = 'DELIVERY';
calculateTotals(items, orderType);
// ~~~~~~~~~
// Argument of type 'string' is not assignable to parameter of type
// '"DELIVERY" | "CARRYOUT"'.orderType widened to string, and string includes
'banana'. Change let to const and it compiles, because the
type is then 'DELIVERY' exactly. This is the mechanism behind the string-literal
unions the whole application is built on — see
Unions and Intersections.
Naming a primitive to say what it means
Sometimes a type is technically a string and that is not the interesting thing about it. You can give it a name:
export type UUID = string;The app does this, and the reasoning is worth quoting because it is a good example of a type carrying documentation:
/**
* Every identifier the API accepts or returns is a UUID string.
*
* The backend keeps a numeric primary key internally but never publishes it: sequential ids would
* let anyone walk /api/orders/1, /2, /3 and read other people's orders. This alias exists so the
* intent is obvious at every use site — it is a UUID, not "some string".
*/Be clear about what this does and does not buy you. UUID is an
alias, not a new type — it is string, and any string is
assignable to it. It will not stop you passing an email address where an id belongs. What it does
is make every signature in the codebase say which kind of string it wants, which is worth a lot on
its own.
If you need the compiler to actually enforce the distinction, the trick is to add a property that exists only in the type system:
type OrderId = string & { readonly __brand: 'OrderId' };No string has a __brand property, so nothing is assignable to OrderId
by accident — you have to go through a function that asserts it once, at the point the value
enters. That is real enforcement, and it costs you a constructor for every branded type plus a
slightly odd-looking declaration. Worth it when mixing up two kinds of id would be expensive;
overkill in most codebases, which is why the app uses the plain alias.
Where you do have to annotate
Parameters. Always. TypeScript cannot infer them, because a function is written before anyone calls it and the compiler will not go looking for call sites to guess from.
export function formatMoney(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
}Leave : number off and, with noImplicitAny on, that is an error. Leave
it off without that flag and amount is any, which is worse: the
function compiles, accepts anything, and the failure happens somewhere else entirely.
The exception is a callback, where the type comes from context:
const toppingsTotal = item.toppings.reduce((sum, t) => sum + t.price, 0);Nothing is annotated there, and nothing needs to be — reduce's own type says what
it passes its callback. This is contextual typing, and it is why React event handlers usually need
no annotation either.
Return types: infer, or pin?
TypeScript infers return types perfectly well, so this is a judgement call rather than a rule. The app annotates them on its exported helpers:
export function unitPrice(item: CartItem): number {
const toppingsTotal = item.toppings.reduce((sum, t) => sum + t.price, 0);
return round2(item.basePrice + item.crustPriceDelta + toppingsTotal);
}
export function lineTotal(item: CartItem): number {
return round2(unitPrice(item) * item.quantity);
}The argument for writing : number there is that it pins the contract. If someone
edits the body and accidentally returns undefined on one branch, the error appears
in that function, pointing at the mistake. Without the annotation the return type silently
becomes number | undefined, everything still compiles, and the error surfaces later in
whichever caller tried to add it up.
So: pin the return type on anything exported, and let it infer for local helpers. That is the convention most codebases converge on.
number is one type, and it is a double
Worth dwelling on, because number hides something the type gives you no warning
about. TypeScript has no int, no float, no decimal — every
number is a 64-bit binary floating point value, exactly as in JavaScript.
So the type system will cheerfully approve arithmetic that is wrong in cents:
10.99 + 1.5 + 1.75 // 14.240000000000002Every value there is a number, the sum is a number, and the checker has
no complaint to make. This is a good early illustration of the limit set out in
lesson 1: types constrain shape, not meaning.
The pizza app deals with it the ordinary way, by rounding at each boundary:
export function round2(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}And it is careful to note that this is display arithmetic only — the server recomputes every price from the database when an order is placed, because a browser that can be trusted with pricing is a shop that can be robbed. The type system is no help with either problem.
If you need exact decimals, the answer is the same as in JavaScript: integer cents, or a decimal
library. A type alias like type Cents = number documents which one you chose.
Annotating is not the same as asserting
Two pieces of syntax look similar and do opposite things. It is worth separating them now, before either becomes a habit.
const a: number = value; // annotation — CHECKS that value is a number
const b = value as number; // assertion — INSISTS that it is, checking nothingThe first asks the compiler a question. The second tells it to be quiet. They are both one word long and one of them can crash your program, so when you have a choice, annotate.
Lesson 16 covers when as is
legitimate — there are real cases — and the satisfies operator, which is usually what
people reaching for as actually wanted.
How to read a type error
TypeScript's messages are more mechanical than they look, and learning the shape of them early saves a lot of squinting. Nearly all of them are one sentence:
Type 'string' is not assignable to type '"DELIVERY" | "CARRYOUT"'.Read it as: the value you supplied is not acceptable where the declared type was expected. The first type is always what you have, the second is always what was wanted. Once that clicks, the fix is usually obvious from the two names alone.
The one that confuses people is the nested version, which reports the outermost mismatch and then drills down:
Type '{ name: string; price: string; }' is not assignable to type 'ToppingWriteRequest'.
Types of property 'price' are incompatible.
Type 'string' is not assignable to type 'number'.Read the last line first. The indented tail is the actual problem —
price is a string and should be a number — and the lines above it are just the path
the compiler walked to get there. On a deeply nested object that trail can run to a dozen lines,
and every one of them except the last is context.
null, undefined and the flag that matters
With strict on — which includes strictNullChecks — a
string is a string and nothing else. If a value can be absent, its type has to say
so, and the app's types say so constantly:
export interface Product {
id: UUID;
name: string;
description: string;
type: ProductType;
imageUrl: string | null;That | null on imageUrl is the whole feature. It means every place that
renders an image has to decide what to do when there is not one, and the compiler will not let it
forget.
// This does not compile.
const url = product.imageUrl.toUpperCase();
// ~~~~~~~~~~~~~~~~
// 'product.imageUrl' is possibly 'null'.Turn strictNullChecks off and that line compiles, ships, and throws
Cannot read properties of null the first time a product has no image. Handling it
properly is narrowing, which gets its own lesson.
Next
any, unknown, never and void — the four types that do not describe values, and the one of them you should be reaching for far more often than you are.