Skip to content
aviral gupta

// B2.5 · ~38 min · Beginner

Literal types and as const; build: a typed contact book

After this lesson you can allow only a few exact values, predict when TypeScript widens a literal, and build a typed contact book.

Lesson 5 of 5 in B2 Everyday types

End of the module

You will be able to

  • Write literal types and unions of literals, and read the errors tsc reports for other values
  • Predict when TypeScript widens a literal type, and keep it with as "GET" or as const
  • Build a typed contact book with an interface, an optional property, a literal union and an as const list
  1. Warm-up · Activity 1 of 7

    From earlier in this module: which of these are true? Pick all that apply.

    Select all that apply.

  2. Predict · Activity 2 of 7

    Predict before you read on: alignment accepts only three exact strings. What does tsc report?

    function printText(s: string, alignment: "left" | "right" | "center") {
      console.log(s + " (" + alignment + ")");
    }
    
    printText("Hello, world", "left");
    printText("G'day, mate", "centre");
  3. Practice · Activity 3 of 7

    Fill in the two words after the object literal that keep method as the literal type "GET", so that tsc accepts the call.

    function handleRequest(url: string, method: "GET" | "POST") {
      console.log(method + " " + url);
    }
    
    const req = { url: "https://example.com", method: "GET" } ____;
    handleRequest(req.url, req.method);
    const req = { url: "https://example.com", method: "GET" } ;
  4. Practice · Activity 4 of 7

    Which type does TypeScript infer for greeting?

    const greeting = "Hello";
    let other = "Hello";
  5. Practice · Activity 5 of 7

    Match each declaration to the type TypeScript infers.

  6. Brain teaser · Activity 6 of 7

    Brain teaser. settings is a const. What does tsc report for line 2?

    const settings = { theme: "dark" };
    settings.theme = "light";
    console.log(settings.theme);
  7. Apply · Activity 7 of 7

    Mini-task. Declare type Theme = "light" | "dark" and a function background(theme: Theme) that returns "#222222" for dark and "#ffffff" otherwise. Store a saved setting as an object { user: "ada", theme: "dark" } and pass saved.theme to background. Read the error tsc gives first, then fix it with as const. Finally try background("blue").

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A price list with aligned columns

pad accepts only "left" or "right" as its alignment. Each column's settings are stored in an object with as const, so align keeps its literal type and can be passed to pad. total is a let, so TypeScript infers number and it can grow in the loop. Run npx tsc, then node main.ts. Then remove one as const and read what tsc says, or pass "middle" to pad.

main.ts

// Literal types: only these exact values fit.
type Align = "left" | "right";

function pad(text: string, width: number, align: Align): string {
  return align === "left" ? text.padEnd(width, ".") : text.padStart(width, ".");
}

// as const keeps "left" and "right" as literal types, not string.
const nameColumn = { width: 10, align: "left" } as const;
const priceColumn = { width: 8, align: "right" } as const;

function row(name: string, price: string): string {
  return pad(name, nameColumn.width, nameColumn.align) + pad(price, priceColumn.width, priceColumn.align);
}

const items = [
  { name: "Tea", price: 2.5 },
  { name: "Cake", price: 3.75 },
];

let total = 0; // let: TypeScript infers number, so it can change
for (const item of items) {
  console.log(row(item.name, item.price.toFixed(2)));
  total += item.price;
}
console.log(row("Total", total.toFixed(2)));

Run it with

npx tsc
node main.ts

Output

Tea...........2.50
Cake..........3.75
Total.........6.25
  • Without as const, nameColumn.align would be string, and pad(…, nameColumn.align) would be error TS2345.
  • items has no annotation: TypeScript infers { name: string; price: number; }[] from the literals.
  • The === comparison inside pad is checked as well: align === "centre" would be error TS2367.
  • The literal types exist only for tsc: Node.js strips them, and the output is what plain JavaScript prints.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Exercises

Exercise 1 of 3

Let tsc catch the typo

This program should print Express: 9.90 EUR, but it prints 4.90, and tsc reports nothing: speed is a string, so the typo "expres" is accepted and quietly treated as standard. Change the type of speed to a union of the two literal strings "standard" and "express". Then tsc reports the typo: fix it.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    A union of literals is written with | between the values: "a" | "b".

  2. Hint 2

    The first line becomes export function shippingCost(speed: "standard" | "express"): number {

  3. Hint 3

    tsc then reports Argument of type '"expres"' on line 5. Write "express".

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export function shippingCost(speed: "standard" | "express"): number {
  return speed === "express" ? 9.9 : 4.9;
}

console.log("Express: " + shippingCost("express").toFixed(2) + " EUR");
console.log("Standard: " + shippingCost("standard").toFixed(2) + " EUR");
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

export function shippingCost(speed: string): number {
  return speed === "express" ? 9.9 : 4.9;
}

console.log("Express: " + shippingCost("expres").toFixed(2) + " EUR");
console.log("Standard: " + shippingCost("standard").toFixed(2) + " EUR");

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';
import {shippingCost} from './main.ts';

test('shippingCost("express") is 9.9', () => {
  assert.equal(shippingCost('express'), 9.9, 'shippingCost("express") should be 9.9');
});

test('shippingCost("standard") is 4.9', () => {
  assert.equal(shippingCost('standard'), 4.9, 'shippingCost("standard") should be 4.9');
});

test('the program prints both prices', async () => {
  const out = (await runMain()).trimEnd();
  assert.equal(out, 'Express: 9.90 EUR\nStandard: 4.90 EUR', `the program printed ${JSON.stringify(out)}`);
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Exercise 2 of 3

Keep the method literal

request accepts only "GET" or "POST", and the program prints the right lines, but tsc reports Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"' twice. Fix it where the objects are created, not in the calls and not in the function: keep each method as its literal type.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Object properties are widened: home.method is string, not "GET", because it could still be assigned.

  2. Hint 2

    Two words after the closing brace of an object literal turn every property into its literal type.

  3. Hint 3

    Write const home = { url: "/home", method: "GET" } as const; and the same for login.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

export function request(url: string, method: "GET" | "POST"): string {
  return method + " " + url;
}

const home = { url: "/home", method: "GET" } as const;
const login = { url: "/login", method: "POST" } as const;
console.log(request(home.url, home.method));
console.log(request(login.url, login.method));
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

export function request(url: string, method: "GET" | "POST"): string {
  return method + " " + url;
}

const home = { url: "/home", method: "GET" };
const login = { url: "/login", method: "POST" };
console.log(request(home.url, home.method));
console.log(request(login.url, login.method));

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';
import {request} from './main.ts';

test('request joins method and url', () => {
  assert.equal(request('/cart', 'POST'), 'POST /cart', 'request("/cart", "POST") should be POST /cart');
});

test('the program prints both requests', async () => {
  const out = (await runMain()).trimEnd();
  assert.equal(out, 'GET /home\nPOST /login', `the program printed ${JSON.stringify(out)}`);
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Exercise 3 of 3

Build: a typed contact book

The module build. The contact book below runs, but tsc reports many errors and the functions are empty. Declare type Kind ("friend", "work" or "family") and interface Contact (name and email as strings, an optional phone, kind as a Kind, favorite as a boolean). Make KINDS an as const list and give ada a type, so that tsc accepts them. Then write the functions: formatContact gives * Ada Lovelace <ada@example.com> (work, no phone), with "* " only for favorites and the phone number when there is one; byKind keeps the contacts of one kind; favorites gives their names. The tests check each function and the printed book.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads the TypeScript compiler (up to 2.1 MB) and keeps it cached. Your code stays on your device.

Hints
  1. Hint 1

    Start with the types: type Kind = "friend" | "work" | "family"; and an interface Contact with phone?: string.

  2. Hint 2

    ada's kind is widened to string, and so is each element of KINDS. Write const ada: Contact = …, and put as const after the KINDS array.

  3. Hint 3

    contact.phone ?? "no phone" gives the default. byKind is book.filter((contact) => contact.kind === kind); favorites filters on favorite, then maps to the name.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

// A typed contact book. Declare the types, then finish the functions.

type Kind = "friend" | "work" | "family";

interface Contact {
  name: string;
  email: string;
  phone?: string;
  kind: Kind;
  favorite: boolean;
}

export const KINDS = ["friend", "work", "family"] as const;

export function formatContact(contact: Contact): string {
  const star = contact.favorite ? "* " : "";
  const phone = contact.phone ?? "no phone";
  return star + contact.name + " <" + contact.email + "> (" + contact.kind + ", " + phone + ")";
}

export function byKind(book: Contact[], kind: Kind): Contact[] {
  return book.filter((contact) => contact.kind === kind);
}

export function favorites(book: Contact[]): string[] {
  return book.filter((contact) => contact.favorite).map((contact) => contact.name);
}

export function summary(book: Contact[]): string {
  const parts: string[] = [];
  for (const kind of KINDS) {
    parts.push(kind + ": " + byKind(book, kind).length);
  }
  return parts.join(", ");
}

const ada: Contact = { name: "Ada Lovelace", email: "ada@example.com", kind: "work", favorite: true };

const book: Contact[] = [
  ada,
  { name: "Grace Hopper", email: "grace@example.com", phone: "+49 30 1234567", kind: "work", favorite: false },
  { name: "Linus Torvalds", email: "linus@example.com", kind: "friend", favorite: true },
];

for (const contact of book) {
  console.log(formatContact(contact));
}
console.log(summary(book));
console.log("Favorites: " + favorites(book).join(", "));
Run it on your computer

Install TypeScript 7.0 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.ts

// A typed contact book. Declare the types, then finish the functions.

// 1. Kind: "friend", "work" or "family".
// 2. Contact: name and email (text), phone (optional text), kind (a Kind),
//    favorite (true or false).

export const KINDS = ["friend", "work", "family"];

export function formatContact(contact: Contact): string {
  // "* Ada Lovelace <ada@example.com> (work, no phone)"; only favorites start with "* "
  return "";
}

export function byKind(book: Contact[], kind: Kind): Contact[] {
  return [];
}

export function favorites(book: Contact[]): string[] {
  // the names of every favorite, in book order
  return [];
}

export function summary(book: Contact[]): string {
  // "friend: 1, work: 2, family: 0", in the order of KINDS
  const parts: string[] = [];
  for (const kind of KINDS) {
    parts.push(kind + ": " + byKind(book, kind).length);
  }
  return parts.join(", ");
}

const ada = { name: "Ada Lovelace", email: "ada@example.com", kind: "work", favorite: true };

const book: Contact[] = [
  ada,
  { name: "Grace Hopper", email: "grace@example.com", phone: "+49 30 1234567", kind: "work", favorite: false },
  { name: "Linus Torvalds", email: "linus@example.com", kind: "friend", favorite: true },
];

for (const contact of book) {
  console.log(formatContact(contact));
}
console.log(summary(book));
console.log("Favorites: " + favorites(book).join(", "));

main.test.ts

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {runMain} from './learnrun.js';
import {formatContact, byKind, favorites, summary} from './main.ts';

const ada = {name: 'Ada', email: 'ada@example.com', kind: 'work', favorite: true};
const bob = {name: 'Bob', email: 'bob@example.com', phone: '555-0100', kind: 'family', favorite: false};
const cy = {name: 'Cy', email: 'cy@example.com', kind: 'work', favorite: false};
const book = [ada, bob, cy];

test('formatContact marks a favorite without a phone', () => {
  const line = formatContact(ada);
  assert.equal(line, '* Ada <ada@example.com> (work, no phone)', `formatContact(ada) gave ${JSON.stringify(line)}`);
});

test('formatContact shows the phone of a contact who is not a favorite', () => {
  const line = formatContact(bob);
  assert.equal(line, 'Bob <bob@example.com> (family, 555-0100)', `formatContact(bob) gave ${JSON.stringify(line)}`);
});

test('byKind keeps only the contacts of that kind', () => {
  const names = byKind(book, 'work').map((contact) => contact.name);
  assert.deepEqual(names, ['Ada', 'Cy'], `byKind(book, 'work') gave ${JSON.stringify(names)}`);
  assert.equal(byKind(book, 'friend').length, 0, 'byKind(book, "friend") should be empty');
});

test('favorites gives the names of the favorites', () => {
  const names = favorites(book);
  assert.deepEqual(names, ['Ada'], `favorites(book) gave ${JSON.stringify(names)}`);
});

test('summary counts every kind', () => {
  const text = summary(book);
  assert.equal(text, 'friend: 0, work: 2, family: 1', `summary(book) gave ${JSON.stringify(text)}`);
});

test('the program prints the whole book', async () => {
  const out = (await runMain()).trimEnd();
  assert.equal(
    out,
    '* Ada Lovelace <ada@example.com> (work, no phone)\nGrace Hopper <grace@example.com> (work, +49 30 1234567)\n* Linus Torvalds <linus@example.com> (friend, no phone)\nfriend: 1, work: 2, family: 0\nFavorites: Ada Lovelace, Linus Torvalds',
    `the program printed ${JSON.stringify(out)}`
  );
});

package.json

{
  "type": "module"
}

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "lib": [
      "esnext",
      "dom"
    ],
    "types": [],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true
  },
  "include": [
    "**/*.ts"
  ],
  "exclude": [
    "**/*.test.ts"
  ]
}

npx tsc needs TypeScript in the folder: run npm install --save-dev typescript there once. tsc checks the types; Node.js runs main.ts by removing them.

Run the program:

npx tsc
node main.ts

Run the checks (needs learnrun.js in the same folder):

npx tsc
node --test
Download learnrun.js

Common mistakes

A value just outside the literal union

function printText(s: string, alignment: "left" | "right" | "center") {
  console.log(s);
}

printText("Price list", "centre");

What tsc or Node.js prints

main.ts(5,25): error TS2345: Argument of type '"centre"' is not assignable to parameter of type '"center" | "left" | "right"'.

Why, and the fix

A literal union allows exactly the values it lists, spelled exactly as listed. tsc names the value it found, "centre", and the values it accepts. Write "center". This is the point of a literal union: in plain JavaScript the typo would run and quietly fall through every check for "center".

A property widened to string

type Mode = "light" | "dark";

let mode: Mode = "light";
const saved = { mode: "dark" };
mode = saved.mode;

What tsc or Node.js prints

main.ts(5,1): error TS2322: Type 'string' is not assignable to type 'Mode'.

Why, and the fix

saved.mode holds "dark", but its type is string: an object property can still be assigned, so TypeScript widens it. string is wider than Mode, so tsc refuses. Keep the literal where the object is created: const saved = { mode: "dark" } as const;, or annotate it: const saved: { mode: Mode } = { mode: "dark" };.

Changing a list made with as const

const KINDS = ["friend", "work"] as const;
KINDS.push("family");

What tsc or Node.js prints

main.ts(2,7): error TS2339: Property 'push' does not exist on type 'readonly ["friend", "work"]'.

Why, and the fix

as const makes an array a readonly tuple: exactly these elements, in this order, and no method that changes it, such as push. That is what you want for a fixed list of kinds. If the list must grow, add "family" to the literal itself, or leave out as const and annotate it, for example const kinds: string[] = ["friend", "work"].

TypeScript in the browser: the TypeScript 6.0.3 compiler, Apache-2.0, then your browser’s own engine. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Literal types: one exact value as a type

Besides string and number, a type can be one exact value: let x: "hello" accepts only the text "hello", so x = "howdy" is error TS2322. One value alone is rarely useful; a union of literals is. function printText(s: string, alignment: "left" | "right" | "center") accepts exactly those three strings, and the typo "centre" is error TS2345 before anything runs. Number literals work the same way: 1 | 2 | 3. boolean itself is the union true | false. Name a union you use more than once: type Kind = "friend" | "work" | "family". It checks comparisons too: kind === "wrok" is error TS2367, because a Kind can never equal it. Unions in general come in module B3.

const keeps the literal, let widens it

TypeScript infers a literal type only where the value cannot change. const greeting = "Hello" has the type "Hello": a const always holds that one string. let other = "Hello" has the type string, because it may get any other string later; likewise let count = 3 is a number and const limit = 3 has the type 3. Object properties behave like let. In const req = { url: "/home", method: "GET" }, req.method is string, since the property can still be assigned: const fixes the variable, not what is inside the object. So passing req.method where "GET" | "POST" is expected is error TS2345: Argument of type 'string' is not assignable to parameter of type '"GET" | "POST"'.

Two fixes: as "GET" and as const

An assertion on one property, method: "GET" as "GET", keeps that property's literal type, so assigning "GUESS" to it later is error TS2322. as const after the whole literal gives every property its literal type and makes it readonly: { url: "/home", method: "GET" } as const has the type { readonly url: "/home"; readonly method: "GET"; }. An array becomes a readonly tuple: ["friend", "work", "family"] as const, so push on it is error TS2339, and each element keeps its literal type. A third way is an annotation: const ada: Contact = { … } checks the literal against Contact directly. None of this changes the values at run time; as const only informs the type-checker.

Sources

Last reviewed October 3, 2026