TypeScript becomes easier to reason about when you follow the information available at each line. An initializer supplies evidence about a value. A parameter annotation states what callers must supply. A conditional adds evidence inside one branch. A union records several possibilities, and a discriminant ties one possibility to the fields that accompany it. These are connected parts of the same activity: deciding whether an operation is justified before JavaScript executes.
This chapter follows that activity from local variables to API-shaped objects and evolving application states. Assume strict checking, including strictNullChecks and noImplicitAny, unless an example states otherwise. Lines marked “compile error” are intentional exercises. Types and interfaces describe the checker's view; they do not add runtime validation or change an object's contents.
In this chapter: inference and the checker, object shapes and structural typing, and discriminated unions and exhaustiveness.
Inference and the checker
Start with a familiar local value. let age = 28 gives the checker a numeric initializer, so it infers number. Assigning 29 works; assigning "29" fails. The inference does not mean “the integer 28 forever,” and JavaScript's ordinary numeric type does not distinguish int from float. A clear initializer usually makes a repeated annotation unnecessary. An annotation earns its place when it communicates a deliberate contract, especially at a function boundary.
let name = "Himanshu"; // string
let age = 28; // number
name = "Alex";
age = 29;
name = 42; // compile error
A declaration without an initializer is subtler. Under strict checking, TypeScript can evolve its knowledge through assignments rather than fixing the variable to the first assigned type. In this straight-line example, the read after the second assignment is known to be a string. Do not confuse that behavior with explicitly declaring any, which switches off useful checks. Clear initialization or a stated union usually communicates intent better than relying on a variable's type evolving across a complicated function. The TypeScript 2.1 release notes describe evolving inference.
let value;
value = 10;
value = "hello";
value.toUpperCase(); // allowed here: observed string
The difference between any and unknown is a difference in what the checker demands. Both can receive data whose actual shape is initially uncertain. With any, property access and calls can continue without evidence, and that unchecked value can spread into otherwise typed code. With unknown, you can hold the value, but must establish what it is before using operations that require a particular type. That makes unknown useful when the uncertainty is real, such as an external response.
Self-check: What happens with the first call below? Why does the second require a guard? Which type would you choose for untrusted JSON?
let a: any = 42;
a.toUpperCase();
let b: unknown = 42;
b.toUpperCase(); // compile error
if (typeof b === "string") b.toUpperCase();
Reveal answer
The any call passes checking, then throws a TypeError when executed: a number has no toUpperCase method. unknown rejects the unguarded call. The string branch supplies the evidence required for the method, although this particular numeric value never enters it. Prefer unknown at the untrusted boundary, then validate.
A union makes uncertainty explicit without giving up checking. For string | number, only operations valid for both members are immediately safe. Calling a string method would be wrong for the numeric possibility. A typeof branch removes that possibility in the true branch and removes the string possibility in the false branch. This is narrowing: the declared possibilities remain the function's contract, while control flow determines the useful view at each point.
function printId(id: string | number) {
if (typeof id === "string") console.log(id.toUpperCase());
else console.log(id.toFixed(2));
}
The condition must express the fact your program actually needs. A truthiness test excludes null, but also excludes the empty string. It can therefore satisfy the checker while accidentally skipping valid input. After a branch rejoins the rest of the function, you also cannot assume every path established the branch's fact. Trace the false path: it reaches the later access with a possibly null value.
Self-check: Which access fails, and does this function process ""?
function processValue(x: string | null) {
if (x) console.log(x.length);
console.log(x.length);
}
Reveal answer
The second access fails under strictNullChecks: the false path may carry null. The truthiness branch skips "". Use x !== null to handle every string, or return early for null before accessing length later.
This ability to prove local facts does not prove the integrity of remote data. JSON.parse returns any; assigning its result to unknown prevents that unchecked result from silently granting permission to use arbitrary fields. A literal union restricts assignments in checked code, but the alias disappears during compilation. The string "running" remains a perfectly ordinary JavaScript value even if your application only supports three statuses.
type Status = "pending" | "success" | "failed";
const incoming: unknown = JSON.parse('"running"');
if (incoming === "pending" || incoming === "success" || incoming === "failed") {
const status: Status = incoming;
console.log(status);
} else {
throw new Error("Unsupported status");
}
Self-check: Why is Status safer than string, and would let status: Status = "running" throw automatically at runtime?
Reveal answer
The unsupported literal is a compile error in checked code. The union allows the checker to distinguish the supported values. It creates no runtime validator; the explicit comparisons above perform that work for external data.
Object shapes and structural typing
The same reasoning applies to several fields together. An object type states which properties an operation needs. A function that greets a user needs a numeric ID and a string name; it need not require every caller to come from a particular class hierarchy. That is structural compatibility: an existing value can satisfy a smaller required shape while carrying additional information for other parts of the program.
interface User {
id: number;
name: string;
email?: string;
}
function greet(user: User) { console.log(user.name); }
const employee = { id: 10, name: "Himanshu", company: "Flipkart" };
const user: User = employee;
greet(employee); // no implements declaration required
The optional email belongs to the shape, but callers may omit it. Reading it therefore produces string | undefined under strict null checking. Decide what absence means before calling a string method. With exactOptionalPropertyTypes, omitting the property and explicitly storing undefined are also distinguished unless the property's declared type includes undefined. That flag concerns allowed writes; an optional read still needs to account for absence. The option's official reference explains this distinction.
You can express this object shape using either an interface or a type alias. A type alias can additionally name a primitive, union, or other type expression. Interfaces support declaration merging, so another interface declaration with the same name can extend the combined contract. Type aliases cannot be reopened that way. For this user example, the practical safety comes from the properties and their types rather than which spelling you select.
There is an additional diagnostic for a fresh object literal supplied directly to a known object type. The checker treats unexpected keys as likely mistakes: a misspelled optional field otherwise looks like a harmless extra. This makes direct literals stricter than assigning a previously inferred value. It does not change structural typing into an exact schema, and it does not delete any properties at runtime.
Self-check: Which assignment fails? Does age disappear from any of these objects?
const x = { id: 1, name: "A", age: 25 };
const a: User = x;
const b: User = { id: 1, name: "A", age: 25 };
const c = { id: 1, name: "A", age: 25 } as User;
Reveal answer
b fails the fresh-literal excess-property check. a has a compatible required structure; c uses an assertion to bypass this excess-property complaint. Every runtime object still contains age. The User view simply does not expose that property.
An assertion changes the view the checker uses; it does not establish the truth of that view. apiResponse as User neither verifies the numeric ID nor manufactures a missing name. Assertions between unrelated types may themselves be rejected, but passing an assertion check is still no runtime proof. At an external boundary, perform real tests or use a validator before granting the value a trusted object type. The Handbook covers assertions and their erasure.
Self-check: Does apiResponse as User prove that the response contains a numeric ID and a string name?
Reveal answer
No. An assertion adds no runtime check, conversion, or missing field. Validate the unknown input before relying on the User contract; wrongly typed fields can otherwise fail when used.
Discriminated unions and exhaustiveness
Once individual fields are trustworthy, model the relationships between them. An object with status: "success" | "error", data?: string, and error?: Error permits confusing combinations: success without data, error without an error, or both payloads together. Separate union members express that each state comes with its own required evidence. A shared literal property identifies the member; checking it narrows the entire object.
type Result =
| { status: "success"; data: string }
| { status: "error"; error: Error };
function handleResult(result: Result) {
if (result.status === "success") console.log(result.data);
else console.log(result.error);
}
The discriminant works because "success" identifies exactly one member of this union. Inside that branch, data is required, so another presence check is unnecessary. In the other branch, error is required instead. The structure records the invariant once, rather than asking every consumer to rediscover which optional field should accompany which state.
Self-check: Do { status: "success", error: new Error("failed") } or { status: "success" } satisfy Result? What about a success with data: "ok"?
Reveal answer
The first two fail because the success member requires data; the fresh first literal also contains an unexpected error. A success with data: "ok" satisfies the union. Structural typing still applies to existing objects: this model does not physically strip additional fields or validate unknown input.
Now consider evolution. A switch may cover every known status today, yet overlook a status added tomorrow. A default returning "unknown" allows that omission to pass silently. Assigning the remaining value to never instead asks the checker to prove that no allowed value remains after the cases. This turns incomplete handling into a diagnostic at each consumer that claims completeness.
function handleStatus(status: Status) {
switch (status) {
case "pending": return "waiting";
case "success": return "done";
case "failed": return "error";
default: {
const impossible: never = status;
return impossible;
}
}
}
Self-check: Add "cancelled" to Status without editing the switch. What fails, and why is that preferable to returning "unknown"?
Reveal answer
In the default branch, status can now be "cancelled", so assigning it to never fails. The compiler exposes the missing case while the codebase changes. A generic fallback could conceal that oversight. This guarantee concerns the checked union; invalid external strings still require runtime validation.
The complete path is now visible: validate uncertain input, represent its trustworthy shape, narrow the possibilities relevant to an operation, and make complete handling explicit when the model evolves. Designing Reusable TypeScript Types builds on those shapes by preserving relationships across functions and deriving related contracts without duplicating them.
Source: TypeScript-Inference-Practice.pdf, pp. 1–11 and 16–19. Supporting references: narrowing and discriminated unions, object types and excess properties. Back to TypeScript and Node Revision.