وب مدرن، رابط کاربری و تجربه‌ی وب · فصل ۳ — Type System، Runtime، Async و Build · 41

TypeScript Strict: Type System for Reliable Code

Use strict TypeScript as a compile-time constraint system, narrow unknown values at trust boundaries, and prove separately what the type checker catches and what still requires runtime validation and tests. می‌تواند «TypeScript Strict: Type System برای کد قابل اعتماد» را در یک feature محدود طراحی/پیاده‌سازی کند و correctness، accessibility یا failure behavior مرتبط را اثبات کند.

TypeScript Strict: Type System برای کد قابل اعتماد — fa-IR

Lesson summary

Use strict TypeScript as a compile-time constraint system, narrow unknown values at trust boundaries, and prove separately what the type checker catches and what still requires runtime validation and tests.

می‌تواند «TypeScript Strict: Type System برای کد قابل اعتماد» را در یک feature محدود طراحی/پیاده‌سازی کند و correctness، accessibility یا failure behavior مرتبط را اثبات کند.

This lesson produces

می‌تواند «TypeScript Strict: Type System برای کد قابل اعتماد» را در یک feature محدود طراحی/پیاده‌سازی کند و correctness، accessibility یا failure behavior مرتبط را اثبات کند.

Who it is for

Foundation

Worked baseline, explicit evidence table, safe local fixture and one guided failure probe.

Repair

Reduce to one failing contract, observe evidence, repair, then restore full case.

Experienced

Skip syntax walkthrough; require independent implementation, mutation evidence and residual-risk statement.

Prerequisites

A TypeScript feature can look reassuring at compile time and still have an undefined runtime trust boundary. If a value came from JSON, storage, a message, an API, or another runtime source, an interface does not inspect that value for you. This lesson focuses on that gap: what TypeScript can check before execution, what only runtime evidence can establish, and what evidence supports a bounded engineering claim.

By the end, you will repair a small request mapper and produce a Strict Type Boundary Evidence Record v1. The record must keep several kinds of evidence separate:

  • a failure caught by strict compile-time checks;
  • the point where uncertain input is deliberately held as unknown;
  • the runtime guard that actually inspects the value;
  • a deliberate mutation that creates false confidence and how you detect it;
  • the compiler version and effective configuration;
  • and one residual limitation that remains after the repair.

This lesson does not claim that strict guarantees runtime correctness, that type annotations validate external input, that every any or every assertion is universally wrong, or that a clean compile proves business behavior.

The real problem: a type contract is not runtime evidence

Suppose a local JSON fixture contains:

{
  "userId": "u-104",
  "retries": 2,
  "note": "retry after cache reset"
}

You may model the intended domain shape like this:

export interface RequestPayload {
  userId: string;
  retries: number;
  note?: string | null;
}

That interface constrains TypeScript code. It does not examine the object that actually arrives at runtime. If the decoded JSON is instead:

{
  "userId": "u-104",
  "retries": "two"
}

the interface does not convert or reject the string for you. If you assert that uncertain data is already RequestPayload, the compiler may become quiet while the trust boundary remains unverified.

The mental model

Use this chain:

untrusted value → unknown → runtime check → narrowing → typed domain path → compile evidence + runtime evidence

Each arrow supports a different claim. Collapse them together and you end up with the unsafe shortcut: “TypeScript accepted it, so the input must be valid.”

JavaScript/TypeScript translation bridge — use it if ordinary web syntax is not yet automatic

This bridge is skippable. If you can read tiny JavaScript/TypeScript snippets and explain const, objects/arrays, functions, if, loops, imports, and throw, go directly to “The TypeScript strict option is not a runtime validator.” This is not a complete JavaScript course. It translates prior programming concepts into this lesson's vocabulary so the actual topic remains strict typing and the trust boundary.

Names and values: bindings become const and let

const retries: number = 2;
let status: string = "queued";
status = "running";

const prevents rebinding that name to another value; let permits reassignment. The : number and : string annotations help TypeScript reason about statically typed uses of those names. They do not validate runtime data.

Common primitives in this lesson are string, number, and boolean:

const userId: string = "u-104";
const retries: number = 2;
const completed: boolean = false;

Records/lists become objects/arrays

A record/list model commonly appears as objects and arrays:

const task = { id: 1, title: "A", completed: false };
const tasks = [task, { id: 2, title: "B", completed: true }];

task.completed reads a field from an object; tasks[0] reads an array item. An interface later supplies a type contract for an intended shape. It does not inspect the runtime object.

Function, parameter, and return keep the same roles

function isOpen(task: { completed: boolean }): boolean {
  return task.completed === false;
}

task is a parameter, its annotation constrains the expected type, and the annotation after the parentheses states the return type. return sends the result to the caller.

Boolean comparisons and if

if (task.completed === false) {
  // this branch runs only when the comparison is true
}

=== creates a comparison; if selects a branch from a boolean result. Later, TypeScript narrowing uses this same control flow to refine possible types.

Loop and map

Two ordinary array traversal patterns are:

for (const task of tasks) {
  // inspect each task
}

const ids = tasks.map((task) => task.id);

for...of visits members one at a time. map creates a new value from each member and returns an array of results. These are runtime behaviors; the type system reasons about how code uses the values.

Import/export moves names between modules

export function mapRequest(input: unknown) { /* ... */ }
import { mapRequest } from "./mapper";

export makes a name available to other modules; import brings it into the current module. Bundler and module-resolution details are outside this bridge.

JSON.parse is a runtime boundary, not a validator

const decoded = JSON.parse(text);

This converts JSON text into a JavaScript runtime value. A successful parse establishes that the text was syntactically valid JSON; it does not establish that the resulting value has the RequestPayload shape. That is why the main lesson keeps uncertain data as unknown at an owned boundary and then gathers runtime evidence.

throw stops and exposes an error path

if (!isRequestPayload(input)) {
  throw new TypeError("Invalid request payload");
}

When the invalidity condition is true, throw stops the normal path and reports an error to the caller/runtime. The presence of throw does not prove the guard is correct or complete; tests still need to observe behavior.

Micro-readiness check — can you close the bridge?

Without memorizing syntax, answer these:

  1. What is the binding difference between const and let?
  2. In const tasks = [...], what is the array and what are the objects inside it?
  3. Identify the parameter and return value in a tiny function.
  4. Explain the control flow created by if and for...of.
  5. Explain why successful JSON.parse is not proof of a domain shape and why the result may remain unknown.
  6. Explain what throw does to the execution path.

If all six are clear, the bridge is closed. From here, the new topic is TypeScript strictness, unknown, runtime guards, and narrowing, not basic syntax. If one item is unclear, reread only that item, then continue into the main model.

1. The TypeScript strict option is not a runtime validator

The strict option enables a family of stronger type-checking behaviors. The exact family can evolve across TypeScript versions and configurations, so a defensible technical evidence record includes the compiler version and the effective configuration. This lesson uses strict: true, but its observations are bounded to the recorded toolchain and this feature.

One useful strict check in this fixture is nullable access. With note?: string | null, this is not defensible without a branch that handles the absent/null case:

const normalized = request.note.trim();

The compiler can expose that static problem. It still has not proved that decoded JSON matched RequestPayload before request existed.

2. Make the trust boundary explicit with unknown

When the runtime shape has not been established, keep the value uncertain at the boundary you own:

const decoded: unknown = JSON.parse(text);

The important move is not that JSON.parse somehow validates data—it does not. The move is that your code refuses to let a trusted domain type appear before runtime evidence exists.

Compare that with:

const decoded = JSON.parse(text) as RequestPayload;

as RequestPayload is not a runtime validator. A type assertion tells the compiler to accept a type claim; it does not compare the actual object with the interface.

3. Narrow from observed runtime evidence

A small guard is enough for this feature:

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null;
}

function isRequestPayload(value: unknown): value is RequestPayload {
  if (!isRecord(value)) return false;

  const note = value.note;
  return (
    typeof value.userId === "string" &&
    typeof value.retries === "number" &&
    (note === undefined || note === null || typeof note === "string")
  );
}

When the guard returns true, TypeScript can treat value as a narrower type in that control-flow branch. The evidence has two layers:

  1. a runtime check actually inspected the value;
  2. the compiler can then use the narrower type.

A type assertion can imitate the second layer syntactically while deleting the first.

Do not collapse compile-time and runtime evidence

QuestionPrimary evidenceWhat it does not prove
Did strict checks catch an unsafe nullable access?Compiler output for the recorded version/configExternal data has a valid runtime shape
Does the input have the expected shape?Runtime guard over the actual valueAll business rules are correct
Is the typed path usable after the check?Narrowing plus compile evidenceThe guard covers every semantic requirement
Is malformed input rejected?Behavior test with invalid fixtureEvery possible input has been tested
Does the method transfer to another shape?Independent changed-context implementation and testsOne transfer proves broad mastery

Worked example: the fragile mapper

The starter looks like this:

export function mapRequest(input: unknown): RequestPayload {
  const candidate = input as RequestPayload;

  return {
    userId: candidate.userId.trim(),
    retries: candidate.retries,
    note: candidate.note.trim()
  };
}

There are two different failure modes:

  • candidate.note.trim() conflicts with the nullable/optional contract under strict null checking.
  • the assertion lets userId and retries enter the trusted path with no runtime evidence. Fixing the null branch alone does not turn the assertion into validation.

A defensible repair handles those problems separately:

export function mapRequest(input: unknown): RequestPayload {
  if (!isRequestPayload(input)) {
    throw new TypeError("Invalid request payload");
  }

  return {
    userId: input.userId.trim(),
    retries: input.retries,
    note: input.note == null ? null : input.note.trim()
  };
}

This still does not claim that retries must be positive or that userId satisfies some product-specific format; those constraints are outside this lesson's contract.

Three mutations you must be able to explain

Mutation A — replace the guard with an assertion

Prediction: the code may still compile, but malformed data is no longer rejected at the trust boundary. Runtime behavior tests should expose the regression.

Mutation B — weaken the nullable branch

Prediction: direct use of input.note.trim() should be rejected by strict compile-time checking because note can be absent or null.

Mutation C — change only the owned boundary from unknown to explicit any

Prediction: tsc may still pass, and the malformed-input runtime tests may also stay green if the runtime guard is retained. That is the point of this probe: run node tests/check_boundary.cjs src/mapper.ts. The learner-visible static boundary check must reject the explicit-any signature. Record the three observations separately—compile result, static-boundary result, and runtime result—rather than inventing a runtime failure that did not occur.

That distinction matters: different mutations need different oracles. A lab that treats every failure as “tsc should fail” has not actually separated compile-time from runtime evidence.

Lab: Strict Type Boundary Evidence Record v1

The lab contains an intentionally incomplete starter. Work in this order:

  1. record the TypeScript compiler version (tsc --version);
  2. inspect tsconfig.json and confirm strict: true;
  3. run the baseline compile and record the nullable-assumption failure;
  4. predict all three mutations before changing the code;
  5. replace the unsafe assertion with runtime shape evidence and handle the nullable field explicitly;
  6. compile again;
  7. run behavior tests against valid and malformed fixtures;
  8. run the mutations and record prediction → observation → diagnosis → repair for each;
  9. write one residual limitation—for example, the guard proves only the selected shape/types, not every domain rule.

Your evidence record must include

  • compiler version and strict configuration;
  • owned claim;
  • compile-time observation;
  • runtime observation;
  • mutation result;
  • repair result;
  • changed-context result;
  • residual limitation.

Three support profiles, one evidence bar

Foundation support may explain the minimum vocabulary and guide the baseline probe. Repair support may begin from the specific failed contract and rerun that probe after the fix. Experienced learners may compress syntax review. None of those support profiles removes the mutation evidence, runtime evidence, compiler version, or residual-limit requirement.

Transfer — new shape, same reasoning

Now imagine a different local JSON fixture that should become:

interface PageSettings {
  theme: "light" | "dark";
  layout: { columns: number; compact: boolean };
  tags: string[];
}

This shape is intentionally different from RequestPayload: validate a literal union, a nested record, and every array element rather than mechanically renaming three flat fields. Without copying the previous guard line by line, design the boundary independently. Your transfer result must state:

  • where the trust boundary is and why the value begins uncertain;
  • what runtime evidence inspects;
  • where narrowing occurs;
  • what compile-time evidence catches;
  • one malformed input and its behavior-test result;
  • one claim boundary;
  • one update condition: what schema/config/compiler change would make you re-check the evidence.

Successful transfer is useful evidence that you can reconstruct the method in a changed context. One transfer is not, by itself, proof of broad TypeScript mastery.

Claim boundary

After this lesson, you can defend a bounded statement such as:

I made the trust boundary explicit, recorded compile-time and runtime evidence separately, observed and repaired at least one deliberate failure mutation, and can state the remaining limitation.

You still cannot infer from this lesson alone that:

  • the program is generally correct at runtime;
  • all external inputs are validated;
  • every use of any or every assertion is wrong;
  • or a clean compile proves correct product behavior.

Sources and freshness

This lesson keeps its technical claims bounded to official TypeScript documentation about the strict family, TypeScript's compile-time relationship to JavaScript runtime behavior, and narrowing through runtime/control-flow evidence. Because stricter checking under strict can evolve across TypeScript versions, the compiler version is part of the evidence, and a compiler/configuration change is a reason to re-run the relevant checks.

Assessment

There is no answer key in this lesson pack; answer it yourself and mark honestly. "Needs review" sends the lesson to your review queue.

Q1 retrieval

lesson ✗AI ✗notes ✗

Q2 application

lesson ✗AI ✗notes ✗

Q3 diagnosis

lesson ✗AI ✗notes ✗

Q4 decision

lesson ✗AI ✗notes ✗

Q5 transfer

lesson ✗AI ✗notes ✗
Assessment rubric
DimensionCriticalSufficient evidenceBlocking failure
Contract correctnessYesThe repair preserves the bounded RequestPayload contract and keeps uncertain input untrusted until runtime evidence exists.Treats an assertion/interface as runtime validation or silently expands the lesson into unowned business rules.
Compile-time evidenceYesRecords compiler version/config and shows which static/nullability problem the compiler catches.Claims a compile pass proves runtime validity.
Runtime evidenceYesRuns valid and malformed fixtures against an actual runtime guard and records observed behavior.No malformed-input check or fabricated runtime evidence.
Mutation diagnosisYesUses a distinct oracle for each mutation; for unknown→any it records compile result, the learner-visible static boundary check, and runtime result separately, without requiring a fabricated runtime failure.Requires every mutation to fail compilation, omits the explicit-any static boundary oracle, or cannot distinguish compile/runtime/static-boundary observations.
Scope disciplineYesStates at least one residual limitation and avoids universal anti-any/anti-assertion claims.Overclaims whole-program correctness or runtime validation.
Transfer independenceYesChanged PageSettings task reconstructs the method for a materially different shape using a literal union, nested object, and string array; includes trust boundary, runtime evidence, narrowing, compile evidence, malformed behavior, claim boundary and update condition without decisive scaffold.Transfer omits claim boundary/update condition or can be solved as a mechanical property rename of the first guard.

AI support boundary

AI may suggest candidate checks after the learner states the claim and evidence need. It may not fabricate compiler/runtime observations, provide the decisive changed-context implementation, or replace the learner's residual-limit judgment.

References & freshness

·
The strict option enables a family of stricter checking behavior and can become stricter across TypeScript versions.
·
TypeScript preserves JavaScript runtime behavior and erases type information when producing JavaScript.
·
Runtime checks/control-flow evidence can narrow broader types.

Case studies

Lesson visuals

Optional video script

Opening visual: Visual 1 — “untrusted value → unknown → runtime guard → narrowing → typed path → compile + runtime evidence.”

An interface does not inspect a runtime object. The video follows one question: when has an uncertain value earned the right to enter the trusted domain path?

Start with the fragile mapper. The assertion suppresses shape questions, while nullable access creates a separate static failure. Add the runtime guard; only after observed checks does narrowing support the typed branch.

Mutation one replaces the guard with an assertion: compilation may stay green, so malformed-input behavior must expose the regression. Mutation two weakens the nullable branch and should be caught statically. Mutation three changes the boundary to any, showing why “it compiled” is not enough evidence.

Close with the evidence record: compiler version, compile observation, runtime observation, mutation, repair, changed-context result, and a residual limitation.

Lab files

This fixture has no pre-solved mapper. It is deliberately not an isomorphic rename of RequestPayload: it contains a theme literal union, a nested layout record, and a tags string array. Implement mapper.ts independently, then run bash run_checks.sh.

Your transfer evidence must preserve the lesson's success contract:

  • trust boundary and why the value starts uncertain;
  • runtime checks for the literal union, nested layout fields, and every tags element;
  • narrowing before typed use;
  • compile-time evidence;
  • malformed-input behavior;
  • one claim boundary;
  • one update condition stating what schema/config/compiler change would make you re-run the evidence.

Do not copy the RequestPayload guard line by line; reconstruct the method for the different control-flow and shape.

Lesson media & slides

MAKNOW_WEB-001_DECISION_EVIDENCE_TABLE_v1.0.0.svg
DECISION EVIDENCE TABLE v1.0.0.svg
MAKNOW_WEB-001_FAILURE_REPAIR_v1.0.0.svg
FAILURE REPAIR v1.0.0.svg
MAKNOW_WEB-001_PRIMARY_MODEL_v1.0.0.svg
PRIMARY MODEL v1.0.0.svg
MAKNOW_WEB-001_WORKED_EXAMPLE_v1.0.0.svg
WORKED EXAMPLE v1.0.0.svg

Teaching files (download)

XLSXStrict Type Boundary Evidence Record v1 v1.0.0.xlsxTeaching files (download)
DOCXStrict Type Boundary Evidence Record v1 WORKSHEET v1.0.0.docxTeaching files (download)
PDFStrict Type Boundary Evidence Record v1 WORKSHEET v1.0.0.pdfTeaching files (download)