TypeScript From Zero

Basic Types, Inference and Narrowing

Primitives, unions, literal types, any versus unknown versus never, inference, narrowing and discriminated unions.

Lesson 0122 min read#typescript#types#narrowing

On this page

  1. Words used in this lesson
  2. The basic types
  3. Literal types
  4. Type inference
  5. any, unknown and never
  6. Union types
  7. Narrowing
  8. Discriminated unions
  9. The non-null assertion (!) and why to avoid it
  10. Try it
  11. Interview questions
  12. Key takeaways
  13. What's next

In one line: a type is a promise about what values a name can hold. TypeScript can often work the type out by itself (inference), and it narrows a wide type to a smaller one when your if checks prove something. Learn the few basic types, the safe way to say "I do not know" (unknown), and the pattern of a tagged union checked with never, and you can read most TypeScript.

This lesson uses only small examples. Each one starts from a value, shows the type TypeScript gives it, and then shows what the compiler says when you break the rule. If you have not set up a project, do Lesson 0 first. Interview versions of these topics are in Full-Stack Q&A, Lesson 3.

What I ran / checked. Everything here was compiled with TypeScript 7.0.2 (tsc --pretty false) on Node v22.22.0, using the tsconfig from Lesson 0. Programs that print something were run with node file.ts and the output is quoted. Error messages are copied from the compiler. I did not check these claims against the handbook text; they describe what I observed. The "reveal the type" trick (assigning to never or string) is a habit, not an official tool.

Words used in this lesson

  • A primitive is a basic value that is not an object: text, number, true/false, and a few others.
  • A literal type is a type that allows exactly one value, such as "up" or 5.
  • A union type (A | B) allows a value of type A or a value of type B.
  • Type inference is when TypeScript decides a type for you from the code, without you writing it.
  • Widening is when inference chooses a wider type than the value you wrote, for example string for "hi".
  • Narrowing is when TypeScript makes a type smaller inside a block of code, because a check has proved something.
  • A type guard is any check that narrows, such as typeof x === "string".
  • A discriminated union is a union of object types that all have one shared property (the tag) with a different literal value in each, such as kind: "circle".
  • Exhaustive means "every possible case is handled".
  • never is the type with no values. Nothing can be assigned to it.

The basic types

JavaScript
const title: string = "TypeScript";const year: number = 2012;const big: bigint = 9007199254740993n;const done: boolean = false;const nothing: null = null;let maybe: undefined = undefined;const tag: symbol = Symbol("tag");

These seven are the JavaScript primitives. number covers whole numbers and decimals; bigint is for integers too large for number. Note the lower case: string, not String. The capital-letter names are the wrapper objects and you almost never want them.

You will rarely write these notes on a const, because inference already knows. See the section on inference below.

Arrays and tuples

An array is a list where every item has the same type. Two spellings mean the same thing:

JavaScript
const names: string[] = ["Ada", "Bo"];const scores: Array<number> = [90, 85];names.push(42);
TEXT
src/a_prims.ts(11,12): error TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

A tuple is an array with a fixed length where each position has its own type:

JavaScript
const pair: [string, number] = ["Ada", 36];const [who, age] = pair;          // who: string, age: numberconst bad: [string, number] = ["Ada"];console.log(pair[2]);
TEXT
src/a_prims.ts(14,7): error TS2322: Type '[string]' is not assignable to type '[string, number]'.  Source has 1 element(s) but target requires 2.src/a_prims.ts(15,18): error TS2493: Tuple type '[string, number]' of length '2' has no element at index '2'.

Use a tuple for small fixed groups, such as a [key, value] pair. For anything with names, use an object (Lesson 2).

Literal types

A literal type is one exact value:

JavaScript
let c: "left" | "right" = "left";c = "up";
TEXT
src/b_lit.ts(4,1): error TS2322: Type '"up"' is not assignable to type '"left" | "right"'.

This is the TypeScript way to say "one of these words". It is lighter than an enum (Lesson 2) and it is erased at runtime.

Type inference

TypeScript works out a type for you when you give a value at the same time:

JavaScript
const o = { n: 1 };const k = [1, 2, 3];const first = k[0];const who = (name: string) => name.length;const nums = [1, 2, 3].map((n) => n * 2);function noRet() {}

How do you see the types it chose? Your editor shows them when you hover. Without an editor, you can assign the value to a type that cannot hold it, and read the error. I did this on each name (const check1: string = o; and so on):

TEXT
src/b2.ts(7,7): error TS2322: Type '{ n: number; }' is not assignable to type 'string'.src/b2.ts(8,7): error TS2322: Type 'number[]' is not assignable to type 'string'.src/b2.ts(9,7): error TS2322: Type '(name: string) => number' is not assignable to type 'string'.src/b2.ts(10,7): error TS2322: Type 'void' is not assignable to type 'string'.src/b2.ts(11,7): error TS2322: Type 'number | undefined' is not assignable to type 'string'.  Type 'undefined' is not assignable to type 'string'.

Look at the last one: first is number | undefined, not number. That is noUncheckedIndexedAccess from Lesson 0 at work.

const and let: literal or wide

JavaScript
let a = "hi";const b = "hi";

I revealed the two types with const x1: never = a;:

TEXT
src/i2.ts(3,7): error TS2322: Type 'string' is not assignable to type 'never'.src/i2.ts(4,7): error TS2322: Type '"hi"' is not assignable to type 'never'.

let a can change later, so TypeScript widens to string. const b can never change, so it keeps the literal type "hi". This causes a classic surprise:

JavaScript
type Dir = "up" | "down";function move(d: Dir) { return d; }const s = "up";move(s);                  // fine: s is "up"let t = "up";move(t);const obj = { dir: "up" };move(obj.dir);
TEXT
src/b_lit.ts(10,6): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Dir'.src/b_lit.ts(12,6): error TS2345: Argument of type 'string' is not assignable to parameter of type 'Dir'.

Properties of an object are like let: they could be changed, so dir is string. Fixes: give the variable a type (let t: Dir = "up"), or use as const (Lesson 2).

When to write the type yourself

Rule of thumb:

  • Function parameters: always write them. TypeScript cannot guess them (Lesson 0, TS7006).
  • Function return types: optional for small functions, good for exported ones. A written return type is a check that your function does what you meant.
  • Variables: let inference do it, unless you want a wider type than the value, or the start value is empty (const items: string[] = []).

any, unknown and never

These three are special. Learn them as a set.

any: the escape hatch

any switches the checker off for that value.

JavaScript
let a: any = "text";a.foo.bar();       // no errora = 5;const n: number = a;   // no error

tsc printed nothing for these four lines. a.foo.bar() would crash at runtime. Worse, any spreads: n is a number as far as the checker knows, but it came from a value that had no checks.

Treat any as debt. Each any is a place where TypeScript can no longer help. noImplicitAny (inside strict) stops you getting it by accident; the : any you write on purpose is your decision.

unknown: the safe "I do not know"

unknown means "could be anything, and I must check before I use it".

JavaScript
let u: unknown = "text";u.toUpperCase();const len: number = u;if (typeof u === "string") {  console.log(u.toUpperCase());}
TEXT
src/c_any.ts(7,1): error TS18046: 'u' is of type 'unknown'.src/c_any.ts(8,7): error TS2322: Type 'unknown' is not assignable to type 'number'.

The if block compiled, because inside it u is narrowed to string. Use unknown for data that comes from outside: JSON.parse (typed as any, so wrap it), catch clauses, and untrusted arguments.

never: no value at all

never is the type of something that cannot happen. Two uses:

  • A function that never returns, because it always throws or loops forever: function fail(msg: string): never { throw new Error(msg); }.
  • A value that is impossible after all cases are handled (see exhaustive checks below).
JavaScript
const nope: never = 5;
TEXT
src/c_any.ts(27,7): error TS2322: Type '5' is not assignable to type 'never'.

Nothing can be assigned to never, which makes it useful as a trap.

TypeCan I assign anything to it?Can I use it without a check?
anyYesYes (unsafe)
unknownYesNo, narrow first
neverNoNot applicable (no values exist)

Union types

A union means "this or that":

JavaScript
function show(id: string | number) {  return id.toUpperCase();}
TEXT
src/d_union.ts(2,13): error TS2339: Property 'toUpperCase' does not exist on type 'string | number'.  Property 'toUpperCase' does not exist on type 'number'.

With a union you may only use what every member allows. number has no toUpperCase, so the call is refused. To use it, you must first prove which one you have. That is narrowing.

Narrowing

TypeScript reads your if, switch, return and throw statements to follow what you know at each line. This is called control-flow analysis.

typeof

JavaScript
function show2(id: string | number) {  if (typeof id === "string") {    return id.toUpperCase();   // id: string here  }  return id.toFixed(2);        // id: number here}

This compiled cleanly. After the if returns, the rest of the function knows id is not a string.

Equality and null checks

JavaScript
function len(x: string | string[] | null) {  if (x === null) return 0;  return x.length;}

Clean. Both string and string[] have .length, so after removing null the code is valid. Array.isArray(x) also narrows: I checked that inside if (Array.isArray(x)), x has the type string[].

Truthiness, and its trap

A truthiness check (if (s)) removes null, undefined, and also "", 0, false:

JavaScript
function nameOf(s?: string) {  if (s) return s.toUpperCase();  return "anonymous";}

This is fine for text. For numbers it is a bug:

JavaScript
function label(count: number | undefined): string {  if (!count) return "none";  return String(count);}console.log(label(0), label(3), label(undefined));
TEXT
none 3 none

label(0) says none, but 0 is a real count. TypeScript is happy with it because the types are right; the logic is wrong. Write if (count === undefined) when 0 is a valid value (I ran this version, and label2(0) printed 0).

in

The in operator checks if a property exists, and narrows object types by it:

JavaScript
type Cat = { meow(): void };type Dog = { bark(): void };function speak(p: Cat | Dog) {  if ("meow" in p) p.meow();  else p.bark();}

This compiled with no errors. In the else branch TypeScript knows that p can only be Dog.

instanceof

instanceof checks if a value was made by a class (or constructor function):

JavaScript
function when(d: Date | string) {  if (d instanceof Date) return d.getFullYear();  return d.length;}

Clean as well. Remember that it works only for classes, because interfaces do not exist at runtime (Lesson 0).

A trap: typeof null

JavaScript
function typeofNull(x: object | null) {  if (typeof x === "object") {    const y: object = x;  }}
TEXT
src/d_union.ts(41,11): error TS2322: Type 'object | null' is not assignable to type 'object'.  Type 'null' is not assignable to type 'object'.

In JavaScript typeof null is "object". I ran console.log(typeof null, typeof [], typeof undefined, typeof 5n) and got object object undefined bigint. So typeof x === "object" does not remove null, and TypeScript knows. Check x !== null too.

Discriminated unions

When a value can be one of several shapes, give every shape the same property with a different literal. Then one switch narrows everything:

JavaScript
type Shape =  | { kind: "circle"; radius: number }  | { kind: "square"; side: number };function area(s: Shape): number {  switch (s.kind) {    case "circle":      return Math.PI * s.radius ** 2;    case "square":      return s.side ** 2;  }}console.log(area({ kind: "square", side: 3 }));console.log(area({ kind: "circle", radius: 1 }).toFixed(2));
TEXT
93.14

Inside case "circle" the compiler knows that s has a radius. If you try to read radius without checking kind:

JavaScript
function bad(s: Shape) {  return s.radius;}
TEXT
src/e_disc.ts(15,12): error TS2339: Property 'radius' does not exist on type 'Shape'.  Property 'radius' does not exist on type '{ kind: "square"; side: number; }'.

This pattern is the standard way to model "a result that is a success or a failure", "an event of different kinds", or "a loading, ready or error state". Learn it well.

Exhaustive checks with never

What happens when someone adds a new shape? You want the compiler to show every switch that needs updating. Add a default that tries to put the leftover value in a never parameter:

JavaScript
type Shape =  | { kind: "circle"; radius: number }  | { kind: "square"; side: number }  | { kind: "triangle"; base: number; height: number };function assertNever(x: never): never {  throw new Error("Unexpected: " + JSON.stringify(x));}function area(s: Shape): number {  switch (s.kind) {    case "circle":      return Math.PI * s.radius ** 2;    case "square":      return s.side ** 2;    default:      return assertNever(s);  }}
TEXT
src/e_disc2.ts(17,26): error TS2345: Argument of type '{ kind: "triangle"; base: number; height: number; }' is not assignable to parameter of type 'never'.

The error names the missing case. Add case "triangle" and the error goes away. At runtime assertNever also throws if bad data arrives that the types did not predict.

The non-null assertion (!) and why to avoid it

A ! after a value tells the checker "this is not null or undefined, believe me":

JavaScript
const users = [{ name: "Ada" }];const found = users.find((u) => u.name === "Bo");console.log(found!.name);

tsc printed nothing. I ran it:

TEXT
TypeError: Cannot read properties of undefined (reading 'name')

Same as as in Lesson 0: the checker was told to stop, so the crash came back at runtime. Better options, in order:

JavaScript
const found = users.find((u) => u.name === "Bo");if (!found) {  throw new Error("User Bo not found");}console.log(found.name);          // found: { name: string }

This compiles, and at runtime it fails on purpose with a clear message: Error: User Bo not found. Other safe options are a default (map.get("a") ?? 0) or optional chaining (found?.name). A ! can be fine in a test, or right after a check that the compiler cannot see. In all other places, a real check is cheap.

Try it

  1. What types do let a = 5, const b = 5 and const c = [5] have? Use the never trick to check.
  2. Write function size(x: string | string[]): number that returns the text length or the list length. Use Array.isArray.
  3. Define type Result = { ok: true; value: number } | { ok: false; error: string }. Write describe(r: Result): string. What does r.value do before you check r.ok?
  4. Write describe2 with switch (r.ok), cases true and false, and a default that calls assertNever(r). Then change the parameter type to Result | { ok: "pending" }. What does tsc say?
  5. Rewrite console.log(found!.name) so it never crashes with a TypeError.

Answers.

  1. a is number, b is 5, c is number[] (each shown by const x: never = ..., for example Type '5' is not assignable to type 'never').
  2. return x.length; already compiles, because both members have length. Array.isArray is needed only when the two branches do different things. I ran size("abc") and size(["a"]) and got 3 1.
  3. describe can switch (r.ok) or if (r.ok) and return String(r.value) or r.error. Reading r.value early gives TS2339: Property 'value' does not exist on type 'Result'.
  4. error TS2345: Argument of type '{ ok: "pending"; }' is not assignable to parameter of type 'never'. Add case "pending": and it goes away.
  5. Check first: if (found) { console.log(found.name); }, or console.log(found?.name ?? "not found").

Interview questions

What is the difference between any and unknown? (Must know)

Both can hold any value. With any, the checker turns off: I can call anything on it and assign it to anything. With unknown, I can assign anything in, but I cannot do anything with it until I narrow it with typeof, instanceof or another check. So unknown is the safe choice for data of unknown shape. In my test, u.toUpperCase() on an unknown gave TS18046, and the same on any gave nothing.

What is type inference, and when do you write the type yourself? (Must know)

Inference is the compiler working out a type from the code. I rely on it for local variables and small functions. I write types on function parameters (it cannot infer them), on exported function return types (so the signature is a check and a contract), and where the start value is too narrow or empty, such as const items: string[] = [].

Why is let x = "a" a string but const x = "a" the type "a"? (Must know)

A let can be reassigned, so TypeScript widens the type to string. A const can never change, so it keeps the exact literal type. The practical effect is that an object property such as { dir: "up" } has type string, so passing obj.dir to a function that wants "up" | "down" fails, as it did in my test. I fix this with a type annotation or as const.

What is narrowing? Name five ways to do it. (Must know)

Narrowing is making a wide type smaller inside a block, because a check proved something. The ways are typeof, instanceof, in, equality (=== null, === "x"), truthiness, Array.isArray, and a kind property in a discriminated union. TypeScript follows if, switch, early return and throw to decide what is known at each line.

What is a discriminated union and why is it useful? (Must know)

It is a union of object types that share one property with a different literal in each, for example kind: "circle" and kind: "square". A check on that property narrows the whole object, so s.radius is available inside case "circle" only. I use it for results (ok: true or ok: false), events, and state like loading, ready and error, because impossible combinations cannot be written.

How do you make a switch exhaustive, and what is never doing there? (Must know)

In the default branch I pass the value to a function that takes never. If all cases are handled, the value has type never and it compiles. If someone adds a new member to the union, the leftover type is not never, and the compiler fails at that line with TS2345 ... not assignable to parameter of type 'never', naming the missing case. The helper can also throw at runtime for data that breaks the types.

Why is typeof x === "object" a poor null check? (Senior signal)

Because in JavaScript typeof null is "object". I ran it and got object. TypeScript knows this, so after the check x is still object | null. I check x !== null as well.

What is wrong with if (!count) return "none" when count is number | undefined? (Senior signal)

Truthiness also removes 0, so a count of zero is treated as missing. The types are correct, so the compiler does not complain. In my test label(0) printed none. For values where 0, "" or false are meaningful, I compare with === undefined or == null explicitly.

Why avoid the non-null assertion !? What do you do instead? (Must know)

! only tells the compiler to stop checking. It adds nothing at runtime, so users.find(...)!.name compiled and then crashed with TypeError: Cannot read properties of undefined. I prefer a real check that fails with a clear message (if (!found) throw new Error(...)), a default with ??, or optional chaining when a missing value is fine. I allow ! rarely, for example in a test.

Key takeaways

  • Basic types: string, number, boolean, bigint, symbol, null, undefined. Use lowercase names.
  • Let inference work for variables. Write parameter types and exported return types.
  • let widens (string); const keeps the literal ("hi").
  • any turns checks off. unknown forces a check. never has no values.
  • Narrow with typeof, in, instanceof, equality and truthiness, and remember the traps: typeof null and 0.
  • Use a discriminated union with a kind tag, and finish a switch with a never check.
  • Avoid ! and as; use a real check that fails loudly.

What's next

Lesson 2: Objects, Interfaces, Type Aliases and Classes. Now that you know the basic building blocks, we describe the shape of objects, learn when to use interface or type, and look at classes, as const and satisfies.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series