160: Primitive Types, Literals, Inference, any, unknown, never, and void
Learning outcomes
By the end of this lesson, you can:
- explain and apply primitive types in a realistic implementation;
- explain and apply literal types in a realistic implementation;
- explain and apply inference and contextual typing in a realistic implementation;
- explain and apply any in a realistic implementation;
- explain and apply unknown in a realistic implementation.
Prerequisites and retrieval
This lesson assumes the earlier 01–06 foundation and the preceding lessons in this module. Before reading, retrieve one concrete example from a previous project where one of these concerns appeared. Perhaps a value came from JSON, a callback received an unexpectedly broad type, or a state value was allowed to drift beyond the states the domain actually supports.
The point is not to memorize a list of TypeScript terms. The point is to make a defensible choice inside a strict TypeScript codebase. The compiler can help model domain invariants, but its types do not replace runtime validation. Anything crossing a runtime boundary still has to be checked at runtime.
Terminology
- Primitive types: Model JavaScript primitives with
string,number,boolean,bigint,symbol,null, andundefined. These describe the kinds of values JavaScript can hold; they do not change JavaScript's runtime rules. - Literal types: A literal such as
"open"can itself be a type, which lets you describe a finite set of valid states rather than accepting every string. - Inference and contextual typing: Let TypeScript infer obvious local types. Add annotations at public APIs, ambiguous boundaries, and places where choosing a broader or narrower contract is deliberate. Callbacks are often typed contextually from the API that receives them.
- any:
anydisables meaningful checking for operations performed through that value and can spread unsafely into downstream code. - unknown:
unknowncan hold any value, but it must be narrowed before use. That makes it the appropriate default for untrusted JSON, caught errors, plugin values, and other uncertain boundaries. - never and void:
nevermodels a value that cannot exist or a path that does not return, and it is useful for exhaustiveness checks.voiddescribes a function whose caller should not rely on a returned value.
Mental model
Treat Primitive Types, Literals, Inference, any, unknown, never, and void as a design problem with observable inputs, outputs, invariants, and failure modes. The useful question is not simply, “Which annotation makes this line compile?” Ask instead what information the compiler already knows, where that information is widened or lost, and whether a special type is preserving safety or bypassing it.
A strong implementation makes its assumptions visible, narrows uncertainty at the boundary where it enters the system, and leaves enough evidence to explain why the design is safe. That evidence might be types, constraints, tests, metrics, or a diagram. Static types are part of the argument; they are not proof that malformed runtime data cannot arrive.
A useful sequence for both interviews and production work is:
requirement -> constraints -> model -> implementation -> failure analysis -> verification
Do not jump from a requirement straight to a library call or an annotation copied from another project. First state what must remain true. Then select the type or runtime mechanism that helps enforce that invariant, and verify the cases where it can fail.
Deep dive
1. Primitive types
JavaScript has primitive values, and TypeScript gives most of them familiar type names: string, number, boolean, bigint, symbol, null, and undefined. Use these types to describe the basic shape of values in a contract. For example, a function accepting a string should not silently be treated as accepting an object merely because the caller currently happens to pass one.
The annotation does not alter runtime JavaScript. A number can still be NaN, and arithmetic still follows JavaScript's rules. A string can still be empty. With strict null checking, null and undefined are distinct values that need to be handled explicitly rather than being assumed away.
Decision rule: Use primitive types deliberately when they make a contract or invariant easier to prove. If an annotation only makes the code look typed while hiding an assumption, make that assumption explicit instead.
2. Literal types
Sometimes “a string” is too broad. A task status might be only "open", "in_progress", or "closed"; accepting an arbitrary string would allow invalid states into the model. A literal such as "open" can therefore be a type, and a union of literals can represent a finite state machine or a constrained option set.
const declarations and as const often preserve literal information that a mutable let variable would widen to string or number. That widening is not inherently wrong: a mutable variable may legitimately receive other strings later. The choice matters when a value is intended to remain one exact state or when an API should reject values outside a known set.
Decision rule: Use literal types deliberately when they make the contract or invariant easier to prove. If they only reduce typing while hiding an assumption, prefer the more explicit design.
3. Inference and contextual typing
TypeScript can infer many obvious local types from their initial values and from how an expression is used. Let it do that when the result is clear. An annotation is more valuable at a public API, an ambiguous boundary, or a point where you intentionally want a broader or narrower contract.
Contextual typing works in the other direction: the receiving API supplies the expected type for an expression. A callback passed to an appropriately typed function can receive parameter types without repeating them at the call site. This keeps local code readable, but the context still comes from a real contract, so inspect the API when the inferred callback type is surprising.
Inference is not a substitute for understanding widening. A local initialized with a literal may be inferred narrowly or broadly depending on whether it is mutable and on the surrounding context. When an inferred type is part of a public promise, expose that promise with an annotation or a named type rather than relying on an implementation detail.
Decision rule: Use inference and contextual typing deliberately when they make the contract or invariant easier to prove. If inference obscures a boundary or an intentional contract, annotate that boundary.
4. any
any is tempting when a migration is blocked or a library has incomplete types. The trade-off is substantial: operations through an any value are largely unchecked, and values derived from it can carry that uncertainty into otherwise well-typed code. The compiler may stop reporting exactly the mistakes you needed it to catch.
Treat any as a migration escape hatch, not as a normal representation of uncertainty. Isolate it at the smallest possible boundary, document why it is present, and replace it with a real type or unknown as soon as the underlying shape is understood. If a value is genuinely untrusted, any is not safer merely because it removes compiler errors.
Decision rule: Use any deliberately when it makes a temporary migration step possible and the escape is isolated. If it only reduces typing while hiding an assumption, prefer an explicit type and runtime validation.
5. unknown
unknown is the type for a value whose runtime kind or structure is not yet known. It can represent any value, but TypeScript requires a narrowing check before you read a property, call it, or use it as a more specific type. That friction is useful: it puts the validation step next to the point where uncertainty is resolved.
Use unknown for untrusted JSON, values from plugins, and caught errors, among other uncertain inputs. Narrow with runtime checks such as typeof, Array.isArray, property checks, or a dedicated validator. The compiler then follows the narrowed branch, while the runtime check protects the application when the input does not match the expected shape.
Decision rule: Use unknown deliberately when a value crosses an uncertain boundary and must be narrowed before use. If it only reduces typing while hiding an assumption, do not replace it with any; make the validation and resulting contract explicit.
6. never and void
never describes a situation in which no value can occur. It is the return type of a function that never completes normally, such as one that always throws, and it can also represent an impossible branch after TypeScript has narrowed all valid cases away. That makes it particularly useful for exhaustiveness checks: if a new state is added to a union and a switch does not handle it, an assignment to never can expose the omission at compile time.
void is used for functions whose callers should not rely on a returned value. It is not the same as undefined in every compatibility context. In particular, do not infer from a void-returning function's annotation that no runtime value could ever be produced; the contract says the caller should ignore the result. Keep the distinction clear when designing callbacks and APIs.
Decision rule: Use never and void deliberately when they make the contract or invariant easier to prove. If they only suppress a confusing error while hiding an assumption, inspect the control flow or return contract instead.
Worked example
Consider a strict TypeScript codebase that uses the compiler to model domain invariants without pretending that static types replace runtime validation. Begin by writing the requirement in one sentence. Then list the input and output contracts and identify which concept owns each failure mode.
The key 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 those layers can make a happy-path demo look shorter, but it pushes edge cases into places where they are harder to reason about and easier to miss.
Here, the parser accepts unknown, checks the runtime value, and only then creates a branded domain value:
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 unknown parameter makes the boundary honest: callers cannot assume that the input is already a string. The runtime check rejects a missing, non-string, or empty value. The assertion creates the branded type only after that check, so code receiving a TaskId can distinguish it from an arbitrary string at compile time. The brand is erased at runtime; it is not a substitute for validation, uniqueness enforcement, or database constraints.
Walk through at least four cases: the normal path; an empty or missing value; a duplicate, retry, or concurrent path where that concern 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 an invalid shape, while duplicate identity may require a repository constraint and a dependency failure may require a structured service error. This is the level of explanation expected in a senior code review or technical interview.
Production perspective
Production correctness is broader than “the code works on my machine.” Consider deploys, retries, partial failure, stale clients, concurrent requests, malformed data, schema changes, and high-cardinality inputs. Prefer explicit contracts, bounded resource usage, structured errors, and measurable behavior. Optimize only after evidence identifies a bottleneck or risk.
When the topic involves an external dependency, define a timeout and cancellation strategy. When it involves persistence, define transaction and consistency expectations. When it involves user-visible state, define loading, empty, error, stale, and success states. When it involves security, assume the client can be modified and network input is untrusted. TypeScript can describe what your application expects after validation; it cannot make an untrusted client follow that expectation.
Guided lab
Compare the behavior of any, unknown, object, never, void, and literal unions in a small parser. Add an exhaustive switch that fails to compile when a new state is introduced. As you work, distinguish what the compiler rejects from what the runtime check rejects; those are different parts of the design.
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.
Edge cases and failure modes
- Primitive types: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Also check runtime values such as
NaNwhen numeric input is involved; anumberannotation alone does not establish the business meaning of the number. - Literal types: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Verify that invalid state strings are rejected at the runtime boundary rather than relying only on a compile-time union.
- Inference and contextual typing: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Inspect inferred types when a mutable value widens or when a callback receives an unexpected contextual contract.
- any: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Trace values originating from
anyto find where unchecked operations contaminate later code. - unknown: Test absence, malformed input, duplicates, ordering or concurrency where applicable, and behavior at the smallest and largest credible sizes. Confirm that every path narrows the value before using it and that the narrowing check matches the runtime data.
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.
When debugging a type-related failure, first reproduce the smallest failing case. Inspect the actual runtime value, not only the editor's type display. Then trace the boundary where the invariant first became false: source or build, the external input, the service layer, the repository, or configuration. If the value came through any, find its origin. If it came through unknown, inspect the narrowing branch. Fix the owning layer instead of adding a downstream assertion that merely silences the symptom.
Interview questions
- What problem does Primitive types solve, and what trade-off or failure mode would make you choose a different approach?
- What problem does Literal types solve, and what trade-off or failure mode would make you choose a different approach?
- What problem does Inference and contextual typing solve, and what trade-off or failure mode would make you choose a different approach?
- What problem does any solve, and what trade-off or failure mode would make you choose a different approach?
- What problem does unknown solve, and what trade-off or failure mode would make you choose a different approach?
Checkpoint
Without notes, explain Primitive Types, Literals, Inference, any, unknown, never, and void to another developer in five minutes. Your explanation must include one invariant, one edge case, one production failure mode, and one alternative design. Then implement a small example without copying the lesson code. Be explicit about which guarantees come from the compiler and which require a runtime check.
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 stricter reliability.
