Most of what you describe in a real application is objects. This lesson is the vocabulary for that: optional properties, readonly, nesting, open-ended keys, and one rule about object literals that looks like a bug the first time you meet it.
Describing a shape
An object type lists property names and their types. You can write it inline:
let size: { size: SizeName; price: number };or give it a name, which is what you do as soon as it is used twice:
export interface ProductSize {
id: UUID;
size: SizeName;
price: number;
}Separators are interchangeable — semicolons, commas, or newlines all work. Semicolons are the usual choice in a multi-line declaration and this track uses them throughout.
Whether to reach for interface or type is
the next lesson. For everything
below, they behave the same.
Optional properties
A ? after the name means the property may be absent:
export interface AddressWriteRequest {
label?: string;
recipientName?: string;
phone?: string;
line1: string;
line2?: string;
city: string;
state: string;
postalCode: string;
primary?: boolean;
}Read that as a form: line1, city, state and
postalCode are required, and the rest may be left out entirely. It is a precise
description of what the endpoint accepts, and it is enforced at every call site.
Reading an optional property gives you T | undefined, so you have to deal with the
absent case:
// This does not compile.
function describe(address: AddressWriteRequest) {
return address.label.toUpperCase();
// ~~~~~~~~~~~~~
// 'address.label' is possibly 'undefined'.
}The usual fixes are ?? for a default, or optional chaining:
const label = address.label ?? 'Home';
const shouted = address.label?.toUpperCase(); // string | undefinedOptional is not the same as nullable
Three declarations that people treat as interchangeable and are not:
interface A { phone?: string } // may be MISSING
interface B { phone: string | undefined } // must be PRESENT, may be undefined
interface C { phone: string | null } // must be PRESENT, may be nullWith B, { } is an error — you have to write
{ phone: undefined }. With A it is fine. The difference matters as soon
as anything iterates keys, spreads the object, or serialises it: an absent property does not appear
in JSON.stringify output, while an explicit undefined is dropped and an
explicit null is kept.
The pizza app uses both deliberately, and the split follows the direction of travel. Data coming
from the API uses | null, because the server sends the key with a null value:
export interface Order {
id: UUID;
status: OrderStatus;
orderType: OrderType;
customerName: string;
email: string;
phone: string | null;
addressLine1: string | null;
addressLine2: string | null;Data going to the API uses ?, because an omitted field is simply not sent:
export interface OrderCreateRequest {
orderType: OrderType;
customerName: string;
guestEmail?: string;
phone?: string;
addressLine1?: string;Same concept, opposite conventions, and the types record which is which. That is a good habit: let the type say what the wire format actually does rather than what is convenient.
readonly properties
readonly stops assignment after construction:
export class ApiError extends Error {
readonly status: number;
readonly body: ApiErrorBody | null;Assigning to err.status anywhere else is a compile error. As with readonly arrays,
two caveats: it is shallow, so err.body.message is still writable,
and it is compile-time only — nothing is frozen at runtime and
Object.assign will happily overwrite it.
It is also not part of assignability in the direction you might expect. A type with
readonly properties accepts a mutable object and vice versa; TypeScript treats the
modifier as advice to the code that holds the reference, not as part of the shape.
Nesting
Property types are types, so they nest without any special syntax:
export interface ReportDashboard {
summary: {
totalOrders: number;
totalRevenue: number;
averageOrderValue: number;
itemsSold: number;
};Here summary is written inline because nothing else refers to it. The moment a
second thing does, pull it out and name it — an anonymous shape repeated in two places is two
shapes that will disagree.
Index signatures, for genuinely open keys
Sometimes the keys are data rather than structure. An index signature says "any key of this type maps to a value of that type":
interface FieldErrors {
[field: string]: string;
}In practice most people write the utility-type version, which means the same thing and is shorter:
fieldErrors(): Record<string, string> {
const result: Record<string, string> = {};
for (const sub of this.body?.errors ?? []) {
if (sub.field) result[sub.field] = sub.message;
}
return result;
}That is the pizza app turning the API's list of validation errors into a lookup, so a form can render each message under the right input. The keys are field names from the server — genuinely unknown at compile time — which is exactly when an index signature is right.
Two things to know. Reading a missing key gives you the value type rather than
undefined, unless noUncheckedIndexedAccess is on — the same optimism
array indexing has. And if you mix an index signature with named properties, the named ones must
be compatible with it:
// This does not compile.
interface Config {
[key: string]: string;
retries: number;
~~~~~~~
// Property 'retries' of type 'number' is not assignable to
// 'string' index type 'string'.
}The signature promised every key is a string, and retries breaks that promise.
Use index signatures sparingly. If you know the keys, list them — you lose autocomplete and
misspelling protection the moment you reach for Record<string, T>. When the keys
are a known set, Record<OrderStatus, T> is far better, and
lesson 14 shows why.
Objects are compared by shape
This is worth stating plainly here, because it explains most of what follows and a good deal of what surprises people later.
TypeScript does not care what a type is called. If a value has the properties a type requires, of the right types, it is assignable — even if the two were declared independently and know nothing about each other.
function label(thing: { name: string }) {
return thing.name.toUpperCase();
}
label(product); // fine — a Product has a name: string
label(topping); // fine — so does a Topping
label(crust); // fineThree unrelated interfaces, one function, no inheritance and no implements. In a
nominal language you would need a shared base type or an interface each of them declares. Here the
requirement is the type.
This is what makes small parameter types worth writing. A function that only reads a name should
ask for { name: string } rather than Product — it becomes usable with
anything that has one, and it documents exactly what it touches.
Getting part of a shape
Rather than hand-writing a subset, derive it. The app does this when it needs a lighter version of a type, and lesson 14 covers the full set:
type ProductSummary = Pick<Product, 'id' | 'name' | 'imageUrl'>;
type ProductDraft = Omit<Product, 'id' | 'createdAt' | 'updatedAt'>;
type PartialProduct = Partial<Product>; // every property optionalThe reason to derive rather than retype is the same as everywhere else in this track: a copy is
a thing that can fall out of step. Rename a field on Product and
Pick<Product, 'name'> fails the build; a hand-written
{ name: string } does not.
Excess property checks
Here is the rule that looks inconsistent until you know the reasoning.
// This does not compile.
const topping: ToppingWriteRequest = {
name: 'Pepperoni',
price: 1.5,
category: 'MEAT',
active: true,
colour: 'red',
~~~~~~
// Object literal may only specify known properties, and 'colour'
// does not exist in type 'ToppingWriteRequest'.
};Reasonable enough. But now assign through a variable and the same object is accepted:
const draft = {
name: 'Pepperoni',
price: 1.5,
category: 'MEAT' as const,
active: true,
colour: 'red',
};
const topping: ToppingWriteRequest = draft; // fineThat is not a bug. TypeScript is structural — draft has everything
ToppingWriteRequest requires, and having more than required is normally
harmless. That is the same rule that lets you pass a full Order where only
{ id: string } was asked for.
The literal case is special-cased on top, because a property written directly at the assignment is almost always a typo or a misunderstanding — there is nowhere else the extra key could usefully be going. So freshly-written literals get the stricter treatment, and objects that have been around get the structural one.
Knowing this saves real time when a spread suddenly stops complaining, and it explains why adding a variable "fixes" an error that was telling you something true.
Two ways to escape the check, and when each is right
Sometimes the extra property is genuinely intended. Three options, in descending order of how much they preserve.
Add it to the type — if it belongs there, this is not an escape at all, it is the fix.
Use satisfies, which checks the object against the type without
changing its inferred type:
const topping = {
name: 'Pepperoni',
price: 1.5,
category: 'MEAT',
active: true,
} satisfies ToppingWriteRequest;Here topping.name is still the literal 'Pepperoni', not widened to
string — which an annotation would have done. That is usually what you wanted, and
lesson 16 is about why.
Assert with as — which silences the check and everything else with
it. Available, rarely correct.
Describing an object you did not design
A last practical note. When you are writing the type for a payload somebody else defined, resist the urge to tidy it. The type's job is to say what is actually there.
The app's error envelope is a good example — it mirrors the Spring Boot API field for field, including the optional list that is only present on validation failures:
/** One invalid field, from the API's ApiSubError. */
export interface ApiSubError {
field: string | null;
message: string;
}
/** The single error envelope every endpoint returns. */
export interface ApiErrorBody {
statusCode: number;
error: string;
message: string;
path: string;
timestamp: string;
errors?: ApiSubError[];
}field is string | null because a whole-request error has no field, and
errors is optional because most responses do not carry one. Both facts came from the
server, not from what would have been convenient — and both are why the
fieldErrors() helper above has to check sub.field before using it.
A type that quietly rounds off the awkward parts of a payload is worse than no type, because it reads as documentation while being wrong. If the server can send you a null, say so, and let the compiler make everyone deal with it.
Next
Interfaces vs Type Aliases — they overlap almost entirely, so the question is which three differences are real.