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
unknownand verify it before use - Set up a tsconfig.json with sensible strictness for a Node project and a bundled web app
On this page
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 - 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.
// 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.
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.
{
"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"]
}{
"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"]
}| Option | What it does | Why it matters |
|---|---|---|
strict | Turns on null checks, noImplicitAny and related checks | The single most valuable switch |
noUncheckedIndexedAccess | Makes arr[i] and record[key] possibly undefined | Catches out-of-range reads; const first: string = list[0] becomes an error (Type 'string | undefined' is not assignable to type 'string') |
noImplicitReturns | Every code path must return a value | Reports Not all code paths return a value. |
skipLibCheck | Skips type-checking of .d.ts files in dependencies | Faster builds and fewer errors from third-party typings |
isolatedModules | Requires each file to be compilable alone | Needed by most bundlers and transpilers |
moduleResolution | How 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 Profilecompiles no matter what the request contains. Narrow fromunknownor validate with a schema. - A type guard that lies. If
isStringArrayreturnedtruefor 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. Usereadonly string[]orReadonlyArrayfor the inner collections too. - Reading `err.message` in a `catch`. Under
strictthat is'err' is of type 'unknown'. Narrow withinstanceof Errorfirst. - 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 moduleor an ESM/CommonJS error even though the file exists. Align both with yourpackage.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 useasto avoid checking. - Treat anything from outside (JSON, requests,
catchvariables) asunknownuntil verified. - Start with
strict; addnoUncheckedIndexedAccessandnoImplicitReturnswhen you are ready for more safety. - Pick
NodeNextfor Node projects andbundlerplusnoEmitfor bundled apps, and verify withtsc --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.
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.