Skip to main content

01 - TypeScript essentials for fullstack devs

Structural types, discriminated unions, constrained generics, two flags strict leaves off and the runtime boundary: all compiled with TypeScript 7.0.2.

What do you think about this article?

01 - TypeScript essentials for fullstack devs
Fullstack Web Developmentfedericocalo.dev

At a Glance

Structural types, discriminated unions, constrained generics, two flags strict leaves off and the runtime boundary: all compiled with TypeScript 7.0.2.

TypeScript checks your program before it runs and then disappears: what reaches production is JavaScript, without a single type. Two of the most common mistakes people make when they come from JavaScript follow from that sentence. The first is thinking that types also protect the data that comes in from outside (an HTTP response, a JSON file, a form field), when they do not touch anything that exists only at runtime. The second is using them as documentation and nothing more, giving up on letting the compiler tell you what you forgot to handle. This first chapter of the Fullstack Web Development series shows four mechanisms that change the way you write code and one boundary that no type crosses. Every listing was compiled and, where it produces a result, run.

The test bench is a small project of mine with TypeScript 7.0.2 (the latest version on the npm registry on 13 November 2026) and Node.js 24.11. The compiler only does the checking (noEmit); the running is done by Node, which on its own strips the types and launches the .ts file. No browser and no server were used, and you need neither for what follows.

The compiler looks at the shape, not the name

In TypeScript two types are compatible if they have the same properties, regardless of what they are called and where they are declared: this is structural typing. For an invoice with an amount and a currency, any object with those fields is an invoice, even if it comes from a query or a CSV. The advantage is that you do not have to declare that one type "implements" another; the risk is that the compiler accepts objects with more fields than the ones you declare, because an object with an extra field is still a valid invoice.

interface Money { amount: number; currency: string }
interface Invoice { id: string; total: Money }

// Not a literal: no excess property check
const row = { id: "F-1", total: { amount: 120, currency: "EUR" }, note: "paid" };
const invoice: Invoice = row;

console.log(invoice.id, invoice.total.amount);
console.log(Object.keys(invoice));

// Output:
// F-1 120
// [ 'id', 'total', 'note' ]

In the listing row has a note field that Invoice does not know about, and the assignment const invoice: Invoice = row goes through anyway: the intermediate variable is not a literal, so the excess property check does not kick in. The output confirms it at runtime: Object.keys still shows note. The type restricts what the compiler lets you read, not what the object actually contains. Practical consequence: if you serialize that object in a response, the extra field goes out anyway.

When instead you write the literal directly in the assignment, the compiler becomes strict, because there an unknown field is almost always a typo. With the same two types, two wrong lines produce two distinct errors (command: tsc -p tsconfig.err.json --pretty false).

interface Money { amount: number; currency: string }
interface Invoice { id: string; total: Money }

const literal: Invoice = { id: "F-2", total: { amount: 10, currency: "EUR" }, note: "x" };
const missing: Invoice = { id: "F-3" };

// tsc error:
// errs/s1_err.ts(4,79): error TS2353: Object literal may only specify known properties, and 'note' does not exist in type 'Invoice'.
// errs/s1_err.ts(5,7): error TS2741: Property 'total' is missing in type '{ id: string; }' but required in type 'Invoice'.

The first message (TS2353) is the literal check: it points to the exact column of the note field. The second (TS2741) says which required property is missing. Reading the code number is useful in practice: you look it up in the documentation or in your editor and find the explanation right away, without relying on the text of the message, which can change between versions.

Discriminated unions: let the compiler count the cases

A payment can be by card, by bank transfer or in cash, and each method carries different data: the last four digits for the card, the IBAN for the transfer, nothing for cash. Modelling it with a single object full of optional fields forces you to remember which combination is legal. The discriminated union makes it explicit: each variant has a literal property in common (here kind) and the compiler, checking it in a switch, knows which other fields exist in each branch.

type Payment =
  | { kind: "card"; last4: string }
  | { kind: "iban"; iban: string }
  | { kind: "cash" };

function describe(p: Payment): string {
  switch (p.kind) {
    case "card":
      return `card ****${p.last4}`;
    case "iban":
      return `bank transfer ${p.iban.slice(0, 4)}...`;
    case "cash":
      return "cash";
    default: {
      const unreachable: never = p;
      return unreachable;
    }
  }
}

const payments: Payment[] = [
  { kind: "card", last4: "4242" },
  { kind: "iban", iban: "IT60X0542811101000000123456" },
  { kind: "cash" },
];
for (const p of payments) console.log(describe(p));

// Output:
// card ****4242
// bank transfer IT60...
// cash

The line that matters is the one in the default branch: const unreachable: never = p. If all the cases are handled, at that point p has type never and the assignment is legal; if one is missing, p still has a type and the assignment fails. Inside case "iban" you can call p.iban.slice(0, 4) without checks, because the compiler knows that in that branch the field exists. The technique is described in the Handbook under "Exhaustiveness checking", with the same never pattern in the default branch.

The advantage shows when the code changes. Add the paypal variant to the union without touching the function and the compiler points to the function to update, instead of letting the new method fall into the default branch and silently produce a wrong result.

type Payment =
  | { kind: "card"; last4: string }
  | { kind: "iban"; iban: string }
  | { kind: "cash" }
  | { kind: "paypal"; email: string };
// ... function describe unchanged, with the same default: never

// tsc error:
// errs/s2_err.ts(16,13): error TS2322: Type '{ kind: "paypal"; email: string; }' is not assignable to type 'never'.

The TS2322 error appears on the default line, not where you added the variant: it is the point where the function can no longer handle all the cases. If your project has twenty functions that switch on an event type, adding an event hands you the list of all twenty. Do not use this pattern when an unhandled case is acceptable: in that case a default that returns a neutral value is the honest choice.

Generics: write the function once, for several types

A function that extracts two fields from an invoice and one that extracts them from a customer are the same function written twice. With a type parameter (T) you write the logic once and the compiler works out the rest from the types you pass. A type parameter on its own, however, accepts everything: to say "this key must exist in that object" you need a constraint, written with extends. In the listing that follows, K extends keyof T requires every key to be a real property of T.

interface Invoice { id: string; customer: string; total: number; paid: boolean }

function pick<T, K extends keyof T>(obj: T, keys: readonly K[]): Pick<T, K> {
  const out = {} as Pick<T, K>;
  for (const k of keys) out[k] = obj[k];
  return out;
}

function groupBy<T, K extends PropertyKey>(
  items: readonly T[],
  key: (item: T) => K,
): Partial<Record<K, T[]>> {
  const out: Partial<Record<K, T[]>> = {};
  for (const item of items) {
    (out[key(item)] ??= []).push(item);
  }
  return out;
}

type Nullable<T> = { [P in keyof T]: T[P] | null };

const invoices: Invoice[] = [
  { id: "F-1", customer: "Rossi", total: 120, paid: true },
  { id: "F-2", customer: "Bianchi", total: 80, paid: false },
  { id: "F-3", customer: "Rossi", total: 45, paid: false },
];

const summary = pick(invoices[0]!, ["id", "total"]);
console.log(summary);

const byCustomer = groupBy(invoices, (i) => i.customer);
console.log(Object.entries(byCustomer).map(([c, list]) => `${c}: ${list?.length ?? 0}`));

const draft: Nullable<Invoice> = { id: null, customer: "Verdi", total: null, paid: null };
console.log(draft);

// Output:
// { id: 'F-1', total: 120 }
// [ 'Rossi: 2', 'Bianchi: 1' ]
// { id: null, customer: 'Verdi', total: null, paid: null }

Three functions, three different ideas. pick returns an object with only the requested subset of fields, and the type of the result (Pick<T, K>) reflects it: summary.customer would be an error. groupBy groups by a computed key. Its return type is Partial<Record<K, T[]>> and not Record<K, T[]> for a precise reason: if K is a union of possible values, nothing guarantees that each one actually appears, and indeed in the code I had to write list?.length ?? 0, because the compiler flags that the list may be missing. The Nullable<T> type is a mapped type: it iterates over the keys of T and adds null to each type.

If you get a key wrong, the compiler stops you before the program runs. With pick(inv, ["id", "tot"]), where tot does not exist in Invoice, you get:

declare const inv: Invoice;
pick(inv, ["id", "tot"]);

// tsc error:
// errs/s3_err.ts(10,18): error TS2322: Type '"tot"' is not assignable to type 'keyof Invoice'.

(The line numbers in the errors refer to the complete files, reduced here to the essentials.) The message tells you that "tot" is not assignable to keyof Invoice, that is, to the union of the property names "id" | "customer" | "total" | "paid". In real projects this constraint protects field selectors, sort columns, translation keys: anywhere a string must be one of a known set. The generic is not needed when the function has a single kind of use: a type parameter that appears only once in the signature is almost always a sign that the concrete type is enough.

The utility types you will use every day

TypeScript's standard library already contains the most common mapped types, and it pays to know them before writing your own. The ones listed on the Handbook's "Utility Types" page and used most often with APIs are these:

  • Partial<T> makes all the properties of T optional: it is used for partial update requests.
  • Required<T> does the opposite and makes all the properties required.
  • Readonly<T> prevents reassigning the properties, at the type level only.
  • Pick<T, K> keeps only the properties listed in K.
  • Omit<T, K> removes the ones listed in K.
  • Record<K, V> builds an object with keys K and values of type V.

The order of the items does not matter here, so the list is a plain catalogue. What you need to remember is the selection criterion: if you start from a complete type and want a smaller variant, use Pick or Omit; if the variant has the same fields with different rules, use Partial, Required or Readonly. One warning: Readonly exists only in the compiler and does nothing at runtime; to really freeze an object you need Object.freeze.

Two flags that strict does not turn on

The documentation of the strict option says that turning it on is equivalent to enabling "all of the strict mode family options" and that future versions may add more. Two very useful checks are not part of it and must be requested by hand. I verified it: with the --strict option alone, the following file compiles without errors. With the two options active in my tsconfig.json it produces two.

const prices: number[] = [19.9, 5];
const first = prices[0];
console.log(first.toFixed(2));

interface Filter { q?: string }
const f: Filter = { q: undefined };

// tsc error:
// errs/s4_err.ts(3,13): error TS18048: 'first' is possibly 'undefined'.
// errs/s4_err.ts(6,7): error TS2375: Type '{ q: undefined; }' is not assignable to type 'Filter' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
//   Types of property 'q' are incompatible.
//     Type 'undefined' is not assignable to type 'string'.

The first error line is an effect of noUncheckedIndexedAccess, introduced with TypeScript 4.2: reading prices[0] returns number | undefined instead of number, because an array can be empty. The compiler forces you to decide what to do in that case. The second error comes from exactOptionalPropertyTypes: with the optional property q?: string the key may be missing, but it cannot be present with the value undefined. The distinction matters when you serialize: "q" in object and Object.keys see a key that is present with the value undefined (I verified this in Node), while a missing key does not appear, and two objects that look the same behave differently.

The code that compiles with both options is the following: in the first case you handle the empty array, in the second you choose whether the key is there or not, and the in operator proves it.

const prices: number[] = [19.9, 5];
const first = prices[0];
console.log(first === undefined ? "empty list" : first.toFixed(2));

interface Filter { q?: string }
const withQuery: Filter = { q: "rossi" };
const withoutQuery: Filter = {};
console.log("q" in withQuery, "q" in withoutQuery);

// Output:
// 19.90
// true false

The cost is real and has to be said: noUncheckedIndexedAccess fills code that walks arrays by index with checks, and in an existing project it can produce hundreds of errors at once. If you start from scratch, turn it on right away; if you are migrating, enable it folder by folder using a secondary configuration file that extends the first.

The boundary that types do not cross

Everything you have seen so far lives in the compiler. Data that arrives from a fetch, from a file or from a form exists only at runtime, and the compiler has no way to verify it. The typical case is JSON.parse(text) as Order: the as assertion checks nothing, it tells the compiler to trust you. The listing shows the difference with a payload where id and qty arrive as strings, as happens with a form or a badly written service.

interface Order { id: number; email: string; qty: number }

const payload = '{"id":"7","email":"a@b.it","qty":"3"}';

// The cast tells the compiler what it wants to hear: at runtime it checks nothing
const trusted = JSON.parse(payload) as Order;
console.log(typeof trusted.qty, trusted.qty + 1);

function parseOrder(input: unknown): Order {
  if (typeof input !== "object" || input === null) throw new TypeError("order: object expected");
  const o = input as Record<string, unknown>;
  if (typeof o.id !== "number" || !Number.isInteger(o.id)) throw new TypeError("order.id: integer expected");
  if (typeof o.email !== "string" || !o.email.includes("@")) throw new TypeError("order.email: address expected");
  if (typeof o.qty !== "number" || o.qty < 1) throw new TypeError("order.qty: number >= 1 expected");
  return { id: o.id, email: o.email, qty: o.qty };
}

try {
  parseOrder(JSON.parse(payload));
} catch (e) {
  console.log((e as Error).message);
}
console.log(parseOrder(JSON.parse('{"id":7,"email":"a@b.it","qty":3}')));

// Output:
// string 31
// order.id: integer expected
// { id: 7, email: 'a@b.it', qty: 3 }

The first line printed is the proof: trusted.qty is declared number but at runtime it is a string, and trusted.qty + 1 is "31" instead of 4. No error, no warning: the type was lying. The parseOrder function accepts unknown, checks the fields one by one and rejects the payload with a message that names the wrong field (order.id: integer expected); only after the checks does the compiler let you treat the value as an Order. This function has a cost: it has to be kept aligned with the type. That is why many projects generate the validation and the type from the same definition, with a schema library, or derive the type from an OpenAPI schema as you will see in the chapter on GraphQL and REST.

The configuration used for all the listings

This is the tsconfig.json file I compiled with, copied as it is. The only options beyond the basic ones are the two already seen and erasableSyntaxOnly, which makes the compiler flag the constructs that Node cannot strip (enums, namespaces with code, properties declared in constructor parameters): the option's documentation explains that it exists precisely so that files can be run directly in Node.

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "erasableSyntaxOnly": true,
    "noEmit": true,
    "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}

With this configuration npx tsc -p tsconfig.json checks the types and finishes without errors, and node file.ts runs the same file. They are two tools with two jobs: Node strips the types without checking them (its documentation says so explicitly: no type checking), so a type error does not stop execution. That is why in the pipeline the type check is a separate step, which must fail before release.

Frequently asked questions

Does TypeScript check the data that comes from an API?

No. Types exist only at compile time: data that arrives at runtime is not checked, and an as assertion does not change this. For external data you need a validation function that starts from unknown, like parseOrder in the listing, or a schema library.

Is it worth turning on noUncheckedIndexedAccess on an existing project?

It depends on the cost: it adds checks on every index read and in a large codebase it produces many errors. On a new project it is worth turning on right away; on an existing one you can enable it in a secondary configuration that extends the main one and apply it one folder at a time.

Can Node run TypeScript without compiling?

Yes, for syntax that can be erased without changing the meaning. Node's documentation says that it replaces types with whitespace and performs no checking; enums and namespaces with code instead require a transformation. TypeScript's erasableSyntaxOnly option flags the unsupported constructs.

What you can do now

You can read a TypeScript error from its code and the column it indicates, model a choice between alternatives with a discriminated union and let the compiler warn you when you add one. You can constrain a generic with keyof, choose among the standard library's utility types and turn on the two checks that strict leaves off. Above all you know where the guarantee ends: at the edges of the program you need a validation written by you, not a cast. The next chapter, Angular SSR, standalone components and signals, uses these tools on the client side.

The series closes with Multi-environment CI/CD, which collects the list of all eight articles.

Sources

  • TSConfig Reference - definition of strict, noUncheckedIndexedAccess (since 4.2), exactOptionalPropertyTypes and erasableSyntaxOnly, page downloaded and read on 2026-11-13
  • Handbook, Narrowing - "Exhaustiveness checking" section with the never type in the default branch, read on 2026-11-13
  • Handbook, Utility Types - definition of Partial, Required, Readonly, Pick, Omit, Record, read on 2026-11-13
  • Handbook, Generics - type parameters and constraints with extends, page downloaded on 2026-11-13
  • Node.js, TypeScript (official documentation) - type stripping without type checking, version history (enabled by default from 23.6 and 22.18, stable from 24.12 and 25.2), read on 2026-11-13
  • npm registry, typescript package - latest version 7.0.2, queried with npm view typescript dist-tags on 2026-11-13

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects