161: Object Types, Type Aliases, Interfaces, Optionality, readonly, and Index Signatures
Learning outcomes
By the end of this lesson, you should be able to:
- explain and apply structural typing in a realistic implementation;
- explain and apply type aliases in a realistic implementation;
- explain and apply interfaces in a realistic implementation;
- explain and apply the difference between optional and nullable values in a realistic implementation;
- explain and apply
readonlyin a realistic implementation.
Prerequisites and retrieval
This lesson assumes the earlier 01–06 foundation and the preceding lessons in this module. Before you start, retrieve one concrete example from a previous project where you had to model the same kind of concern. Maybe an API response had fields that were sometimes omitted, a configuration object needed arbitrary metadata, or a function had to promise that it would not mutate its input.
The point is not to memorize a list of TypeScript terms. The point is to make a defensible design decision in a strict TypeScript codebase. The compiler can help us express domain invariants, but static types do not replace runtime validation when data enters the system.
Terminology
- Structural typing: TypeScript generally checks whether a value has the required structure, rather than whether it was created from a particular named declaration.
- Type aliases: A type alias can give a name to primitives, unions, tuples, functions, mapped types, and object types.
- Interfaces: Interfaces describe object-like contracts and support extension as well as declaration merging.
- Optional versus nullable:
field?: Tpermits the property to be absent;field: T | nullrequires the property to exist while allowing its value to represent explicit emptiness. - readonly:
readonlyprevents assignment through that type view; it does not deep-freeze a JavaScript object at runtime. - Index signatures and records: Dictionary-shaped data needs a constrained key/value contract rather than an unbounded escape hatch.
Mental model
Treat Object Types, Type Aliases, Interfaces, Optionality, readonly, and Index Signatures as a design problem. Start with observable inputs and outputs, then state the invariants that must remain true and the failure modes that the design needs to expose. Object modeling is not just a choice of syntax. It is how you make meaningful differences visible: required data is not the same as absent data, null is not the same as omission, read-only access is not the same as runtime immutability, and a dictionary is not the same as a fixed set of named properties.
A strong implementation makes its assumptions visible, narrows uncertainty at system boundaries, and leaves enough evidence—tests, types, constraints, metrics, or diagrams—to support the claim that the design is safe. Types help with the values that pass through the compiler; parsing and validation are still necessary for values received from a request, a database, a file, or another service.
A useful sequence for both production work and technical interviews is:
requirement -> constraints -> model -> implementation -> failure analysis -> verification
Do not jump from a requirement directly to a library call or a convenient broad type. First state what must remain true. Then choose the TypeScript mechanism that makes that rule clear and, where possible, lets the compiler enforce it.
Deep dive
1. Structural typing
If a function needs an object with id and name, TypeScript usually cares that the value has those properties. It does not require the value to have been constructed from one particular class or interface declaration. This is structural typing: compatibility is based primarily on shape.
That flexibility makes composition straightforward. A value with extra properties can often be passed where a smaller shape is required. The trade-off is that names alone do not create nominal identity. Two separately declared types with the same structure can be compatible even when the domain treats them as different concepts.
Decision rule: Use structural typing deliberately when the shape expresses the contract or makes the invariant easier to prove. If structural compatibility would hide an important domain assumption, make that distinction explicit instead of relying on a shorter type declaration.
2. Type aliases
A type alias gives a name to a type expression. It can describe a primitive, union, tuple, function, mapped type, or object type, not only an object shape. Aliases compose particularly well through unions and intersections, so they are a good fit for concepts that are not specifically intended to participate in declaration merging.
For example, an alias can describe a state space such as 'pending' | 'complete' | 'failed', while another can describe an object that combines several existing shapes. The name improves the readability of the surrounding code, but the alias does not create a runtime constructor or validation step.
Decision rule: Use type aliases deliberately when the expression makes the contract or invariant easier to prove. If the alias only saves a few keystrokes while hiding an assumption, prefer a more explicit design.
3. Interfaces
Interfaces describe object-like contracts. They can be extended, and TypeScript also supports declaration merging, where separate declarations with the same interface name contribute to one contract. Those capabilities can be useful for an intentionally extensible API or for library type augmentation.
The useful distinction is not a folklore rule that one is always better than the other. Choose between an interface and a type alias based on the semantics and tooling you need. An interface is often a clear choice for an object contract that consumers may extend; an alias is often clearer for unions, tuples, or other composed type expressions. Both are checked at compile time and neither validates untrusted runtime data.
Decision rule: Use interfaces deliberately when their object-contract, extension, or declaration-merging behavior makes the invariant easier to prove. If that behavior is not wanted, do not introduce it merely from habit.
4. Optional versus nullable
This is where API and persistence models commonly become ambiguous. field?: T means the property may be absent. field: T | null means the property is required, but its value may explicitly represent no value. With strict null checking enabled, those states affect what callers must check before using the field.
The distinction carries meaning across boundaries. An omitted field may mean “the client did not provide a value” or “leave the existing value unchanged.” A null field may mean “the client explicitly cleared this value.” Whether that interpretation is correct depends on the contract, so document it rather than treating undefined and null as interchangeable.
Decision rule: Choose optional versus nullable deliberately when the distinction makes the contract or invariant easier to prove. If omission and explicit emptiness have different behavior, model them differently and test both cases.
5. readonly
readonly prevents assignment through the type view that declares the property as read-only. It is useful for documenting a non-mutating function and for allowing the compiler to catch accidental coupling between callers and callees.
There is one subtle detail worth keeping in view: readonly is not a runtime deep freeze. It does not recursively freeze a JavaScript object, and another alias or an untyped operation may still mutate the underlying value. Treat it as a compile-time constraint and an API signal, not as a security boundary or an immutable data structure.
Decision rule: Use readonly deliberately when preventing writes through a particular API makes the contract or invariant easier to prove. If the program needs runtime immutability, choose and enforce a runtime strategy as well.
6. Index signatures and records
Dictionary-shaped data has a different contract from an object with a known list of properties. An index signature expresses the value type allowed for keys, for example { [key: string]: string }. An unrestricted string index signature also affects named properties: every named property must be compatible with the index value type. That behavior is a frequent source of surprising errors when a fixed property has a different type.
When the keys are known, Record<K, V> is often clearer because the key set is visible in the type. When keys are genuinely open-ended, constrain the values to a useful safe type instead of using an unbounded any index signature. The type should describe what the application can actually handle.
Decision rule: Use index signatures and records deliberately when their key/value contract makes the invariant easier to prove. If the set of keys is known, model it. If it is not, keep the value type bounded and validate external data before treating it as a record.
Worked example
Consider a strict TypeScript codebase that uses the compiler to model domain invariants, while still validating data at runtime. Begin by writing the requirement in one sentence. Then list the input and output contracts and identify which of the concepts above owns each possible failure mode.
The important design move is separation of concerns. Parsing and validation belong at the boundary. Domain rules belong in the domain or service layer. Persistence rules belong in the database or repository. Presentation rules belong in the client. Combining all of these in one type or one happy-path function can make a demo look shorter, but it makes edge cases and ownership much harder to reason about.
One way to prevent arbitrary strings from being confused with validated task identifiers is to use a branded type:
type TaskId = string & { readonly __brand: 'TaskId' };
function parseTaskId(value: unknown): TaskId {
if (typeof value !== 'string' || value.length === 0) {
throw new TypeError('Invalid task id');
}
return value as TaskId;
}
The compiler will not call parseTaskId for you, and the assertion in the function does not inspect a database or prove that the ID exists. The function establishes only the invariant it checks: the value is a non-empty string at this boundary. The brand then helps prevent an already-typed string from being passed as a TaskId accidentally within the rest of the typed code.
Walk through at least four cases: the normal path; an empty or missing value; a duplicate, retry, or concurrent path where that behavior is relevant; and a dependency failure. For each case, state which layer detects the problem and what the caller observes. For example, parsing can reject a missing identifier, while a repository may report a duplicate or a dependency outage. This level of ownership analysis is what makes the example useful in a senior code review or technical interview.
Production perspective
Production correctness is broader than “the code works on my machine.” Ask how the design behaves during deploys, retries, partial failure, stale clients, concurrent requests, malformed data, schema changes, and high-cardinality workloads. Prefer explicit contracts, bounded resource usage, structured errors, and measurable behavior. Optimize only after evidence identifies a bottleneck or a meaningful risk.
When the topic involves an external dependency, define timeout and cancellation behavior. When it involves persistence, define transaction and consistency expectations. When it involves user-visible state, account for loading, empty, error, stale, and success states. When it involves security, assume the client can be modified and all network input is untrusted. A TypeScript declaration cannot authorize a request, sanitize a payload, or repair malformed data after deployment.
Guided lab
Model separate TaskCreate, TaskUpdate, TaskRecord, and TaskResponse types. Your design should make the differences between input, update, stored, and returned data visible. Demonstrate absent versus null, use readonly inputs where a function must not mutate its argument, and define a safe metadata record without an unbounded any index signature.
Complete the lab with this discipline:
- Write the requirement and two non-requirements.
- List input, output, and error contracts before implementation.
- Implement the smallest correct vertical slice.
- Add at least one invalid-input test and one edge-case test.
- Instrument or inspect the behavior instead of guessing.
- Refactor one hidden assumption into an explicit type, constraint, function, or configuration.
- Explain one alternative design and why you did not choose it.
- Record a short “what would break at 10× scale?” note.
The lab is intentionally small, but the reasoning should not be. In particular, test what an omitted update field means, what an explicit null means, and where metadata is validated. A passing compile is evidence about the typed source, not proof that a request body or persisted document satisfies the model.
Edge cases and failure modes
- Structural typing: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
- Type aliases: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
- Interfaces: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
- Optional versus nullable: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
- readonly: test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes.
Not every category will use every test literally. The point is to ask which invalid state can cross the boundary, which operation can race or repeat, and how the implementation behaves at credible limits. A type that looks precise in an editor still needs tests for runtime input and for the layer that owns the invariant.
Common mistakes and debugging
- Solving the example instead of the requirement: a copied pattern can be syntactically correct but architecturally wrong.
- Hiding uncertainty with assertions, broad exception handlers, permissive schemas, or “temporary”
anyvalues. - Testing only the happy path and therefore discovering contracts only after integration.
- Optimizing before measuring, or selecting a scalable mechanism without a scale requirement.
- Letting client-side behavior stand in for server-side authorization, validation, or persistence guarantees.
For debugging, reproduce the smallest failing case first. Inspect the actual value and the execution path rather than trusting the declared type. Trace the boundary where the invariant first becomes false, then fix the layer that owns that invariant instead of adding a downstream patch. In a TypeScript project, inspect both the source and the generated or running JavaScript when runtime behavior differs from what the types suggest.
Interview questions
- What problem does structural typing solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do type aliases solve, and what trade-off or failure mode would make you choose a different approach?
- What problem do interfaces solve, and what trade-off or failure mode would make you choose a different approach?
- What problem does the optional-versus-nullable distinction solve, and what trade-off or failure mode would make you choose a different approach?
- What problem does
readonlysolve, and what trade-off or failure mode would make you choose a different approach?
Checkpoint
Without notes, explain Object Types, Type Aliases, Interfaces, Optionality, readonly, and Index Signatures to another developer in five minutes. Include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson code.
Mastery checklist
- I can define the core terms precisely.
- I can choose a design from requirements instead of from habit.
- I can implement and test the normal path and edge cases.
- I can explain the runtime, storage, or complexity cost.
- I can identify which layer owns validation, errors, and recovery.
- I can compare at least two reasonable alternatives.
- I can explain how the design changes at larger scale or under stricter reliability requirements.
