TypeScript Foundation
TypeScript and JavaScript together
See exactly what TypeScript becomes when it runs, why types cannot check outside data, and how to close that gap by hand with type guards, assertion functions and a validator that returns a Result.
From TypeScript to a running program
Every TypeScript program goes through the same steps:
- TypeScript: you write
.tsfiles with types.tscchecks them. - JavaScript: the types are removed, by
tsc,tsxor Node.js. - Node.js: runs the JavaScript. It has never heard of your types.
- Your application: receives requests, reads files and databases, and talks to other services, all while running.
Here is a small file. It has an interface, a type alias, annotations, a default parameter and a type assertion (as):
interface Task {
id: number;
title: string;
}
type Status = "todo" | "done";
function label(task: Task, status: Status = "todo"): string {
return `${task.title} (${status})`;
}
const raw: unknown = { id: 1, title: "Buy milk" };
const task = raw as Task;
console.log(label(task));
npx tsx erase.ts and of the browser terminalBuy milk (todo)
Compile it in your ts-tasks folder, and read what Node.js will actually run:
npx tsc --noEmit false --outDir dist cat dist/erase.js function label(task, status = "todo") { return `${task.title} (${status})`; } const raw = { id: 1, title: "Buy milk" }; const task = raw; console.log(label(task)); export {};
The interface and the type alias are gone. So are the annotations. The default value = "todo" stayed, because it is JavaScript. And look at const task = raw;: the as Task became nothing at all. No check, no conversion. This is called type erasure, and it explains everything in this lesson.
Compile time and runtime
There are two worlds. Compile time is when tsc reads your code. Runtime is when Node.js runs it. Types live only in the first:
| Only at compile time (erased) | Also at runtime (real JavaScript) |
|---|---|
interface, type, annotations, generics | Values, functions, classes |
value as Task | typeof value === "string" |
private, readonly, import type | #private, Object.freeze, import |
Checking that task.title exists | "title" in task, value instanceof Date, Array.isArray(value) |
So you cannot ask, while the program runs, "is this value a Task?". There is no Task left to ask about:
interface Task {
id: number;
title: string;
}
function isTask(value: unknown): boolean {
return value instanceof Task;
}
npx tsc --noEmit printsinstanceof.ts:7:27 - error TS2693: 'Task' only refers to a type, but is being used as a value here.
7 return value instanceof Task;
~~~~
Found 1 error in instanceof.ts:7instanceof works with classes, because a class is also a real JavaScript value. An interface is not. To check a value against an interface, you have to check its parts with runtime tools: typeof, in, Array.isArray.
The gap: data from outside
Inside your program, the compiler follows every value from where it is made to where it is used. But a backend's most important data comes from outside: request bodies, query strings, environment variables, files, database rows, other APIs. That data arrives while the program runs, as text, and the compiler never saw it.
The trap is JSON.parse. It is declared to return any, so you can put its result straight into a typed variable, and the compiler believes you:
interface Task {
title: string;
done: boolean;
}
const requestBody = '{"title": 42, "done": "sometimes"}';
const task: Task = JSON.parse(requestBody);
console.log(typeof task.title, typeof task.done);
try {
console.log(task.title.toUpperCase());
} catch (error) {
console.log(String(error));
}
npx tsx gap.ts and of the browser terminalnumber string TypeError: task.title.toUpperCase is not a function
This file passes tsc with no errors, then fails when it runs. Without the try, the program would crash. The types said title was a string. The data said otherwise, and the data won, because the types were erased before the code ran.
Every request that reaches your API is text like requestBody, sent by someone else, possibly an attacker. Something has to check it at runtime and turn it into a value that really matches the type. The rest of this lesson builds that check by hand.
Type assertions: when as lies
value as Task is a type assertion. It tells the compiler "trust me, this is a Task". You just saw it compile to nothing. It changes the type the compiler believes, never the value:
interface Task {
id: number;
title: string;
}
const body: unknown = JSON.parse('{"id": "7"}');
const task = body as Task;
const id = "42" as unknown as number;
console.log(typeof task.id, task.title);
console.log(typeof id, id + 1);
npx tsx as-lies.ts and of the browser terminalstring undefined string 421
task.idis typed as anumber, but it is the string"7", andtask.titleis typed as astringbut does not exist."42" as unknown as numbercompiles, andid + 1gives"421": string joining, not addition. TypeScript refuses"42" as numberdirectly because it is obviously wrong, but going throughunknownsilences every objection.
When is as acceptable? Only when you know something the compiler cannot, and you have checked it yourself. as const is always safe: it makes a type narrower, not different. field as keyof NewTask right after Object.keys on an object you built yourself, as in Advanced and utility types, is safe. On data from outside, as is never a check. The same goes for any and for the ! operator (value!, "not null, trust me"): each one switches the compiler off for one spot.
Type guards: value is T
You already narrow types with typeof and in. A type guard packs such checks into a function you can reuse. Its return type, value is NewTask, is called a type predicate: when the function returns true, TypeScript narrows the argument to that type.
interface NewTask {
title: string;
done: boolean;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNewTask(value: unknown): value is NewTask {
return isRecord(value) && typeof value.title === "string" && typeof value.done === "boolean";
}
for (const text of ['{"title": "Buy milk", "done": false}', '{"title": 42, "done": "sometimes"}', "[1, 2]", "null"]) {
const body: unknown = JSON.parse(text);
if (isNewTask(body)) {
console.log("valid:", body.title.toUpperCase());
} else {
console.log("invalid:", text);
}
}
npx tsx guard.ts and of the browser terminalvalid: BUY MILK
invalid: {"title": 42, "done": "sometimes"}
invalid: [1, 2]
invalid: nullbodyisunknown: the honest type for parsed JSON. Writingconst body: unknownstopsanyfrom spreading.isRecordchecks the value is a plain object.typeof nullis"object"in JavaScript, and so is an array, so both need their own test.- Inside
if (isNewTask(body)),bodyis aNewTask, and.toUpperCase()is allowed.
THE COMPILER TRUSTS YOUR GUARD
TypeScript does not check that the body of a type guard matches its predicate. A guard that forgets to checkdone still narrows to NewTask, and the bug is back. Type guards are the one place where a type can lie, so keep them short and write tests for them.Assertion functions: asserts value is T
A type guard returns a boolean and you write the if. An assertion function throws instead, and when it returns normally, the value is narrowed for the rest of the code. Its return type is asserts value is T:
interface NewTask {
title: string;
done: boolean;
}
function assertNewTask(value: unknown): asserts value is NewTask {
if (typeof value !== "object" || value === null) {
throw new TypeError("body must be a JSON object");
}
if (!("title" in value) || typeof value.title !== "string") {
throw new TypeError("title must be a string");
}
if (!("done" in value) || typeof value.done !== "boolean") {
throw new TypeError("done must be true or false");
}
}
for (const text of ['{"title": "Call Ada", "done": true}', '{"title": "Call Ada"}']) {
const body: unknown = JSON.parse(text);
try {
assertNewTask(body);
console.log(body.title, body.done);
} catch (error) {
console.log(String(error));
}
}
npx tsx asserts.ts and of the browser terminalCall Ada true TypeError: done must be true or false
After assertNewTask(body), body is a NewTask on every following line. This version also uses in instead of isRecord: after "title" in value, TypeScript knows value has a title property, of type unknown, which typeof then narrows. Use an assertion function when bad data should stop the work, and a type guard when you want to choose what to do.
unknown in catch
There is one more place where outside data sneaks in: catch. JavaScript lets code throw anything, not only Error objects. So with strict on, the variable in catch (error) has the type unknown:
try {
JSON.parse("{not json");
} catch (error) {
console.log(error.message);
}
npx tsc --noEmit printscatch.ts:4:15 - error TS18046: 'error' is of type 'unknown'.
4 console.log(error.message);
~~~~~
Found 1 error in catch.ts:4function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
try {
JSON.parse("{not json");
} catch (error) {
console.log("bad JSON:", messageOf(error));
}
try {
throw "a plain string";
} catch (error) {
console.log("thrown:", messageOf(error));
}
npx tsx catch.ts and of the browser terminalbad JSON: Expected property name or '}' in JSON at position 1 (line 1 column 2) thrown: a plain string
error instanceof Error is a runtime check (Error is a class), so it narrows. Never write catch (error: any); it just hides the question.
Build: a validator that returns a Result
A real API should not stop at the first problem. It should tell the client everything that is wrong, in one answer. It should also clean the data: trim spaces, enforce lengths, and copy only the fields it expects, so a client cannot sneak in an id or a role (the mass assignment hole from the user module). Here is a validator that does all of that and returns the Result type from Generics:
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
export type Priority = "low" | "normal" | "high";
export interface NewTask {
title: string;
done: boolean;
priority: Priority;
}
const PRIORITIES: readonly Priority[] = ["low", "normal", "high"];
function isPriority(value: unknown): value is Priority {
return PRIORITIES.some((p) => p === value);
}
export function parseNewTask(body: unknown): Result<NewTask, string[]> {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
return { ok: false, error: ["body must be a JSON object"] };
}
const input = body as Record<string, unknown>;
const issues: string[] = [];
const title = typeof input.title === "string" ? input.title.trim() : "";
if (title.length < 3 || title.length > 100) issues.push("title must be a string of 3 to 100 characters");
const done = input.done ?? false;
if (typeof done !== "boolean") issues.push("done must be true or false");
const priority = input.priority ?? "normal";
if (!isPriority(priority)) issues.push(`priority must be one of ${PRIORITIES.join(", ")}`);
if (issues.length > 0 || typeof done !== "boolean" || !isPriority(priority)) {
return { ok: false, error: issues };
}
return { ok: true, value: { title, done, priority } };
}
Read it top to bottom. The one as is safe: the line before it proved body is a non-null, non-array object, and Record<string, unknown> still says "every property is unknown". Each field is read, checked, and given its default. The final object is built by hand from three checked variables, so nothing else the client sent can get through.
The last if repeats typeof done and isPriority even though issues already says whether they failed. The compiler cannot connect "the array is empty" to "these checks passed", so the checks are written where it can see them. Now the handler, the one function that touches the raw request:
import { parseNewTask } from "./validate.js";
import type { NewTask } from "./validate.js";
interface Reply {
status: number;
body: unknown;
}
let nextId = 1;
function createTask(input: NewTask): { id: number } & NewTask {
return { id: nextId++, ...input };
}
export function handleCreateTask(rawBody: string): Reply {
let body: unknown;
try {
body = JSON.parse(rawBody);
} catch {
return { status: 400, body: { error: "body is not valid JSON" } };
}
const result = parseNewTask(body);
if (!result.ok) return { status: 422, body: { error: "invalid task", issues: result.error } };
return { status: 201, body: createTask(result.value) };
}
console.log(handleCreateTask('{"title": " Buy milk ", "priority": "high"}'));
console.log(handleCreateTask('{"title": 42, "done": "sometimes"}'));
console.log(handleCreateTask('{"title": "Fix bug", "id": 999, "role": "admin"}'));
console.log(handleCreateTask("{not json"));
npx tsx handler.ts and of the browser terminal{
status: 201,
body: { id: 1, title: 'Buy milk', done: false, priority: 'high' }
}
{
status: 422,
body: {
error: 'invalid task',
issues: [
'title must be a string of 3 to 100 characters',
'done must be true or false'
]
}
}
{
status: 201,
body: { id: 2, title: 'Fix bug', done: false, priority: 'normal' }
}
{ status: 400, body: { error: 'body is not valid JSON' } }Four requests, four correct answers. The spaces around "Buy milk" were trimmed. The bad body from earlier got a 422 with both problems listed, as you designed in the REST design lesson. The third client tried to choose its own id and make itself an admin; both fields were simply dropped. Broken JSON got a 400. And createTask takes a NewTask, so it can only ever be called with checked data.
Designing type-safe APIs
What you just built follows a handful of rules that hold for any TypeScript backend:
- Type the boundary as
unknown. Everything from outside (request bodies,process.env, files, other APIs) starts asunknown, neverany. - Validate once, at the edge. Turn
unknowninto a precise type in one place. Inner functions such ascreateTask(input: NewTask)take only checked types, so they never need to check again. - Return failures in the type.
Result<T, E>or a discriminated union makes the caller handle the bad case. Throw for things that should never happen. - Prefer guards to
as,unknowntoany, checks to!. Every escape hatch is a spot the compiler can no longer help you. - Allow-list fields. Build output and input objects from named fields, never by spreading what a client sent, and never by sending a whole internal object.
- Make wrong states unwritable. Literal unions instead of free strings,
readonlywhere things must not change, discriminated unions instead of many optional fields.
Hand-written validators get long
Count the lines. Checking three fields took about twenty lines of careful code, plus a trick to keep the compiler convinced, plus the NewTask interface written separately from the checks. If someone adds a field to the interface and forgets the validator, nothing warns them. A real API has dozens of request shapes, with nested objects, arrays, e-mail addresses and dates.
That is exactly the problem @zudojs/schema solves. You describe the shape once, as a schema; it checks values at runtime, collects every issue, strips unknown fields, and gives you the TypeScript type for free, so the type and the check can never disagree. The same task, with ZudoJS:
const NewTaskSchema = schema.object({
title: schema.string().trim().min(3).max(100),
done: schema.default(schema.boolean(), false),
priority: schema.default(schema.enum(["low", "normal", "high"]), "normal"),
});
type NewTask = Infer<typeof NewTaskSchema>;
You will install it and use it in Your first Zudo code. Now you know exactly what it does for you, and why.
Practice
TRY IT YOURSELF
Read a port from the environment
Write readPort(value: string | undefined): Result<number, string> for process.env.PORT. Missing means 3000. Anything that is not a whole number from 1 to 65535 is an error. Why is the parameter string | undefined and not number?
Show a solution
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function readPort(value: string | undefined): Result<number, string> {
if (value === undefined || value.trim() === "") return { ok: true, value: 3000 };
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
return { ok: false, error: `PORT must be a whole number from 1 to 65535, got "${value}"` };
}
return { ok: true, value: port };
}
for (const input of [undefined, "8080", "80.5", "http"]) {
console.log(readPort(input));
}
npx tsx port.ts and of the browser terminal{ ok: true, value: 3000 }
{ ok: true, value: 8080 }
{
ok: false,
error: 'PORT must be a whole number from 1 to 65535, got "80.5"'
}
{
ok: false,
error: 'PORT must be a whole number from 1 to 65535, got "http"'
}Environment variables are always strings, or missing. That is what @types/node says too: process.env.PORT has the type string | undefined. In your program you would call readPort(process.env.PORT) and stop with a clear message when the result is not ok.
TRY IT YOURSELF
Find the lying guard
This guard compiles, and the program crashes. Find the bug, fix the guard, and explain why tsc did not catch it.
interface User {
email: string;
tags: string[];
}
function isUser(value: unknown): value is User {
return typeof value === "object" && value !== null && "email" in value;
}
const body: unknown = JSON.parse('{"email": "ada@example.com"}');
if (isUser(body)) {
try {
console.log(body.tags.join(", "));
} catch (error) {
console.log(String(error));
}
}
npx tsx lying-guard.ts and of the browser terminalTypeError: Cannot read properties of undefined (reading 'join')
Show a solution
The guard only checks that email exists. It checks neither that email is a string nor that tags is an array of strings. tsc does not compare a guard's body with its value is User promise; it simply believes it.
interface User {
email: string;
tags: string[];
}
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) return false;
if (!("email" in value) || typeof value.email !== "string") return false;
if (!("tags" in value) || !Array.isArray(value.tags)) return false;
return value.tags.every((tag: unknown) => typeof tag === "string");
}
const body: unknown = JSON.parse('{"email": "ada@example.com"}');
console.log(isUser(body) ? body.tags.join(", ") : "not a user");
npx tsx lying-guard.ts and of the browser terminalnot a user
TRY IT YOURSELF
Validate a patch
Write parseTaskPatch(body: unknown): Result<{ title?: string; done?: boolean }, string[]>. Both fields are optional, but when present they must be valid (title 3 to 100 characters after trimming). An empty patch {} is an error: there is nothing to change.
Show a solution
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
interface TaskPatch {
title?: string;
done?: boolean;
}
function parseTaskPatch(body: unknown): Result<TaskPatch, string[]> {
if (typeof body !== "object" || body === null || Array.isArray(body)) {
return { ok: false, error: ["body must be a JSON object"] };
}
const input = body as Record<string, unknown>;
const patch: TaskPatch = {};
const issues: string[] = [];
if (input.title !== undefined) {
const title = typeof input.title === "string" ? input.title.trim() : "";
if (title.length >= 3 && title.length <= 100) patch.title = title;
else issues.push("title must be a string of 3 to 100 characters");
}
if (input.done !== undefined) {
if (typeof input.done === "boolean") patch.done = input.done;
else issues.push("done must be true or false");
}
if (issues.length === 0 && Object.keys(patch).length === 0) issues.push("nothing to change");
return issues.length > 0 ? { ok: false, error: issues } : { ok: true, value: patch };
}
console.log(parseTaskPatch({ done: true }));
console.log(parseTaskPatch({ title: " Buy oat milk " }));
console.log(parseTaskPatch({ id: 5 }));
console.log(parseTaskPatch({ title: "x", done: "yes" }));
npx tsx patch.ts and of the browser terminal{ ok: true, value: { done: true } }
{ ok: true, value: { title: 'Buy oat milk' } }
{ ok: false, error: [ 'nothing to change' ] }
{
ok: false,
error: [
'title must be a string of 3 to 100 characters',
'done must be true or false'
]
}{ id: 5 } is refused as "nothing to change": the id is ignored, because only allowed fields are ever copied into patch.
Recap
- TypeScript becomes JavaScript before it runs. Types, interfaces and
asare erased; only JavaScript checks exist at runtime. - Outside data (JSON,
process.env, files, other APIs) was never seen by the compiler. Type it asunknown. aschanges what the compiler believes, never the value. Do not use it on outside data.- Type guards (
value is T) and assertion functions (asserts value is T) narrowunknownafter real checks. The compiler trusts them, so keep them correct. - In
catch, the error isunknown; check withinstanceof Error. - Validate once at the boundary, return a
Resultwith every issue, and copy only allowed fields.@zudojs/schemadoes this for you, next.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.