EduFlow

Section 1 · Types as a Design Tool

Literal Types, as const and satisfies

18 min3 min readCode playground

Lesson at a glance

Stop literals from widening into string, derive union types from runtime arrays with as const, and validate config objects with satisfies without losing their precise types.

  1. 1Why literals widen
  2. 2as const: freeze the literals
  3. 3Annotation versus satisfies
  4. 4Choosing between them

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Here is a bug report that every TypeScript team has filed at least once: “I’m passing exactly the right string and it says Type 'string' is not assignable to type 'OrderStatus'.”

TypeScript
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Order = { id: number; status: OrderStatus };
function save(order: Order) {}

const draft = { id: 1001, status: 'processing' };
save(draft);
// Error: Argument of type '{ id: number; status: string; }' is not assignable
// to parameter of type 'Order'.

The string is 'processing'. But TypeScript did not infer 'processing' for it. It inferred string. This is widening, and understanding it explains three tools you will use every week: literal types, as const and satisfies.

Why literals widen

TypeScript infers the type a value is likely to have for its whole life, not just the value it starts with. A const can never change, so it keeps the literal. A let or an object property can be reassigned, so TypeScript widens it:

TypeScript
const a = 'processing';           // 'processing'
let b = 'processing';             // string
const c = { status: 'processing' }; // { status: string }

The last one is the surprise. const c stops you reassigning c, but c.status = 'anything' is still legal JavaScript, so the property widens to string.

There are three good fixes, in order of preference. Write the literal where the type is already known (save({ id: 1001, status: 'processing' })), so it is checked against Order directly. Annotate the variable (const draft: Order = …) when you build it ahead of time. Or use as const, which comes next.

as const: freeze the literals

as const tells TypeScript to infer the narrowest possible type: every literal stays a literal, every array becomes a readonly tuple, every property becomes readonly:

TypeScript
const COUNTRIES = ['EG', 'AE', 'SA', 'JO', 'GB', 'DE', 'US', 'CA'] as const;
// readonly ['EG', 'AE', 'SA', 'JO', 'GB', 'DE', 'US', 'CA']

type Country = (typeof COUNTRIES)[number];
// 'EG' | 'AE' | 'SA' | 'JO' | 'GB' | 'DE' | 'US' | 'CA'

That second line is one of the most useful patterns in everyday TypeScript. typeof COUNTRIES turns the value into its type, and [number] asks “what type do I get when I index this with any number?” (you will meet both operators properly in keyof, typeof and Indexed Access Types). The result is a union that is derived from a runtime array. You get one source of truth: the array exists at runtime for dropdowns and validation, and the type follows it automatically.

TypeScript
function isCountry(value: string): value is Country {
  return (COUNTRIES as readonly string[]).includes(value);
}

The widening cast inside isCountry is needed because includes on a tuple of literals only accepts those literals, which defeats the point of a check on an arbitrary string.

Annotation versus satisfies

Cartwheel’s coupons live in a config object. The obvious way to type it is an annotation:

TypeScript
type Coupon = { kind: 'percent'; percentOff: number } | { kind: 'free-shipping' };

const coupons: Record<string, Coupon> = {
  WELCOME10: { kind: 'percent', percentOff: 10 },
  FREESHIP: { kind: 'free-shipping' },
};

coupons.WELCOME10.percentOff; // Error: Property 'percentOff' does not exist on type 'Coupon'.
coupons.SPRNG15;              // compiles, undefined at runtime

The annotation replaces what TypeScript knew. You wrote WELCOME10 as a percent coupon, but its type is now just Coupon, and the object accepts any key at all, typos included.

satisfies checks the value against a type without replacing the inferred type:

TypeScript
const coupons = {
  WELCOME10: { kind: 'percent', percentOff: 10 },
  FREESHIP: { kind: 'free-shipping' },
} satisfies Record<string, Coupon>;

coupons.WELCOME10.percentOff; // number
coupons.SPRNG15;              // Error: Property 'SPRNG15' does not exist on type …

You still get validation: a misspelled percentof or a kind: 'bogo' is flagged right in the object literal. And you keep the precise keys and variants. satisfies also contextually types the literals, so kind stays 'percent' instead of widening to string, without needing as const.

Use the combination as const satisfies T when you want both: deeply readonly literals and a check against a shape. It reads left to right as “freeze this, then make sure it fits”.

Choosing between them

A simple default: annotate function parameters, return types and variables whose type should be the general one (let current: Order). Use satisfies for config objects, lookup tables and anything where the specific keys and values matter later. Use as const for fixed lists you want as a union. All three are compile-time only; none of them changes a byte of the emitted JavaScript.

In the exercise you will derive Country from Cartwheel’s country list and type the currency table so that it rejects unknown countries and remembers exact currencies. That completes the design toolkit for this section; next you move on to generics, starting with the relationships they express.

Key takeaways

  • let variables and object properties widen literals to string or number; const variables keep the literal.
  • as const makes a value deeply readonly and keeps every literal, which lets you derive a union type from a runtime array.
  • A type annotation replaces the inferred type with the annotated one; satisfies checks against a type and keeps the inferred one.
  • as assertions skip checks such as missing properties, so use satisfies when you want validation rather than trust.

Further reading

Check your understanding

4 questions

Question 1 of 4

Why does save(draft) fail when Order['status'] is 'processing' | 'shipped'?

TypeScript
const draft = { id: 1001, status: 'processing' };
save(draft);
Question 2 of 4

What is Country here?

TypeScript
const COUNTRIES = ['EG', 'AE', 'GB'] as const;
type Country = (typeof COUNTRIES)[number];
Question 3 of 4

coupons is declared with satisfies Record<string, Coupon>. Which line is an error?

TypeScript
type Coupon = { kind: 'percent'; percentOff: number } | { kind: 'free-shipping' };
const coupons = {
  WELCOME10: { kind: 'percent', percentOff: 10 },
  FREESHIP: { kind: 'free-shipping' },
} satisfies Record<string, Coupon>;
Question 4 of 4

A teammate writes const config = { retries: 3 } as CheckoutConfig;, and CheckoutConfig also requires timeoutMs. What happens?

Practice

Code playground

Derive countries, check the tables

Make Cartwheel’s lookup tables precise without repeating yourself:

  1. Derive Country from the COUNTRIES array, so it is the union 'EG' | 'AE' | … | 'CA'.
  2. Type currencyByCountry so it must have one entry per Country, and so currencyByCountry.DE has the exact type 'EUR'.
  3. Type coupons so every value is checked against Coupon but the object only has the three keys you wrote, and SPRING15 is known to be a percent coupon.

Keep isCountry working at runtime for any string.

type Currency = 'EGP' | 'AED' | 'SAR' | 'JOD' | 'GBP' | 'EUR' | 'USD' | 'CAD';
type Coupon = { kind: 'percent'; percentOff: number } | { kind: 'free-shipping' };

const COUNTRIES = ['EG', 'AE', 'SA', 'JO', 'GB', 'DE', 'US', 'CA'];
type Country = string; // derive this from COUNTRIES

const currencyByCountry: Record<string, Currency> = {
  EG: 'EGP',
  AE: 'AED',
  SA: 'SAR',
  JO: 'JOD',
  GB: 'GBP',
  DE: 'EUR',
  US: 'USD',
  CA: 'CAD',
};

const coupons: Record<string, Coupon> = {
  WELCOME10: { kind: 'percent', percentOff: 10 },
  SPRING15: { kind: 'percent', percentOff: 15 },
  FREESHIP: { kind: 'free-shipping' },
};

function isCountry(value: string): value is Country {
  return COUNTRIES.includes(value);
}

function currencyFor(country: Country): Currency {
  return currencyByCountry[country];
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<Country, 'EG' | 'AE' | 'SA' | 'JO' | 'GB' | 'DE' | 'US' | 'CA'>>;
type T2 = Expect<Equal<(typeof currencyByCountry)['DE'], 'EUR'>>;
type T3 = Expect<Equal<keyof typeof coupons, 'WELCOME10' | 'SPRING15' | 'FREESHIP'>>;
type T4 = Expect<Equal<(typeof coupons)['SPRING15']['percentOff'], number>>;

// @ts-expect-error: France is not a Cartwheel country
currencyByCountry.FR;

// @ts-expect-error: typo in the coupon code
coupons.SPRNG15;

console.log(currencyFor('DE'), isCountry('EG'), isCountry('FR'));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors, including all type tests
  • isCountry accepts Cartwheel countries and rejects others at runtime
  • currencyFor returns the right currency