TypeScript – The Basic Types

July 13, 20268 min readUpdated 9/5/2026

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

TypeExampleNote
string'Cheese'quotes, template literals, all the same type
number10.99there is no int. All numbers are doubles.
booleantrue
bigint9007199254740993nrare; integers beyond number's safe range
symbolSymbol('key')rarer still
nullnulla type as well as a value
undefinedundefinedlikewise

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.240000000000002

Every 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 nothing

The 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.