Skip to main content
PProfileMatch

TypeScript Narrowing, Utility Types and tsconfig.json Setup

ProgrammingLesson 4 of 46 min read

What you'll learn

  • Narrow broad types with typeof, in, instanceof, equality checks and custom type guards
  • Derive new types with Partial, Pick, Omit, Record, Readonly, ReturnType and Awaited
  • Handle untrusted input as unknown and verify it before use
  • Set up a tsconfig.json with sensible strictness for a Node project and a bundled web app

On this page

  1. Deriving types instead of copying them
  2. Narrowing: from a broad type to the one you need
  3. A sensible tsconfig.json
  4. Common mistakes and the errors they produce
  5. How this shows up in real projects and interviews

A job site lets members edit their profile: change the headline, add skills, switch "open to work" on. The update endpoint receives JSON, and the author wants three guarantees: the member ID can never be changed, only known fields are accepted, and anything coming from the request is checked before it touches the database. Each guarantee maps to a TypeScript feature in this lesson: utility types for the first two, and narrowing for the third.

Deriving types instead of copying them

A utility type is a built-in generic that transforms an existing type. They keep a single source of truth: change Profile once and every derived type follows. The ones you will reach for most are Partial<T> (all properties optional), Required<T> (all required), Readonly<T> (all read-only), Pick<T, K> (keep only some properties), Omit<T, K> (drop some), Record<K, V> (an object with keys K and values V), ReturnType<F> (what a function returns) and Awaited<T> (what a promise resolves to).

profile.ts
// profile.ts - applying a profile update safely
interface Profile {
  id: number;
  name: string;
  headline: string;
  email: string;
  skills: string[];
  openToWork: boolean;
}

type ProfileUpdate = Partial<Omit<Profile, "id">>;          // anything but id, all optional
type PublicProfile = Pick<Profile, "id" | "name" | "headline" | "skills">;
type EditableField = keyof ProfileUpdate;
type FieldErrors = Partial<Record<EditableField, string>>;

const current: Readonly<Profile> = {
  id: 5,
  name: "Dana Whitfield",
  headline: "Data analyst",
  email: "dana@example.com",
  skills: ["SQL", "Python"],
  openToWork: false,
};

function applyUpdate(base: Profile, update: ProfileUpdate): Profile {
  return { ...base, ...update };
}

function toPublic(p: Profile): PublicProfile {
  const { id, name, headline, skills } = p;
  return { id, name, headline, skills };
}

function validate(update: ProfileUpdate): FieldErrors {
  const errors: FieldErrors = {};
  if (update.headline !== undefined && update.headline.length > 60) {
    errors.headline = "Keep it under 60 characters";
  }
  if (update.email !== undefined && !update.email.includes("@")) {
    errors.email = "Not an email address";
  }
  return errors;
}

const update: ProfileUpdate = { headline: "Senior data analyst", openToWork: true };
console.log(validate(update));
console.log(validate({ email: "dana.example.com" }));
console.log(toPublic(applyUpdate(current, update)));

Output

{}
{ email: 'Not an email address' }
{
  id: 5,
  name: 'Dana Whitfield',
  headline: 'Senior data analyst',
  skills: [ 'SQL', 'Python' ]
}

ProfileUpdate makes the first guarantee a compile-time fact: { id: 3 } is not a valid update because id was omitted from the type. keyof ProfileUpdate produces the union of editable field names, and Record<EditableField, string> wrapped in Partial gives an error map that can only use real field names. If you later add location to Profile, all four derived types update automatically. One caution on Readonly: it is shallow, so current.name = "x" is rejected but current.skills.push("Go") is allowed.

Narrowing: from a broad type to the one you need

TypeScript tracks what checks you have already done. After if (typeof x === "string") it treats x as a string inside the block. The checks it understands include typeof, equality against null or undefined, truthiness, instanceof, the in operator for object shapes, and a literal property for discriminated unions (last lesson). When none of those fit, you write a type guard: a function whose return type is a *type predicate*, like value is string[]. The compiler trusts your predicate, so keep its logic honest.

narrowing.ts
// the request body arrives as unknown: do not trust it
function isStringArray(value: unknown): value is string[] {
  return Array.isArray(value) && value.every((v) => typeof v === "string");
}

function readSkills(raw: unknown): string[] {
  if (typeof raw === "string") return raw.split(",").map((s) => s.trim());
  if (isStringArray(raw)) return raw;
  throw new TypeError("skills must be a string or an array of strings");
}
console.log(readSkills("SQL, Python ,dbt"));
console.log(readSkills(["Go"]));
try {
  readSkills(42);
} catch (err) {
  if (err instanceof TypeError) console.log("TypeError:", err.message);
}

function describeContact(c: { phone: string } | { email: string }): string {
  return "phone" in c ? `call ${c.phone}` : `write ${c.email}`;
}
console.log(describeContact({ phone: "555-0100" }), "|", describeContact({ email: "a@b.co" }));

function label(x: string | number | null): string {
  if (x === null) return "none";
  if (typeof x === "number") return x.toFixed(1);
  return x.toUpperCase();
}
console.log(label(null), label(2), label("ok"));

Output

[ 'SQL', 'Python', 'dbt' ]
[ 'Go' ]
TypeError: skills must be a string or an array of strings
call 555-0100 | write a@b.co
none 2.0 OK

Notice the catch clause. Under strict, the caught value err has type unknown, because anything can be thrown in JavaScript, so you narrow it with instanceof before reading .message. The same discipline applies to JSON.parse, which returns any and is best assigned to unknown first. For real validation of a request body, many teams use a schema library so that the runtime check and the TypeScript type come from one definition.

The block below is wrong on purpose. The messages are real TypeScript 5.9 output.

utility_errors.ts (does not compile)
const p: Readonly<Profile> = { id: 1, name: "a", email: "e", skills: [] /* ... */ };
p.name = "b";                                                 // (1)
const upd: Partial<Omit<Profile, "id">> = { id: 3 };           // (2)
type Pub = Pick<Profile, "id" | "nick">;                      // (3)
const names: Record<"a" | "b", number> = { a: 1 };            // (4)

function readIt(raw: unknown) {
  return raw.toUpperCase();                                   // (5)
}
try { throw new Error("x"); } catch (err) { console.log(err.message); }   // (6)

Output

(1) Cannot assign to 'name' because it is a read-only property.
(2) Object literal may only specify known properties, and 'id' does not exist in type 'Partial<Omit<Profile, "id">>'.
(3) Type '"id" | "nick"' does not satisfy the constraint 'keyof Profile'.
      Type '"nick"' is not assignable to type 'keyof Profile'.
(4) Property 'b' is missing in type '{ a: number; }' but required in type 'Record<"a" | "b", number>'.
(5) 'raw' is of type 'unknown'.
(6) 'err' is of type 'unknown'.

A sensible tsconfig.json

tsconfig.json tells the compiler which files to include and how strict to be. Two shapes cover most projects. For a Node program that you compile with tsc, use the Node-aware module settings. For code that a bundler (Vite, webpack, Next.js) processes, the bundler does the emitting, so TypeScript only type-checks and you set noEmit.

tsconfig.json (Node, compiled with tsc)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true,
    "rootDir": "src",
    "outDir": "dist",
    "sourceMap": true
  },
  "include": ["src"]
}
tsconfig.json (bundled web app, type-check only)
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "module": "esnext",
    "moduleResolution": "bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "noEmit": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src"]
}
OptionWhat it doesWhy it matters
strictTurns on null checks, noImplicitAny and related checksThe single most valuable switch
noUncheckedIndexedAccessMakes arr[i] and record[key] possibly undefinedCatches out-of-range reads; const first: string = list[0] becomes an error (Type 'string | undefined' is not assignable to type 'string')
noImplicitReturnsEvery code path must return a valueReports Not all code paths return a value.
skipLibCheckSkips type-checking of .d.ts files in dependenciesFaster builds and fewer errors from third-party typings
isolatedModulesRequires each file to be compilable aloneNeeded by most bundlers and transpilers
moduleResolutionHow imports are found (NodeNext or bundler)A mismatch causes Cannot find module errors for valid imports

Common mistakes and the errors they produce

  • Asserting instead of narrowing. const body = req.body as Profile compiles no matter what the request contains. Narrow from unknown or validate with a schema.
  • A type guard that lies. If isStringArray returned true for any array, the compiler would trust it and the crash would move later. Test guards like normal code.
  • Expecting `Readonly` to be deep. current.skills.push("Go") still compiles. Use readonly string[] or ReadonlyArray for the inner collections too.
  • Reading `err.message` in a `catch`. Under strict that is 'err' is of type 'unknown'. Narrow with instanceof Error first.
  • Reaching for `Partial` everywhere. A Partial<Profile> passed to code expecting a full profile hides missing data. Use it for updates, not for domain objects.
  • Mismatched `module` and `moduleResolution`, or a CommonJS package using ESM `import` under `NodeNext`. You will see Cannot find module or an ESM/CommonJS error even though the file exists. Align both with your package.json "type".

How this shows up in real projects and interviews

In production codebases the work is rarely writing exotic types; it is deriving types from one source (Omit, Pick, ReturnType<typeof fn>), narrowing external data, and keeping the config strict enough to matter. A migration from JavaScript usually goes file by file with allowJs and rising strictness, which is a realistic thing to discuss in an interview. Common questions: "what is the difference between Partial and Pick", "how do you type a catch variable", "type assertion versus type guard". When you move on, apply all of this in React props and state, or in a typed Node.js API, and browse open roles to see how often TypeScript appears in requirements.

Try it yourself

Define an Order interface with id, customer, items: { sku: string; qty: number }[] and status: "new" | "paid" | "shipped". Derive NewOrder (no id, no status), OrderSummary (only id and status) and StatusCount (a Record from status to number). Write parseOrder(raw: unknown): NewOrder that checks the shape without using as, and throws a TypeError with a helpful message otherwise.

Show a hint

First check typeof raw === "object" && raw !== null, then use "customer" in raw and typeof raw.customer === "string". Write a small isItem(x: unknown): x is { sku: string; qty: number } guard and use Array.isArray(raw.items) && raw.items.every(isItem).

Key takeaways

  • Utility types (Partial, Pick, Omit, Record, Readonly, ReturnType, Awaited) derive new types so there is one source of truth.
  • Narrow with typeof, in, instanceof, equality checks and custom type predicates; never use as to avoid checking.
  • Treat anything from outside (JSON, requests, catch variables) as unknown until verified.
  • Start with strict; add noUncheckedIndexedAccess and noImplicitReturns when you are ready for more safety.
  • Pick NodeNext for Node projects and bundler plus noEmit for bundled apps, and verify with tsc --noEmit.

Test yourself

A trick from this lesson's ride: Read utility types inside-out: Omit drops first, then Partial relaxes Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between Partial, Pick and Omit in TypeScript?

Partial<T> makes every property of T optional. Pick<T, K> creates a new type containing only the listed properties, and Omit<T, K> creates one containing everything except the listed properties. They are often combined, such as Partial<Omit<Profile, "id">> for an update payload.

What is a type guard in TypeScript?

A type guard is a check that tells the compiler a value is a narrower type inside a branch. Built-in ones are typeof, instanceof and in. You can also write a function returning a type predicate, such as value is string[], but the compiler trusts it, so its logic must be correct.

What should a good tsconfig.json include?

At minimum "strict": true, a modern target, and module settings that match your runtime or bundler (NodeNext for Node, bundler for Vite or Next.js). Many teams add noUncheckedIndexedAccess and skipLibCheck. Start from your framework's generated config and tighten it gradually.