স্কিমা ও TypeScript টাইপের দুইমুখী সিঙ্ক
সংক্ষেপে: টাইপ ও স্কিমা প্রতিটি একটি অংশ ঢাকে: একটি কম্পাইল সময়ে, একটি রানটাইমে। প্রশ্ন কোনটি বাছবেন তা নয়, বরং দুইটিকে কীভাবে স্বয়ংক্রিয়ভাবে সিঙ্কে রাখবেন।

মূল কথা: আগে একক সত্যের উৎস বাছুন — ব্যাকএন্ড চুক্তির মালিক হলে schema-first, ফ্রন্টএন্ডের হলে zod-শৈলীর types-first — তারপর অন্য কপি জেনারেট করে CI-তে ডিফ করুন; জেনারেটর যা প্রকাশ করতে পারে না, যেমন branded type, কেবল হাতে লিখুন।
দুটি সংজ্ঞা হাতে লিখলে সেগুলো সরে যাবে। সরে যাওয়ার দাম হলো "কম্পাইল হয়, তারপর রানটাইমে ৫০০ ফেরত দেয়"।
১. এক কপি রাখলে কেন কাজ হয় না
| সক্ষমতা | TypeScript টাইপ | JSON Schema |
|---|---|---|
| কম্পাইল-সময় চেক | ✓ | ✗ |
| রানটাইম ভ্যালিডেশন | ✗ (টাইপ মুছে যায়) | ✓ |
| ক্রস-ভাষা ব্যবহার | ✗ | ✓ (Java/Go/Python সবই পড়তে পারে) |
| IDE কমপ্লিশন ও রিফ্যাক্টরিং | ✓ | মাঝারি |
| ডকুমেন্টেশন জেনারেশন | মাঝারি | ✓ (OpenAPI ইকোসিস্টেম) |
API-র ইনপুট নেটওয়ার্ক থেকে আসে আর সবসময় unknown, তাই রানটাইম ভ্যালিডেশন বাদ দেওয়া যায় না; আর ব্যবসায়িক কোড লেখার সময় তবু টাইপ কমপ্লিশন দরকার।
বুদ্ধিমান পথ হলো একটি সত্যের উৎস বাছাই করে অন্যটি জেনারেট করা।
২. পথ A: schema first
উপযুক্ত: ব্যাকএন্ড-চালিত কাজ, ক্রস-ভাষা ব্যবহার, পাবলিক চুক্তি।
pnpm add -D json-schema-to-typescript
import { compileFromFile } from 'json-schema-to-typescript';
import { writeFileSync } from 'node:fs';
/**
* Generate TypeScript type definitions from a JSON Schema.
* @param schemaPath Path to the schema file
* @param outputPath Path to the output file
* @returns A Promise that resolves when generation is finished
*/
async function generateTypes(schemaPath: string, outputPath: string): Promise<void> {
const typeText = await compileFromFile(schemaPath, {
bannerComment: '/* Auto-generated from the schema. Do not edit by hand. */',
additionalProperties: false,
style: { singleQuote: true },
});
writeFileSync(outputPath, typeText, 'utf8');
}
আউটপুটে কয়েকটি প্রথা লক্ষ্য করার যোগ্য:
required: ["id"]→id: string;required-এ না থাকা কি →id?: string;"type": ["string", "null"]→string | null;enum→ লিটেরাল টাইপের union,string-এর চেয়ে অনেক নির্দিষ্ট;additionalProperties: falseঠিক করে ইনডেক্স সিগনেচার জেনারেট হবে কি না।
একটি zero-build বিকল্পও আছে, json-schema-to-ts, যা সরাসরি টাইপ স্তরে অনুমান করে:
import type { FromSchema } from 'json-schema-to-ts';
const userSchema = {
type: 'object',
properties: { id: { type: 'string' }, age: { type: 'integer' } },
required: ['id'],
additionalProperties: false,
} as const;
type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }
সুবিধা হলো জেনারেশন ধাপ নেই আর টাইপ সবসময় স্কিমার সঙ্গে সিঙ্কে; অসুবিধা হলো স্কিমা as const হিসেবে লিখতে হয়, আর জটিল $ref পরিস্থিতি ধীরে কম্পাইল হয়।
৩. পথ B: types first
উপযুক্ত: zod / valibot-এর মতো লাইব্রেরিসহ ফ্রন্টএন্ড-চালিত কাজ, যেখানে "স্কিমাই টাইপ"।
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
export const userSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(64),
age: z.number().int().min(0).max(150).optional(),
tags: z.array(z.string()).default([]),
});
export type User = z.infer<typeof userSchema>;
/** The contract published to other languages */
export const userJsonSchema = zodToJsonSchema(userSchema, {
target: 'jsonSchema2020-12',
$refStrategy: 'none',
});
এখানে zod দুই ভূমিকা পালন করে — "টাইপ সংজ্ঞা" ও "রানটাইম ভ্যালিডেশন" — আর JSON Schema নিছক এক্সপোর্ট আর্টিফ্যাক্ট। সামগ্রিকভাবে এটি সবচেয়ে কম পরিশ্রম, তবে দুটি বিষয় মনে রাখুন:
default,transformওrefine-এz.inferও জেনারেট করা JSON Schema ঠিক মেলে না;- জেনারেট করা স্কিমায়
$refওdefinitionsথাকতে পারে, তাই প্রকাশের আগে দেখুন তা লক্ষ্য স্পেসিফিকেশনের সঙ্গে মেলে কি না।
৪. উল্টো দিকও যাচাই দরকার
যে পথই বাছুন, একটি CI চেক যোগ করুন: জেনারেট করা আর্টিফ্যাক্ট রিপোজিটরির ফাইলের সঙ্গে মেলাতে হবে।
# Generate into a temp directory and compare; fail CI on any difference
node scripts/generate-types.mjs --out .tmp/types.d.ts
diff -u src/types/api.d.ts .tmp/types.d.ts
এই ধাপ ছাড়া তিন মাস পরে কেউ জানে না api.d.ts হালনাগাদ কি না।
৫. চারটি উচ্চ-কম্পাঙ্ক ফাঁদ
৫.১ optional ও nullable এক জিনিস নয়
type A = { name?: string }; // the key may be absent
type B = { name: string | null }; // the key must exist, but the value may be null
স্কিমার ভাষায়:
{ "properties": { "name": { "type": "string" } } } // optional
{ "required": ["name"], "properties": { "name": { "type": ["string", "null"] } } } // nullable
জেনারেটর দুই ক্ষেত্র সম্পূর্ণ আলাদাভাবে সামলায়। চুক্তিতে কোনটি বোঝানো হচ্ছে বলা থাকতে হবে; নাহলে ফ্রন্টএন্ডের data.name?.length null-এ ক্র্যাশ করবে।
৫.২ বিবর্তনশীল এনাম
type Status = 'pending' | 'paid' | 'refunded';
ব্যাকএন্ড 'cancelled' যোগ করে আর ফ্রন্টএন্ড টাইপ হালনাগাদ না হলে কোড default শাখায় পড়ে।
এনাম-সদৃশ ফিল্ড অজানা মান স্পষ্টভাবে সামলানো উচিত:
function normalizeStatus(raw: string): Status | 'unknown' {
return raw === 'pending' || raw === 'paid' || raw === 'refunded' ? raw : 'unknown';
}
৫.৩ তারিখ ও টাকা সোজা string ব্যবহার করতে পারে না
স্কিমায় format: "date-time" নিছক টীকা; TypeScript-এ তা কেবল string।
বলিষ্ঠ নিশ্চয়তা দরকার হলে "branded type" ব্যবহার করুন:
type IsoDateTime = string & { readonly __brand: 'IsoDateTime' };
/**
* Mark a string as an ISO timestamp.
* @param value A string whose format has already been verified
* @returns The branded timestamp string
*/
function asIsoDateTime(value: string): IsoDateTime {
return value as IsoDateTime;
}
৫.৪ বড় ইন্টিজার
type: "integer" মান 2^53 ছাড়ালে number-এর সমান নয়। চুক্তিতে লিখুন:
{ "type": "string", "pattern": "^[0-9]+$", "description": "Order number; exceeds 2^53, must be a string" }
৬. একটি প্রস্তাবিত রোলআউট প্রক্রিয়া
- চুক্তি সংজ্ঞায়িত করুন: রিকোয়েস্ট/রেসপন্স কাঠামো স্কিমা (বা zod) হিসেবে লিখে শুরু করুন;
- টাইপ জেনারেট করুন: CI-তে
.d.tsজেনারেট করে রিপোজিটরিতে কমিট করুন, যাতে পার্থক্য সহজে রিভিউ হয়; - ভ্যালিডেটর জেনারেট করুন: একই স্কিমা Ajv ভ্যালিডেশন ফাংশনে কম্পাইল করুন, প্রতিটি সার্ভার প্রবেশবিন্দুতে কল করুন;
- ডকুমেন্টেশন জেনারেট করুন: স্কিমা থেকে Markdown বা OpenAPI বের করুন, যাতে ডক হাতে পুরোনো না হয়;
- রিগ্রেশন: API ফিক্সচার "টাইপ চেক" ও "স্কিমা ভ্যালিডেশন" — দুইটির মধ্য দিয়ে চালান; দুটোই পাস করলেই গণনা।
৭. চেকলিস্ট
- [ ] ঠিক একটি সত্যের উৎস আছে; অন্য কপি স্ক্রিপ্ট দিয়ে জেনারেট;
- [ ] জেনারেশন স্ক্রিপ্ট CI-তে চলে, আর আর্টিফ্যাক্টের যেকোনো পার্থক্য বিল্ড ব্যর্থ করে;
- [ ]
optionalওnullableচুক্তিতে স্পষ্টভাবে আলাদা; - [ ] এনাম ফিল্ডে অজানা মানের ফলব্যাক;
- [ ] বড় ইন্টিজার, টাকা ও সময়ের স্কিমা ও টাইপ — দুই জায়গায় স্পষ্ট প্রথা;
- [ ] জেনারেট করা টাইপ ফাইলে "হাতে সম্পাদনা করবেন না" হেডার।
সাধারণ প্রশ্ন
schema-first ও types-first-এর মধ্যে কীভাবে ঠিক করব?
ব্যাকএন্ড চুক্তির মালিক, ক্রস-ভাষা ব্যবহার দরকার, বা চুক্তি প্রকাশ্য হলে schema-first বাছুন, আর json-schema-to-typescript দিয়ে টাইপ জেনারেট করুন। ফ্রন্টএন্ড নেতৃত্ব দেয় ও টাইপ ও ভ্যালিডেশন একবার সংজ্ঞায়িত করতে চায় এমন হলে zod-শৈলীর "স্কিমাই টাইপ" লাইব্রেরি বাছুন, তারপর JSON Schema এক্সপোর্ট করুন। যে পথই হোক, জেনারেট করা আর্টিফ্যাক্ট রিপোজিটরির ফাইলের সঙ্গে তুলনা করার CI ধাপ দরকার।
হাতে লেখা টাইপ ও স্কিমা সংজ্ঞা সবসময় সরে যায় কেন?
টাইপ কম্পাইল সময়ে মুছে যায় অথচ স্কিমা কেবল রানটাইমে কাজ করে, আর দুই কপি ভিন্ন মানুষ ভিন্ন সময়ে সম্পাদনা করে। জেনারেশন ও তুলনা ছাড়া এক কপি হালনাগাদ হয় অন্যটি থেমে থাকে, যা "কম্পাইল হয়, তারপর রানটাইমে ৫০০" উৎপন্ন করে।
জেনারেট করা টাইপ ফাইল রিপোজিটরিতে কমিট করা উচিত?
হ্যাঁ। কমিট করলে রিভিউয়ার চুক্তির পরিবর্তন দেখতে পারে, আর CI নতুন জেনারেশন আউটপুট রিপোজিটরির ফাইলের সঙ্গে তুলনা করে যেকোনো পার্থক্যে ব্যর্থ হতে পারে। আর্টিফ্যাক্ট কেবল লোকাল ফোল্ডার বা বিল্ড ক্যাশে রেখে গেলে তিন মাস পরে কেউ জানে না তা হালনাগাদ কি না।