Emmanuel Akinfulubi

TypeScript: Basic

Declaring variables, the built-in types, inference, assertions, aliases, arrays and enums.

10 exercises

1 A name and an age, with types

easy

Declare one variable holding a person's name as text and another holding their age as a number. Print both. Annotate each one so the reader can see the type without running anything.

Stuck? Three hints, smallest first
  1. 1. A type annotation goes after the variable name and a colon: let x: number.
  2. 2. The two type names you want are string and number, both lowercase.
  3. 3. You do not have to annotate these at all. Do it here anyway, then note in a comment that TypeScript already knew.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

const personName: string = 'Amaka Obi';
const age: number = 31;

console.log(personName, age);

// The annotations above earn nothing: TypeScript reads 'Amaka Obi' and already
// knows it is a string. Lesson: annotate when it adds information, not by habit.
const inferred = 'Chidi';   // inferred as string, no annotation needed

test('name', personName, 'Amaka Obi');
test('age', age, 31);
test('inference still gives a string', typeof inferred, 'string');

Output


      

Notice the annotations are optional here. const personName: string = 'Amaka Obi' tells you nothing the value did not already say. Save annotations for function parameters and for empty containers, where there is nothing to read the type from.

Answers TS Basic 1 in the original set.

2 let, const and var, and how they differ

easy

Declare one variable with each of let, const and var. Then show the two ways they differ: whether you can reassign them, and whether a block keeps them in.

Stuck? Three hints, smallest first
  1. 1. const stops you pointing the name at something else. It does not freeze an object’s contents.
  2. 2. let and const stop existing at the closing brace of their block. var leaks out to the whole function.
  3. 3. Put a var and a let inside an if block, then try to read both after it.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

function scoping(): string[] {
  const log: string[] = [];

  if (true) {
    var leaks = 'declared with var';
    let stays = 'declared with let';
    log.push('inside the block, both readable: ' + leaks + ' / ' + stays);
  }

  // `leaks` is still here, because var is scoped to the function.
  log.push('after the block, var is still readable: ' + leaks);

  // `stays` is gone. Uncommenting the next line is a compile error:
  // log.push(stays);   // Cannot find name 'stays'.
  return log;
}

let reassignable = 1;
reassignable = 2;                 // fine

const fixed = 1;
// fixed = 2;                     // error: Cannot assign to 'fixed'

// const protects the NAME, not the contents:
const settings = { theme: 'light' };
settings.theme = 'dark';          // allowed
console.log(settings.theme);

const lines = scoping();
for (const line of lines) console.log(line);

test('var escaped the block', lines.length, 2);
test('let was reassignable', reassignable, 2);
test('const object was still mutable', settings.theme, 'dark');

Output


      

Two separate ideas get confused here. Reassignment is about the name: const locks it. Scope is about where the name exists: let and const stop at the block, var runs to the end of the function. Use const by default, let when you must reassign, and var never.

Answers TS Basic 2 in the original set.

3 The four everyday types

easy

Declare one variable of each type: number, string, boolean and undefined. Do something small with each one and print the result.

Stuck? Three hints, smallest first
  1. 1. typeof returns a string, and for an undefined value that string is "undefined".
  2. 2. Template literals with backticks are the tidiest way to join text and numbers.
  3. 3. A variable annotated undefined can only ever hold undefined, which is rarely useful on its own.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

const count: number = 7;
const label: string = 'seats';
const isFull: boolean = false;
const notSetYet: undefined = undefined;

const doubled = count * 2;
const shouted = label.toUpperCase();
const flipped = !isFull;

console.log(`${count} ${label} -> doubled ${doubled}`);
console.log('shouted:', shouted);
console.log('flipped:', flipped);
console.log('typeof notSetYet:', typeof notSetYet);

test('arithmetic on a number', doubled, 14);
test('a method on a string', shouted, 'SEATS');
test('negating a boolean', flipped, true);
test('typeof undefined', typeof notSetYet, 'undefined');

Output


      

These four plus null, bigint and symbol are the whole primitive set. Everything else, arrays and objects and functions included, is an object. Note that typeof hands you a string, which is why it works so well in the narrowing checks you will meet later.

Answers TS Basic 3 in the original set.

4 Watch the type checker refuse an assignment

easy

Declare a variable as a number, then try to put text in it. Show what the compiler says, and show the two honest ways to end up with text instead.

Stuck? Three hints, smallest first
  1. 1. The error is TS2322, "Type ‘string’ is not assignable to type ‘number’".
  2. 2. You cannot fix it by assigning. You either convert the value or use a different variable.
  3. 3. String(n) converts a number to text. It always succeeds.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

let seats: number = 14;

// This is the error the exercise is about. Uncomment it and the build stops:
// seats = 'fourteen';
//         ^ Type 'string' is not assignable to type 'number'. (TS2322)

// Fix one: convert, and keep the two values in separate variables.
const seatsAsText: string = String(seats);

// Fix two: if it really can be either, say so in the type.
let flexible: number | string = 14;
flexible = 'fourteen';

console.log(seats, typeof seats);
console.log(seatsAsText, typeof seatsAsText);
console.log(flexible, typeof flexible);

test('the number is untouched', seats, 14);
test('converted to text', seatsAsText, '14');
test('a union accepts both', typeof flexible, 'string');

Output


      

This is the whole point of TypeScript in one error. Nothing about the running program changed; the checker simply refused a line that would have produced "fourteen" * 2 === NaN later. When you genuinely need two shapes, write the union number | string rather than reaching for any.

Answers TS Basic 4 in the original set.

5 Let inference do the work

easy

Declare several variables with no annotations at all and show what TypeScript worked out for each. Include one case where const and let are inferred differently.

Stuck? Three hints, smallest first
  1. 1. Hover a variable in your editor to see the inferred type. That is the fastest way to check.
  2. 2. const greeting = 'hi' is inferred as the literal type "hi", not as string.
  3. 3. Arrays are inferred from their contents, so a mixed array becomes a union of the element types.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

const greeting = 'hi';        // inferred as the literal type "hi"
let mutableGreeting = 'hi';   // inferred as string, because it can change
const total = 42;             // 42
const ok = true;              // true
const plates = ['LAG-441-XA', 'ABJ-772-KM'];   // string[]
const mixed = [1, 'two', true];                // (string | number | boolean)[]

// Proof that the const version is narrower than the let version:
// mutableGreeting can take any string...
mutableGreeting = 'hello';
// ...but a variable typed "hi" could not.

function describe(value: unknown): string {
  return Array.isArray(value) ? `array of ${value.length}` : typeof value;
}

console.log(describe(greeting), describe(total), describe(ok));
console.log(describe(plates), describe(mixed));

test('literal type still behaves as text', greeting.toUpperCase(), 'HI');
test('let was widened to string', mutableGreeting, 'hello');
test('array inferred and usable', plates.length, 2);
test('mixed array keeps all three', mixed.length, 3);

Output


      

The difference between const greeting = 'hi' and let mutableGreeting = 'hi' is worth holding onto. A const can never change, so TypeScript gives it the narrowest possible type, the literal "hi". A let might change, so it widens to string. That single rule is what makes as const useful later.

Answers TS Basic 5 in the original set.

6 Converting between types, and asserting

fair

Turn text into a number and back. Use a real conversion function for one direction, and a type assertion for a value the compiler cannot see into. Show why the two are not the same thing.

Stuck? Three hints, smallest first
  1. 1. parseInt(text, 10) and Number(text) both convert. Always pass the radix to parseInt.
  2. 2. A conversion changes the value at runtime. An assertion changes nothing at runtime; it only silences the checker.
  3. 3. Number('abc') gives NaN, and NaN is of type number, so the checker will not catch it. Guard with Number.isNaN.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// Conversion: really happens, at runtime.
const fromText: number = parseInt('42', 10);
const alsoFromText: number = Number('42');
const backToText: string = String(42);

// Conversion can fail, and the failure is still typed number.
const broken = Number('abc');
const safe = Number.isNaN(broken) ? 0 : broken;

// Assertion: a promise to the compiler, with no runtime effect at all.
const loose: unknown = 'actually a string';
const asserted = loose as string;

console.log(fromText, alsoFromText, backToText);
console.log('Number("abc") is', broken, '-> guarded to', safe);
console.log('asserted length:', asserted.length);

test('parseInt converted', fromText, 42);
test('Number converted', alsoFromText, 42);
test('String converted back', backToText, '42');
test('NaN is still a number', typeof broken, 'number');
test('guard replaced NaN', safe, 0);
test('assertion let us use a string method', asserted.length, 17);

Output


      

Conversion runs; assertion does not. Number(x) produces a new value. x as string produces nothing at all: it is erased, and if you were wrong the program breaks later with no warning. Reach for assertions only when you genuinely know something the compiler cannot, such as a value you just parsed from JSON.

Answers TS Basic 6 in the original set.

7 Type aliases for shapes you use more than once

fair

Create an alias for an object shape and another for a small set of allowed strings. Use both, and show what the alias bought you compared with writing the shape out each time.

Stuck? Three hints, smallest first
  1. 1. type Name = ... creates an alias. It is a new name for a type, not a new type.
  2. 2. An alias can name a union of string literals, which is how you model a fixed set of options.
  3. 3. The payoff is one place to change. Add a field to the alias and every use of it is checked again.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

type Tier = 'economy' | 'luxury';

type Booking = {
  reference: string;
  seats: number;
  tier: Tier;
};

function priceOf(booking: Booking): number {
  const perSeat = booking.tier === 'luxury' ? 18000 : 12000;
  return booking.seats * perSeat + 200;
}

function describe(booking: Booking): string {
  return `${booking.reference}: ${booking.seats} ${booking.tier} seat(s)`;
}

const one: Booking = { reference: 'BK-0001', seats: 2, tier: 'economy' };
const two: Booking = { reference: 'BK-0002', seats: 1, tier: 'luxury' };

// A tier outside the two allowed strings is rejected:
// const bad: Booking = { reference: 'BK-3', seats: 1, tier: 'first' };
//                                                    ^ not assignable to Tier

console.log(describe(one), '->', priceOf(one));
console.log(describe(two), '->', priceOf(two));

test('economy price', priceOf(one), 24200);
test('luxury price', priceOf(two), 18200);
test('description', describe(two), 'BK-0002: 1 luxury seat(s)');

Output


      

Two jobs, one keyword. type Booking names a shape so three functions can agree on it. type Tier names a closed set, which is stronger than string: a typo like 'lux' is caught at the point you write it. Prefer a union of literals over an enum for this; it needs no runtime code.

Answers TS Basic 7 in the original set.

8 A typed array, added to and walked

fair

Declare an array that holds one type only. Add items, remove one, loop over it, and derive something from it. Keep the compiler happy the whole way.

Stuck? Three hints, smallest first
  1. 1. Both string[] and Array<string> mean the same thing. The first is the usual spelling.
  2. 2. push adds to the end, filter gives you a new array without changing the original.
  3. 3. Under this course’s settings, reading arr[0] gives string | undefined, so check it or use arr[0]! when you are certain.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

const plates: string[] = ['LAG-441-XA', 'ABJ-772-KM'];

plates.push('ENU-318-PH');
plates.push('KAN-905-BD');

// Remove one without mutating: filter returns a new array.
const withoutKano = plates.filter((p) => !p.startsWith('KAN'));

// Walk it.
for (const plate of plates) console.log('in the fleet:', plate);

// Derive from it.
const lagosOnly = plates.filter((p) => p.startsWith('LAG'));
const joined = plates.join(', ');
const lengths = plates.map((p) => p.length);

// Index access is 'string | undefined' here, so it must be handled.
const first = plates[0];
const firstSafe = first === undefined ? '(empty)' : first;

console.log('without Kano:', withoutKano.length);
console.log('joined:', joined);
console.log('first:', firstSafe);

test('pushed twice', plates.length, 4);
test('filter did not mutate', plates.length - withoutKano.length, 1);
test('only one Lagos plate', lagosOnly, ['LAG-441-XA']);
test('all plates are the same length', lengths, [10, 10, 10, 10]);
test('first element handled', firstSafe, 'LAG-441-XA');

Output


      

The detail worth noticing is the last one. With noUncheckedIndexedAccess switched on, plates[0] is string | undefined, because TypeScript cannot know the array is not empty. That feels annoying for one line and then saves you from the single most common runtime crash in JavaScript.

Answers TS Basic 8 in the original set.

9 An enum of colours, and the better alternative

fair

Define a set of four colours and a variable that can only hold one of them. Do it with an enum, then do it again with as const, and compare what each costs.

Stuck? Three hints, smallest first
  1. 1. enum Colour { Red, Green } gives its members the numbers 0 and 1 unless you assign values.
  2. 2. An enum is not erased. It emits real JavaScript, and Node refuses to run it without a compile step.
  3. 3. const COLOURS = [...] as const plus (typeof COLOURS)[number] gives you the same safety and disappears at runtime.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

// The enum version.
enum Colour {
  Red = 'red',
  Green = 'green',
  White = 'white',
  Blue = 'blue',
}

let selectedColour: Colour = Colour.Green;
selectedColour = Colour.Blue;

// The version that leaves nothing behind.
const COLOURS = ['red', 'green', 'white', 'blue'] as const;
type ColourName = (typeof COLOURS)[number];   // 'red' | 'green' | 'white' | 'blue'

let picked: ColourName = 'green';
picked = 'blue';

// Both reject nonsense:
// selectedColour = 'purple';   // not assignable to Colour
// picked = 'purple';           // not assignable to ColourName

console.log('enum value:', selectedColour);
console.log('literal union value:', picked);
console.log('all colours:', COLOURS.join(', '));

test('enum member holds its string', selectedColour, 'blue');
test('union member holds its string', picked, 'blue');
test('the array is the single source of truth', COLOURS.length, 4);
test('membership check', COLOURS.includes(picked), true);

Output


      

Both are safe. The difference is what survives compilation. The enum becomes a real object in your output, so it adds bytes and cannot be run by node file.ts directly. The as const array plus (typeof COLOURS)[number] gives the same checking, and the type vanishes entirely. Reach for the second unless you need an enum for a library’s sake.

Answers TS Basic 9 in the original set.

10 null and undefined are not the same thing

fair

Declare one variable holding null and one holding undefined. Show how they differ, and show the operator that treats them as the same when you want that.

Stuck? Three hints, smallest first
  1. 1. undefined means "nobody set this". null means "somebody set this to nothing".
  2. 2. == treats them as equal; === does not. This is the one case where == is genuinely useful.
  3. 3. ?? supplies a fallback for null or undefined only, and leaves 0 and '' alone. || replaces those too.

The answer, and proof it works

const same = (a: unknown, b: unknown): boolean =>
  JSON.stringify(a) === JSON.stringify(b);

function test(name: string, got: unknown, want: unknown): void {
  console.log(
    (same(got, want) ? 'ok   ' : 'FAIL ') + name +
    (same(got, want) ? '' : `  got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`)
  );
}

const isNull: null = null;
const isUndefined: undefined = undefined;

console.log('typeof null:', typeof isNull);            // "object", a famous bug
console.log('typeof undefined:', typeof isUndefined);  // "undefined"
console.log('loose equal:', isNull == isUndefined);    // true
console.log('strict equal:', isNull === isUndefined);  // false

// Where the difference bites: a fallback.
function seatsLeft(value: number | null | undefined): number {
  return value ?? 14;      // only null and undefined are replaced
}

function seatsLeftWrong(value: number | null | undefined): number {
  return value || 14;      // 0 is falsy, so a full bus reads as empty
}

console.log('?? with 0 :', seatsLeft(0));
console.log('|| with 0 :', seatsLeftWrong(0));

test('typeof null is object', typeof isNull, 'object');
test('== sees them as equal', isNull == isUndefined, true);
test('=== does not', isNull === isUndefined, false);
test('?? keeps a real zero', seatsLeft(0), 0);
test('|| loses a real zero', seatsLeftWrong(0), 14);
test('?? still fills in for null', seatsLeft(null), 14);

Output


      

The 0 case is the reason this matters. A bus with 0 seats left is a real answer, and value || 14 silently turns it into a bus with fourteen free seats. Use ?? whenever the fallback is for "missing", and keep || for "missing or empty or zero", which is rarer than people think.

Answers TS Basic 10 in the original set.

The exercise list follows w3resource's TypeScript exercises (CC BY-NC 4.0), and each exercise names the one it answers. The wording, hints, solutions and explanations here are my own.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.