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

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

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

TypeScript Strict: Type System for Reliable Code — en-US

خلاصهٔ درس

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

خروجی این درس

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

پیش‌نیازها

یک feature می‌تواند در TypeScript بدون خطای کامپایل جدی به نظر برسد، اما هنوز در مرز واقعی داده ناامن باشد. اگر داده از JSON، storage، پیام، API یا هر منبع runtime دیگری آمده باشد، یک interface به‌تنهایی شکل واقعی آن داده را بررسی نمی‌کند. این درس دربارهٔ همین فاصله است: TypeScript چه چیزی را هنگام کامپایل می‌تواند بررسی کند، چه چیزی فقط در زمان اجرا معلوم می‌شود، و برای یک ادعای محدود دقیقاً چه شاهدی لازم است.

در پایان این درس، یک mapper کوچک را تعمیر می‌کنی و Strict Type Boundary Evidence Record v1 می‌سازی. این رکورد باید جداگانه نشان دهد:

  • کدام خطا با تنظیمات strict در مرحلهٔ کامپایل دیده شد؛
  • مرز نامطمئن را کجا به‌صورت unknown نگه داشتی؛
  • کدام runtime guard واقعاً شکل داده را بررسی کرد؛
  • چه mutationی یک اطمینان کاذب ایجاد کرد و چگونه آن را تشخیص دادی؛
  • نسخهٔ compiler و تنظیمات مؤثر چه بوده‌اند؛
  • و حتی پس از repair چه محدودیتی هنوز باقی است.

این درس نمی‌گوید که strict صحت runtime را تضمین می‌کند، type annotation دادهٔ بیرونی را validate می‌کند، هر any یا هر assertion همیشه اشتباه است، یا کامپایل تمیز معادل رفتار درست کسب‌وکار است.

مسئلهٔ واقعی: قرارداد تایپی با دادهٔ واقعی یکی نیست

فرض کن یک fixture محلی JSON داریم:

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

و این مدل را نوشته‌ای:

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

وجود این interface فقط یک قرارداد TypeScript برای کد ماست. اگر JSON واقعی این باشد:

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

خود interface رشتهٔ "two" را در runtime به عدد تبدیل یا رد نمی‌کند. اگر دادهٔ نامطمئن را با assertion مستقیماً به RequestPayload تبدیل کنیم، ممکن است compiler ساکت شود، در حالی که مرز اعتماد هنوز بررسی نشده است.

مدل ذهنی این درس

مسیر قابل دفاع چنین است:

دادهٔ نامطمئن → unknown → بررسی runtime → narrowing → مسیر typed → شاهد کامپایل + شاهد runtime

هر پیکان یک ادعای متفاوت دارد. اگر این مراحل را با هم مخلوط کنی، خیلی زود به جملهٔ خطرناک «TypeScript قبولش کرد، پس داده معتبر است» می‌رسی.

پل ترجمهٔ JavaScript/TypeScript — اگر syntax معمولی وب هنوز خودکار نشده است

این bridge قابل رد کردن است. اگر قطعه‌های کوچک JavaScript/TypeScript را می‌خوانی و می‌توانی const، object/array، function، if، loop، import و throw را توضیح بدهی، مستقیم به بخش «strict یک کلید جادویی برای runtime نیست» برو. هدف این قسمت آموزش کامل JavaScript نیست؛ فقط مفاهیم برنامه‌نویسی قبلی را به vocabulary این درس ترجمه می‌کند تا موضوع اصلی واقعاً strict typing و trust boundary باشد.

نام و value: از binding به const و let

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

const یعنی خود binding دوباره به value دیگری assign نمی‌شود؛ let اجازه reassignment می‌دهد. annotationهای : number و : string به TypeScript کمک می‌کنند دربارهٔ استفادهٔ statically typed از آن نام reasoning کند. این annotationها دادهٔ runtime را validate نمی‌کنند.

Primitiveهای پرکاربرد این درس string، number و boolean هستند:

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

record/list به object/array تبدیل می‌شود

مدلی مثل record/list را در TypeScript معمولاً با object/array می‌بینی:

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

task.completed یک field از object را می‌خواند و tasks[0] یک عضو array را. interface بعداً یک type contract برای shape مورد انتظار می‌دهد؛ object واقعی را در runtime بازرسی نمی‌کند.

function، parameter و return همان نقش قبلی را دارند

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

task parameter است، : boolean بعد از parameter shape/type آن را محدود می‌کند و : boolean بعد از پرانتز نوع return را بیان می‌کند. خود return نتیجه را به caller می‌دهد.

boolean، comparison و if

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

=== comparison می‌سازد؛ if بر اساس boolean branch را انتخاب می‌کند. وقتی بعداً narrowing می‌بینی، TypeScript از همین control flow برای محدودترکردن type ممکن استفاده می‌کند.

loop و map

دو الگوی عادی برای پیمایش array:

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

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

for...of اعضا را یکی‌یکی می‌بیند. map از هر عضو یک value تازه می‌سازد و array نتیجه را برمی‌گرداند. این‌ها runtime behavior هستند؛ type system فقط دربارهٔ استفادهٔ کد از valueها reasoning می‌کند.

import/export یعنی نام‌ها را بین moduleها جابه‌جا کنیم

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

export نام را برای moduleهای دیگر قابل استفاده می‌کند و import آن را وارد module فعلی می‌کند. جزئیات bundler/module-resolution موضوع این bridge نیست.

JSON.parse یک boundary runtime است، نه validator

const decoded = JSON.parse(text);

این command متن JSON را به یک value جاوااسکریپت تبدیل می‌کند. اما اینکه parse موفق شده، فقط syntax JSON را تأیید می‌کند؛ ثابت نمی‌کند value شکل RequestPayload را دارد. برای همین در درس اصلی value نامطمئن را در مرز تحت مالکیت خود به unknown نگه می‌داریم و بعد runtime evidence می‌سازیم.

throw مسیر خطا را متوقف و آشکار می‌کند

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

اگر شرط نامعتبر بودن true شود، throw اجرای عادی آن مسیر را متوقف می‌کند و error را به caller/runtime می‌سپارد. وجود throw به‌تنهایی ثابت نمی‌کند guard درست یا کامل است؛ test باید behavior را مشاهده کند.

micro-readiness check — آیا می‌توانی bridge را ببندی؟

قبل از ادامه، بدون حفظ‌کردن syntax جواب بده:

  1. فرق const و let در binding چیست؟
  2. در const tasks = [...]، array چیست و object داخل آن چیست؟
  3. در یک function کوچک parameter و return را پیدا کن.
  4. توضیح بده if و for...of چه control-flowی می‌سازند.
  5. بگو چرا JSON.parse موفق، اثبات shape/domain نیست و چرا ممکن است پس از آن unknown نگه داریم.
  6. بگو throw چه اتفاقی در مسیر اجرا ایجاد می‌کند.

اگر این شش مورد روشن است، bridge بسته است. از اینجا به بعد موضوع جدید درس TypeScript strictness، unknown، runtime guard و narrowing است؛ نه syntax پایه. اگر یکی مبهم است فقط همان بند را دوباره بخوان، سپس وارد مدل اصلی شو.

1. strict یک کلید جادویی برای runtime نیست

گزینهٔ strict مجموعه‌ای از بررسی‌های سخت‌گیرانه‌تر TypeScript را فعال می‌کند. جزئیات این خانواده به نسخه و پیکربندی compiler وابسته است؛ بنابراین یک evidence record فنی باید نسخهٔ compiler و configuration مؤثر را ثبت کند. این درس از strict: true استفاده می‌کند، اما نتیجه‌ای که می‌گیریم فقط دربارهٔ همان محیط ثبت‌شده و همان feature محدود است.

یکی از مهم‌ترین پیامدهای strict checks در این fixture، دیدن دسترسی ناامن به مقدار nullable است. اگر note اختیاری یا null باشد، نوشتن این کد بدون guard قابل دفاع نیست:

const normalized = request.note.trim();

Compiler می‌تواند در اینجا مشکل تایپی را نشان دهد. اما این هنوز چیزی دربارهٔ JSON نامعتبر پیش از ساخت request ثابت نمی‌کند.

2. مرز اعتماد را با unknown آشکار کن

وقتی شکل runtime یک مقدار را هنوز بررسی نکرده‌ای، در این lesson آن را در مرز خودت به unknown نگه می‌داری:

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

نکتهٔ مهم این نیست که JSON.parse ذاتاً برای تو validation انجام می‌دهد؛ نمی‌دهد. نکته این است که تو اجازه نمی‌دهی نوع مطمئنِ دامنه قبل از شاهد runtime وارد مسیر شود.

در مقابل، این کد فقط سکوت compiler را می‌خرد:

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

as RequestPayload یک runtime validator نیست. assertion به compiler می‌گوید «این ادعا را بپذیر»، اما شیء واقعی را با contract مقایسه نمی‌کند.

3. Narrowing باید بر یک مشاهدهٔ runtime تکیه کند

برای این feature، یک guard کوچک کافی است:

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")
  );
}

وقتی guard true برمی‌گرداند، TypeScript می‌تواند در branch بعدی نوع دقیق‌تری برای value در نظر بگیرد. این narrowing است. شاهد آن دو لایه دارد:

  1. runtime check واقعاً روی value اجرا شده است؛
  2. compiler بعد از آن check اجازهٔ استفادهٔ typed را می‌دهد.

اگر فقط لایهٔ دوم را با assertion تقلید کنی، بخش اول از بین رفته است.

Compile-time و runtime را در یک جدول قاطی نکن

پرسششاهد اصلیچیزی که ثابت نمی‌کند
آیا strict یک nullable access ناامن را می‌گیرد؟خروجی compiler در همان نسخه/configشکل واقعی دادهٔ بیرونی معتبر است
آیا ورودی شکل مورد انتظار را دارد؟runtime guard روی value واقعیهمهٔ قواعد کسب‌وکار درست‌اند
آیا بعد از guard مسیر typed قابل استفاده است؟narrowing + compile evidenceguard تمام semantic constraintهای محصول را پوشش می‌دهد
آیا malformed input به‌درستی رد می‌شود؟behavior test با fixture نامعتبرتمام inputهای ممکن پوشش داده شده‌اند
آیا repair در زمینهٔ دیگری هم قابل دفاع است؟changed-context implementation + testsاین یک بار انتقال، mastery کلی را ثابت می‌کند

مثال کارشده: mapper شکننده

نسخهٔ اولیه چنین است:

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

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

دو مشکل متفاوت داریم:

  • candidate.note.trim() با strictNullChecks یک مشکل compile-time دارد، چون note می‌تواند absent یا null باشد.
  • assertion باعث شده userId و retries بدون هیچ runtime evidence وارد مسیر trusted شوند. حتی اگر nullable branch را درست کنی، assertion هنوز validation نیست.

Repair باید این دو failure mode را جدا حل کند. نسخهٔ قابل دفاع ابتدا guard را اجرا می‌کند، سپس nullable field را صریح مدیریت می‌کند:

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()
  };
}

این repair هنوز ادعا نمی‌کند retries حتماً مثبت است یا userId قواعد کسب‌وکار خاصی را رعایت می‌کند؛ چنین constraintهایی در contract این lesson تعریف نشده‌اند.

سه mutation که باید بتوانی توضیح بدهی

Mutation A — guard را با assertion عوض کن

پیش‌بینی: ممکن است compile سبز بماند، اما malformed input دیگر در trust boundary رد نمی‌شود و behavior test باید regression را آشکار کند.

Mutation B — nullable branch را ضعیف کن

پیش‌بینی: اگر دوباره مستقیماً input.note.trim() را بنویسی، strict compile باید دسترسی ناامن را بگیرد.

Mutation C — فقط مرز تحت مالکیت خودت را از unknown به any صریح تبدیل کن

پیش‌بینی: ممکن است tsc همچنان pass شود و اگر runtime guard را نگه داشته باشی، malformed-input testها هم همچنان سبز بمانند. همین نکته هدف probe است: دستور node tests/check_boundary.cjs src/mapper.ts را اجرا کن. static boundary check قابل‌مشاهده برای learner باید signature دارای any صریح را رد کند. سه مشاهده را جدا ثبت کن—نتیجهٔ compile، نتیجهٔ static boundary check و نتیجهٔ runtime—و failureای را که واقعاً رخ نداده جعل نکن.

این تفاوت مهم است: قرار نیست همهٔ mutationها یک نوع failure بسازند. اگر oracle تو فقط «compiler باید fail شود» باشد، compile/runtime split را هنوز نفهمیده‌ای.

آزمایشگاه: Strict Type Boundary Evidence Record v1

در پوشهٔ آزمایشگاه یک starter عمداً ناقص وجود دارد. ترتیب کار:

  1. نسخهٔ TypeScript compiler را ثبت کن (tsc --version).
  2. tsconfig.json را بررسی کن و تأیید کن strict: true فعال است.
  3. baseline compile را اجرا کن و failure مربوط به nullable assumption را ثبت کن.
  4. قبل از تغییر کد، نتیجهٔ سه mutation بالا را پیش‌بینی کن.
  5. assertion را با runtime guard جایگزین کن و nullable field را صریح مدیریت کن.
  6. compile را دوباره اجرا کن.
  7. behavior tests را روی fixture معتبر و malformed اجرا کن.
  8. mutationها را اجرا کن و برای هرکدام بنویس: prediction → observation → diagnosis → repair.
  9. یک residual limitation ثبت کن؛ مثلاً guard این lesson فقط shape/typeهای انتخاب‌شده را بررسی می‌کند و قواعد دامنه‌ای دیگری را اثبات نمی‌کند.

Evidence Record باید این فیلدها را داشته باشد

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

سه نوع پشتیبانی، یک evidence bar

اگر Foundation هستی، vocabulary و baseline probe را مرحله‌به‌مرحله دنبال کن. اگر Repair هستی، از failure واقعی شروع کن و فقط همان misconception را بازسازی کن. اگر Experienced هستی، می‌توانی syntax walkthrough را فشرده کنی. هیچ‌کدام از این مسیرهای پشتیبانی اجازه نمی‌دهند mutation، runtime evidence یا residual limitation حذف شود.

تمرین انتقال — زمینه عوض می‌شود، قرارداد نه

اکنون یک fixture متفاوت را در نظر بگیر: تنظیمات صفحه از JSON محلی decode می‌شود و باید به این مدل برسد:

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

این shape عمداً با RequestPayload فرق دارد: باید یک literal union، یک رکورد nested و تک‌تک اعضای آرایه را validate کنی، نه اینکه فقط سه فیلد flat را rename کنی. بدون کپی‌کردن guard قبلی، یک boundary design مستقل بساز. خروجی انتقال باید شامل این موارد باشد:

  • trust boundary کجاست و چرا مقدار را ابتدا نامطمئن می‌دانی؛
  • runtime evidence چه چیزی را بررسی می‌کند؛
  • narrowing کجا رخ می‌دهد؛
  • compile-time evidence چه چیزی را می‌گیرد؛
  • یک malformed input و نتیجهٔ behavior test؛
  • یک claim boundary؛
  • یک update condition: چه تغییر در schema/config/compiler باعث می‌شود evidence را دوباره بررسی کنی.

موفقیت در این انتقال شاهد مفیدی است که روش را فقط حفظ نکرده‌ای، اما به‌تنهایی mastery کلی TypeScript را ثابت نمی‌کند.

مرز ادعا

پس از این درس می‌توانی دربارهٔ یک feature کوچک بگویی:

من trust boundary را صریح کرده‌ام، compile-time و runtime evidence را جدا ثبت کرده‌ام، حداقل یک failure mutation را دیده و repair کرده‌ام، و محدودیت باقی‌مانده را می‌دانم.

اما هنوز نمی‌توانی فقط با تکیه بر این درس بگویی:

  • این برنامه در runtime به‌طور کلی صحیح است؛
  • تمام inputهای بیرونی validate شده‌اند؛
  • همهٔ استفاده‌های any یا assertion اشتباه‌اند؛
  • یا compile سبز، behavior درست محصول را ثابت می‌کند.

منابع و تازگی

این درس سه claim فنی را به مستندات رسمی TypeScript محدود می‌کند: خانوادهٔ بررسی‌های strict، جدایی TypeScript از رفتار runtime جاوااسکریپت و حذف type information در خروجی، و narrowing مبتنی بر runtime/control-flow evidence. چون رفتار strict می‌تواند با نسخه‌های TypeScript سخت‌گیرانه‌تر شود، compiler version بخشی از evidence است و پس از تغییر نسخه/config باید evidence مربوط دوباره اجرا شود.

ارزیابی

پاسخ‌نامه‌ی این درس در بسته‌ی آموزش وجود ندارد؛ خودت جواب بده و صادقانه علامت بزن. «نیاز به مرور» درس را به صف تکرار می‌فرستد.

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 ✗
ملاک‌های ارزیابی (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.

منابع و تازگی

·
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)

تصاویر درس

اسکریپت ویدیوی اختیاری

نمای آغازین: تصویر ۱ — مسیر «داده نامطمئن → unknown → runtime guard → narrowing → typed path → compile + runtime evidence».

یک interface به دادهٔ runtime نگاه نمی‌کند. این ویدیو فقط یک سؤال را دنبال می‌کند: کجا حق داریم یک value نامطمئن را به‌عنوان domain type استفاده کنیم؟

ابتدا starter را ببین: assertion باعث می‌شود compiler دربارهٔ shape داده سؤال نکند، و nullable field هم یک failure جدا دارد. سپس guard را اضافه می‌کنیم. وقتی check واقعی انجام شد، narrowing معنا پیدا می‌کند.

در mutation اول، guard را با assertion عوض می‌کنیم. compile ممکن است سبز بماند، اما malformed input باید regression را نشان دهد. در mutation دوم، nullable branch را خراب می‌کنیم و compiler باید مشکل static را بگیرد. در mutation سوم، boundary را any می‌کنیم تا ببینیم چرا «compile سبز» به‌تنهایی evidence کافی نیست.

جمع‌بندی: یک evidence record خوب نسخه compiler، compile observation، runtime observation، mutation، repair و محدودیت باقی‌مانده را جدا ثبت می‌کند.

فایل‌های آزمایشگاه

این fixture solution آماده ندارد و عمداً rename هم‌شکلِ RequestPayload نیست: theme یک literal union است، layout یک رکورد nested دارد و tags آرایه‌ای از stringهاست. mapper.ts را مستقل پیاده کن و بعد bash run_checks.sh را اجرا کن.

Evidence انتقال باید این قرارداد را نگه دارد:

  • trust boundary و دلیل نامطمئن‌بودن مقدار اولیه؛
  • runtime check برای literal union، فیلدهای nested layout و تک‌تک اعضای tags؛
  • narrowing پیش از استفادهٔ typed؛
  • compile-time evidence؛
  • رفتار malformed input؛
  • یک claim boundary؛
  • یک update condition که مشخص کند چه تغییر schema/config/compiler باعث می‌شود evidence را دوباره اجرا کنی.

Guard مربوط به RequestPayload را خط‌به‌خط کپی نکن؛ روش را برای shape و control-flow متفاوت بازسازی کن.

رسانه و اسلایدهای درس

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

فایل‌های آموزشی (دانلود)

XLSXStrict Type Boundary Evidence Record v1 v1.0.0.xlsxفایل‌های آموزشی (دانلود)
DOCXStrict Type Boundary Evidence Record v1 WORKSHEET v1.0.0.docxفایل‌های آموزشی (دانلود)
PDFStrict Type Boundary Evidence Record v1 WORKSHEET v1.0.0.pdfفایل‌های آموزشی (دانلود)