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.
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'.”
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:
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:
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.
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:
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 runtimeThe 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:
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
letvariables and object properties widen literals tostringornumber;constvariables keep the literal.as constmakes 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;
satisfieschecks against a type and keeps the inferred one. asassertions skip checks such as missing properties, so usesatisfieswhen you want validation rather than trust.