TypeScript Typing in 2026: Interfaces, Generics and Utility Types in Practice

TypeScript Typing in 2026: Interfaces, Generics and Utility Types in Practice

TypeScript typing is the craft of telling the compiler just enough about your data that it catches bugs you’d otherwise meet in production — and not so much that you spend your day wrestling the type system instead of shipping software. Everything practical lives between those two extremes.

This article covers the concrete tools: interfaces and types, generics, utility types, unknown versus any, type guards and mapped types. And it covers the one thing most tutorials skip, even though it causes more production failures in typed projects than anything else: at the system boundary your type system stops protecting you — and the compiler never says a word about it.

Every error message here is real. We compiled every example in a fresh project running TypeScript 7.0.2 and copied the output verbatim, error codes included. Where code crashes at runtime, we let it crash and kept the stack trace.

Measurement date: August 5, 2026

ItemValueHow verified
TypeScript stable7.0.2npm view typescript dist-tags
TypeScript preview7.1.0-dev.20260804.1same (next tag)
Zod (for the runtime section)4.4.3npm view zod version
Node for runtime proofsv22.23.0node --version
Compiler optionsstrict: true, noEmit, target ES2022see tsconfig.json below

Every example ran against this tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "preserve",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true
  }
}

Run the same examples without strict: true and you’ll get fewer errors — and a false sense of safety. More on that below.

The short version

  • interface and type are interchangeable about 95% of the time. The one hard difference: interfaces can be extended after the fact (declaration merging), types cannot. That’s a feature for public APIs and a liability for internal data shapes.
  • Generics are placeholders, not magic. You write a function once and the caller still keeps their concrete type. The any[] shortcut throws away exactly the information you adopted TypeScript for.
  • Utility types save real maintenance work. Omit, Pick, Partial, Readonly, Record derive types from each other instead of duplicating them. Copied types drift apart eventually — silently.
  • any turns checking off, unknown forces it. Our test: the same faulty access produces error TS18046 with unknown, and zero errors with any.
  • The most important finding in this article: a program that treats an API response as a typed value compiles with not a single error and dies at runtime with TypeError: Cannot read properties of undefined. We show the proof in both directions.
  • as is an assertion, not a check. Two as casts, 0 compiler errors, 1 crash.
  • The cures are type guards and schema validation at exactly three places: network, storage, user input. In between, trust your types.
  • noUncheckedIndexedAccess is the most underrated option. It caught a bug in our test that strict: true alone let through.
🧪

Try everything without installing anything Every example in this article runs in our TypeScript Playground: code on the left, type errors and compiled JavaScript on the right. Entirely in your browser.

Two similar translucent blueprint containers compared side by side, the left one extensible with additional layers merging in, the right one a sealed single unit, a balance scale between them

Prerequisites: what you should already have

This article assumes you can get TypeScript running and know what string, number and a basic object literal are. If you’re at the very beginning — what order to learn things in, how long it takes, how to set up a project — read our honest TypeScript roadmap first and come back.

This is the layer above that: how do you actually type things? Not “what is a type”, but “which construct fits which situation, and what does the wrong choice cost me”.

Interface or type — a question smaller than its reputation

Few questions in the TypeScript world generate so much discussion over so little practical difference. The honest answer first: in the vast majority of cases either works, and nobody will ever notice.

Both describe the shape of an object:

interface User {
  id: number;
  name: string;
}

type Admin = {
  id: number;
  role: string;
};

Both check equally strictly. Add an undeclared field and you get the same message either way. Here’s the real tsc output for an extra extra field:

a.ts(4,39): error TS2353: Object literal may only specify known properties,
and 'extra' does not exist in type 'User'.

That message is its own concept, and it surprises people: TypeScript checks object literals more strictly than variables. Assign the same object to a variable first, then to the target, and the message disappears. That’s deliberate — the excess property check only applies to fresh literals — and it’s why errors sometimes appear or vanish as you refactor.

The three real differences

1. Declaration merging — interfaces only. An interface can be declared multiple times and TypeScript merges the declarations:

interface Window {
  myExtension: string;
}

That’s how you extend third-party type definitions (from the browser, from a library) without touching their source. With type, the same name is a hard error: Duplicate identifier.

That’s simultaneously the argument against interfaces for internal data shapes: if anyone can bolt fields on from anywhere, reading a declaration no longer tells you whether it’s complete.

2. Unions and anything that isn’t an object — type only. This simply doesn’t work with an interface:

type Status = "open" | "paid" | "cancelled";
type ID = string | number;
type Callback = (error: Error | null, data?: string) => void;

Union types are the single most-used tool in day-to-day TypeScript. That alone means you can’t avoid type.

3. Error messages. Interfaces appear in messages under their name; complex type expressions sometimes get expanded inline. With deeply nested types that’s the difference between a readable error and one that fills the screen.

The rule we actually use

  • Object shapes others should be able to extend (public APIs, library types) → interface
  • Everything else — unions, function signatures, derived types, internal shapes → type
  • Don’t argue about it on a team. Pick a convention, write it down, and spend the argument budget on something users can perceive.

Generics: write once, keep the type

Generics are where most people bail out — usually because of the notation, not the concept. The concept is simple: a placeholder for a type that’s decided at the call site.

The value shows up in the counterexample. Here’s the version without generics:

function firstBad(arr: any[]) { return arr[0]; }
function first<T>(arr: T[]): T | undefined { return arr[0]; }

const a = firstBad(["x"]);   // type: any
const b = first(["x"]);      // type: string | undefined

firstBad works — and discards every piece of information on the way. From there on the compiler knows nothing about a, and any typo on it goes unnoticed. With first the type survives. Treat the result as a number by mistake and the compiler says so plainly:

g1.ts(6,7): error TS2322: Type 'string | undefined' is not assignable to type 'number'.
  Type 'undefined' is not assignable to type 'number'.

That message does two jobs at once: it reports the wrong base type and reminds you the array might be empty. That’s the type system earning its keep.

A reusable stencil through which differently colored objects pass, each keeping its own color and identity on the other side

Constraints: placeholders with a minimum requirement

A bare T could be anything — so you’re allowed to do almost nothing with it. extends sets a floor:

function longest<T extends { length: number }>(a: T, b: T): T {
  return a.length >= b.length ? a : b;
}

longest("abc", "de");   // fine, strings have length
longest([1, 2], [3]);   // fine, arrays have length
longest(10, 20);        // numbers don't

The last line is the interesting one. Real output:

g2.ts(6,9): error TS2345: Argument of type 'number' is not assignable to
parameter of type '{ length: number; }'.

Note what does not happen: the function still returns the concrete type, not { length: number }. longest("a","b") is a string, not an anonymous object. That’s the whole point — impose a requirement without losing information.

When generics are overkill

Generics cost readability and error-message clarity. The rule of thumb that has held up for us:

A type parameter that appears only once is usually wrong. The purpose of a placeholder is to link two places — input to output, or one parameter to another. If T shows up in exactly one position, you could have written the concrete type there. At that point the generic is decoration.

Utility types: derive, don’t copy

Utility types are built-in type functions that build new types from existing ones. Their real value isn’t saved keystrokes — it’s coupling. A derived type updates automatically; a copied type drifts, and nobody notices.

The following example comes from practice, because it has a security angle:

interface User {
  id: number;
  name: string;
  email: string;
  passwordHash: string;
}

type PublicUser = Omit<User, "passwordHash">;
type UserPatch  = Partial<Pick<User, "name" | "email">>;
type Ids        = Readonly<Record<"a" | "b", number>>;

And here are the real errors when you violate them:

u.ts(5,61): error TS2353: Object literal may only specify known properties,
and 'passwordHash' does not exist in type 'PublicUser'.
u.ts(7,5): error TS2540: Cannot assign to 'a' because it is a read-only property.
u.ts(9,26): error TS2353: Object literal may only specify known properties,
and 'id' does not exist in type 'Partial<Pick<User, "email" | "name">>'.

The first one matters most: the compiler stops a password hash from leaking into an outbound response. No test, no review — a type definition. And when someone later adds a resetToken field to User, it’s automatically included in PublicUser and has to be excluded on purpose. A hand-copied PublicUser would have silently not included it, which sounds harmless but isn’t: the copy would never have received another update again.

A master blueprint transformed by glowing tools into four smaller derived blueprints, one removing parts, one making parts optional, one locking parts with padlocks

The seven that cover daily work

Utility typeWhat it doesTypical use
Partial<T>All fields optionalUpdate/patch objects
Required<T>All fields mandatoryAfter filling in defaults
Pick<T, K>Only the named fieldsNarrow views of large types
Omit<T, K>Everything but the namedStripping sensitive fields
Readonly<T>All fields read-onlyConfig, constants
Record<K, V>Object with fixed keysLookup tables
ReturnType<F>A function’s return typeDeriving types from code

Everything beyond that — Awaited, Parameters, NonNullable, conditional types — you need when you need it. Learn them preemptively and you’ll forget them.

satisfies: check without losing the type

This is the most useful addition of recent years and still underused. The problem: a type annotation checks — and widens the type in the process.

type Cfg = Record<string, string | number>;

const withAnnotation: Cfg = { port: 3000, host: "localhost" };
const withSatisfies = { port: 3000, host: "localhost" } satisfies Cfg;

withAnnotation.port.toFixed(0);   // error
withSatisfies.port.toFixed(0);    // works

The compiler output proves it — there is exactly one error, in the annotated line:

x/s.ts(4,32): error TS2339: Property 'toFixed' does not exist on type 'string | number'.
  Property 'toFixed' does not exist on type 'string'.

With : Cfg the compiler only knows “something from string | number” and refuses toFixed. With satisfies it checks the same constraint but remembers that port is concretely the number 3000. Validation without information loss. For config objects, route tables and constant maps it’s almost always the right call.

any versus unknown: the difference that matters

any isn’t a type annotation, it’s the absence of one. It switches checking off for that value — contagiously, because everything derived from it becomes any too.

We compiled both variants with the same mistake. First unknown:

function parse(raw: string): unknown { return JSON.parse(raw); }
const data = parse('{"n":1}');
console.log(data.n);
b.ts(4,13): error TS18046: 'data' is of type 'unknown'.

Now the same carelessness with any:

const val: any = "hello";
val.definitelyNotThere().alsoNotThere;
const n: number = val;

Result: not one error message. A call to a method that doesn’t exist, another access on its result, and finally assigning a string to a number variable. The compiler is silent on all three.

That’s the core of it: unknown says “I don’t know, so prove it to me”. any says “don’t ask”. Both describe the same uncertainty, but only one forces you to resolve it.

Two gates side by side, the left wide open with objects streaming through unchecked, the right with a scanner checkpoint inspecting each item before letting it pass

When any is fine anyway

Dogma doesn’t help here. There are legitimate cases:

  • Migrating existing code. An any with a // TODO beats a week of being blocked. What matters is that it stays findable — @typescript-eslint/no-explicit-any as a warning, not an error.
  • Genuinely dynamic boundaries, like a generic plugin loader. Even there unknown plus a type guard is usually better, just more expensive.

What is not fine: reaching for any whenever an error message annoys you. That’s the moment TypeScript stops being a tool and becomes decoration.

The most important section: where your types stop protecting you

Now the part tutorials almost always skip — and which, in our experience, causes more production failures than every generics subtlety combined.

TypeScript does not exist at runtime. Types are stripped before execution. What remains is JavaScript with no checking whatsoever. As long as your data stays inside your code that’s fine — the compiler verified it. The moment data arrives from outside, every type annotation is just a claim.

This program compiles cleanly:

interface ApiUser { id: number; name: string }

function fromApi(payload: string): ApiUser {
  return JSON.parse(payload);
}

const u = fromApi('{"id":1}');   // 'name' is actually missing
console.log("Type checking says: all good");
console.log(u.name.toUpperCase());

tsc reports zero errors. JSON.parse is declared to return any, and any fits everything — including ApiUser. The compiler has no reason to complain.

Then you run it:

Type checking says: all good
/tmp/tstyping/proof.ts:5
console.log(u.name.toUpperCase());
                   ^
TypeError: Cannot read properties of undefined (reading 'toUpperCase')

This isn’t theory or a contrived example — it’s the single most common way typed projects fall over. The compiler was green, the tests were green, and the API omitted a field.

The same goes for as. We wrote two casts, one of them deliberately absurd:

const asUser = raw as User;                  // raw is { id: 1 }
const bad = "5" as unknown as number;

0 compiler errors. At runtime: TypeError: Cannot read properties of undefined (reading 'length'). An as only asserts — it checks nothing. The double cast through unknown is the escape hatch that lets you tell the compiler anything at all.

A glowing vertical boundary between an orderly blueprint world on the left and a chaotic stream of raw unverified data arriving from the right, with a filter membrane at the border catching malformed items

The three boundaries where you must validate

You don’t need to validate everywhere — that would be expensive and pointless. There are exactly three places where data enters your type system:

  1. Network — every fetch response, every webhook, every queue message
  2. Storage — database, files, localStorage, environment variables
  3. User input — forms, URL parameters, uploaded files

Inside those boundaries, trust your types. At them, a type without a check is a wish.

Cure 1: type guards

A type guard is an ordinary function whose return type reads v is User. If it passes, the compiler knows from then on:

function isUser(v: unknown): v is User {
  return typeof v === "object" && v !== null
    && typeof (v as Record<string, unknown>).id === "number"
    && typeof (v as Record<string, unknown>).name === "string";
}

const raw: unknown = { id: 1 };
if (isUser(raw)) {
  console.log(raw.name.length);      // here raw is a User
} else {
  console.log("rejected: not a valid user");
}
console.log(raw.name);               // outside: unknown again

At runtime the program prints rejected: not a valid user instead of crashing. And the last line, which accesses outside the check, gets flagged:

guard.ts(10,13): error TS18046: 'raw' is of type 'unknown'.

Exactly right: full access inside the verified branch, no free trust outside it.

The downside is obvious: one line per field, maintained by hand. For a nested object with twenty fields, nobody writes this — and what nobody writes protects nobody.

Cure 2: schema validation (what we actually do)

Past a handful of fields, a schema validator is the better answer. We verified this with Zod 4.4.3:

import { z } from "zod";

const ApiUser = z.object({ id: z.number(), name: z.string() });
type ApiUser = z.infer<typeof ApiUser>;

function fromApi(payload: string): ApiUser {
  return ApiUser.parse(JSON.parse(payload));
}

The same broken payload as before ({"id":1}) no longer produces a crash three functions later, but this — real output:

[
  {
    "expected": "string",
    "code": "invalid_type",
    "path": ["name"],
    "message": "Invalid input: expected string, received undefined"
  }
]

The decisive improvement isn’t just that the error surfaces, but where: at the system boundary, with a path to the offending field. Compare that to Cannot read properties of undefined somewhere deep in a call chain.

The second win is z.infer: schema and TypeScript type come from one source. They cannot drift apart. That was the real hazard with hand-written type guards — that after a field change the guard checks a truth that no longer exists.

The same principle applies to security in general: in our article on IT security vulnerabilities we showed with real incidents that unvalidated input is the most common entry point of all. Types don’t replace validation — they only describe what should hold true after it.

Mapped types: building types from types

One step beyond utility types: you can build your own. Mapped types iterate over a type’s keys and produce new ones.

type Events = {
  click: { x: number; y: number };
  keypress: { key: string };
};

type Handlers = {
  [K in keyof Events as `on${Capitalize<K>}`]: (e: Events[K]) => void
};

That automatically yields a type with onClick and onKeypress fields — each with the matching event object. Access a keyboard-event field inside the click handler and the compiler says:

mapped.ts(7,57): error TS2339: Property 'key' does not exist on type '{ x: number; y: number; }'.

This is where typing genuinely pays off: adding a new event to Events automatically produces the matching handler type. No second list for anyone to forget.

A warning from practice: mapped and conditional types are where TypeScript code becomes unreadable. Our rule: a home-grown type a colleague can’t understand in thirty seconds either needs a comment or should be two simple types. Cleverness in the type system is repaid with interest during debugging.

Discriminated unions: the underrated workhorse

If we could recommend only one pattern, this would be it. A shared field with literal values turns a union into something precisely distinguishable:

type Shape =
  | { kind: "circle"; r: number }
  | { kind: "square"; s: number };

function area(sh: Shape) {
  if (sh.kind === "circle") return Math.PI * sh.r ** 2;
  return sh.s * sh.s;
}

The compiler understands the check on kind and knows which fields exist in each branch. Access without checking and you get an immediate message — including which union member lacks the field:

n.ts(9,41): error TS2339: Property 'r' does not exist on type 'Shape'.
  Property 'r' does not exist on type '{ kind: "square"; s: number; }'.

The same pattern cleanly solves the perennial result-type problem:

type Result<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

After if (res.ok) there’s data and no error, and vice versa. No data?: T forcing ! or optional chaining everywhere. Make impossible states impossible — instead of allowing them via optional fields and then checking for them everywhere.

The compiler options that actually change something

strict: true is the umbrella switch and belongs in every new project. What it buys you concretely shows in this real message:

n.ts(2,18): error TS18047: 'name' is possibly 'null'.

Without strictNullChecks (part of strict) that would have sailed through — and reappeared later as Cannot read properties of null. This one option prevents by far the most common class of bug in JavaScript.

noUncheckedIndexedAccess — the one strict doesn’t include

This is the most interesting one, because almost nobody knows it. We measured it:

const names: string[] = ["a", "b"];
const n = names[99];
console.log(n.toUpperCase());

With strict: true alone: no error. TypeScript assumes indexing a string[] yields a string — even at index 99. With the option on:

idx.ts(3,13): error TS18048: 'n' is possibly 'undefined'.

That’s the truth about JavaScript arrays, and strict doesn’t tell it by default. The cost is real: loops and lookup tables gain a lot of new undefined checks. Our recommendation: on from day one in new projects, in existing ones only with time budgeted.

OptionIncluded in strict?What it catches
strictNullChecks✅ yesnull/undefined access
noImplicitAny✅ yesForgotten parameter types
strictFunctionTypes✅ yesUnsafe callback signatures
noUncheckedIndexedAccessnoArray/object access into nothing
exactOptionalPropertyTypes❌ noExplicit undefined vs. missing
noImplicitOverride❌ noAccidentally overridden methods

Frequently asked questions

What exactly does “typing” mean in TypeScript? Typing means describing the shapes of data in your program — which fields an object has, what a function accepts and returns, which values are allowed. TypeScript checks those descriptions before execution and removes them afterwards. At runtime not a single type annotation remains.

Interface or type — which should I use? Both work equally well for object shapes. Use interface when others should be able to extend the type (library APIs, augmenting third-party types). Use type for everything else, especially unions, function signatures and derived types — unions don’t work with interfaces at all.

Why is any bad if it works? Because it disables checking not just for that value but for everything derived from it. In our test, calling a non-existent method plus assigning a string to a number variable produced zero error messages. The same code with unknown immediately reports TS18046. unknown forces clarification; any prevents it.

Do TypeScript types protect me from malformed API responses? No, and that’s the key insight of this article. We compiled a program treating an incomplete API response as a typed value: zero compiler errors, then TypeError: Cannot read properties of undefined at runtime. At system boundaries you need real checking — type guards or a schema validator like Zod.

When do I actually need generics? Whenever two places should stay linked by type: input to output, or two parameters to each other. If your type parameter appears in only one position, you don’t need it — write the concrete type there instead.

What’s the difference between satisfies and a type annotation? An annotation (const x: Cfg = …) checks and then widens the type to Cfg. satisfies checks the same constraint but keeps the concrete type. In our test withAnnotation.port.toFixed(0) failed with TS2339 while the identical line using satisfies worked.

Should I enable noUncheckedIndexedAccess? In new projects yes, from the start. It caught an access to index 99 of a two-element array that strict: true alone let through. In existing projects it produces many new messages at once — only turn it on with time set aside.

How many types are too many? When you spend more time on the type system than on the problem, you’ve gone too far. A concrete test: if a colleague can’t understand one of your types in thirty seconds, it needs a comment — or should be split into two simple ones.

Tools that help

A playground for experiments. For “what happens if I…” questions, setting up a project is too slow. Our TypeScript Playground runs right in the browser: code on the left, type errors and compiled output on the right, no installation.

The editor as the main tool. Most of the value of typing arrives while writing, not while compiling: autocomplete, go-to-definition, safe renames across files. If your types are good, the editor becomes documentation that never goes stale.

AI assistants, carefully. They’re good at generating a Zod schema from a sample response or explaining a mapped type. They’re bad at knowing current version state — see our roundup of the best AI coding tools. Always check suggestions about newer language features against actual compiler output.

Framework context. For what typing looks like inside a concrete framework — dynamic routes and their parameters, for instance — see our worked example in Next.js Dynamic Routes.

Conclusion: types are a tool, not a goal

Good TypeScript typing isn’t recognizable by how clever the types are, but by the fact that nobody talks about them anymore. The constructs that deliver the most value are unspectacular: discriminated unions, a few utility types instead of copied declarations, unknown instead of any, and real validation at the three system boundaries.

The thing we’d hand to everyone is the uncomfortable one: a green compiler doesn’t prove your data has the shape you claimed. It proves your code is internally consistent. Between those two statements lies every TypeError we genuinely produced in this article — including from the program that proudly printed Type checking says: all good right before it crashed.

If you change exactly two things after reading: replace any with unknown and resolve the errors that surface. Then write a schema for the one API response your code trusts most blindly. Those two moves catch more production bugs than every conditional type you will ever write.