TypeScript · lesson 11 of 21
Beginner
6Intermediate
12- 7 Type Aliases in TypeScript
- 8 Classes in TypeScript
- 9 Generics in TypeScript
- 10 Utility Types in TypeScript
- 13 Modules and Declaration Files in TypeScript
- 14 Error Handling in TypeScript
- 15 Async TypeScript
- 16 Testing TypeScript
- 17 Design Patterns in TypeScript
- 18 React with TypeScript
- 19 Node.js with TypeScript
- 21 TypeScript Interview Prep
Also part of TypeScript
2Advanced Types in TypeScript
Template literal types, recursive DeepPartial, variance and in/out, satisfies, variadic tuples and key remapping — each shown with real tsc and tsx output.
By SkillByExample · Updated
Examples run on TypeScript 6.0.3 and Node 22, each as a standalone file in a folder whose package.json has "type": "module". npx tsx file.ts runs a file; npx tsc --noEmit --module nodenext file.ts type-checks it. TypeScript 6 is strict by default — on 5.x add --strict to get the same errors.
Several examples reveal a computed type by assigning a deliberately wrong value to it: the resulting tsc error spells out what the type resolved to.
Template Literal Types
A union inside a template literal type expands to every combination, and infer can pull segments back out of a string type.
type Color = "red" | "green" | "blue";
type Shade = "light" | "dark";
type ColorShade = `${Shade}-${Color}`;
type RouteParams<S extends string> =
S extends `${string}:${infer P}/${infer Rest}` ? P | RouteParams<`/${Rest}`>
: S extends `${string}:${infer P}` ? P
: never;
const shades: Record<ColorShade, string> = {};
const params: Record<RouteParams<"/users/:userId/posts/:postId">, string> = {};
$ npx tsc --noEmit --module nodenext template.ts
template.ts(10,7): error TS2740: Type '{}' is missing the following properties from type 'Record<"light-red" | "light-green" | "light-blue" | "dark-red" | "dark-green" | "dark-blue", string>': "light-red", "light-green", "light-blue", "dark-red", and 2 more.
template.ts(11,7): error TS2739: Type '{}' is missing the following properties from type 'Record<"userId" | "postId", string>': userId, postId
Two shades times three colours gives six members. RouteParams peels off one :param at a time and unions the results. Both are pure type-level computation — there’s no runtime code here at all.
Recursive Types: DeepPartial
Partial only affects the top level. A recursive conditional mapped type applies it all the way down, which is what a deep-merge of config overrides needs.
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
interface AppConfig {
database: { host: string; port: number; credentials: { user: string; password: string } };
server: { port: number; ssl: boolean };
}
function merge<T>(base: T, overrides: DeepPartial<T>): T {
const out: any = { ...base };
for (const [key, value] of Object.entries(overrides as object)) {
out[key] = value && typeof value === "object" ? merge(out[key], value) : value;
}
return out;
}
const defaults: AppConfig = {
database: { host: "localhost", port: 5432, credentials: { user: "app", password: "dev" } },
server: { port: 3000, ssl: false },
};
const prod = merge(defaults, { database: { credentials: { password: "s3cret" } }, server: { ssl: true } });
console.log(JSON.stringify(prod));
const shallow: Partial<AppConfig> = { database: { host: "db.internal" } };
$ npx tsc --noEmit --module nodenext deep-partial.ts
deep-partial.ts(24,39): error TS2739: Type '{ host: string; }' is missing the following properties from type '{ host: string; port: number; credentials: { user: string; password: string; }; }': port, credentials
With plain Partial, database is optional but, once given, must be complete. DeepPartial allowed { credentials: { password } } three levels down. The any inside merge is contained: callers only see T and DeepPartial<T>. Remove the last line:
$ npx tsx deep-partial.ts
{"database":{"host":"localhost","port":5432,"credentials":{"user":"app","password":"s3cret"}},"server":{"port":3000,"ssl":true}}
Every default survived except the two overridden leaves.
Variance
If Dog is a subtype of Animal, is Box<Dog> a subtype of Box<Animal>? It depends on how Box uses its type parameter. A producer (() => T) is covariant; a consumer ((t: T) => void) is contravariant.
interface Animal { name: string }
interface Dog extends Animal { bark(): string }
type Producer<T> = () => T;
type Consumer<T> = (t: T) => void;
const dogProducer: Producer<Dog> = () => ({ name: "Rex", bark: () => "Woof" });
const animalConsumer: Consumer<Animal> = (a) => console.log(a.name);
const p: Producer<Animal> = dogProducer;
const c: Consumer<Dog> = animalConsumer;
const p2: Producer<Dog> = () => ({ name: "Generic animal" });
const c2: Consumer<Animal> = (d: Dog) => console.log(d.bark());
$ npx tsc --noEmit --module nodenext variance.ts
variance.ts(13,33): error TS2741: Property 'bark' is missing in type '{ name: string; }' but required in type 'Dog'.
variance.ts(14,7): error TS2322: Type '(d: Dog) => void' is not assignable to type 'Consumer<Animal>'.
Types of parameters 'd' and 't' are incompatible.
Property 'bark' is missing in type 'Animal' but required in type 'Dog'.
The two assignments that go “with” the direction compile: something that produces dogs produces animals, and something that can consume any animal can consume a dog. The last two go against it: a producer of plain animals can’t promise dogs, and a dog-only consumer would call bark() on a cat.
Mutable arrays both produce and consume, so strictly they should be neither — but TypeScript treats them as covariant for convenience. That’s a real hole:
interface Animal { name: string }
interface Dog extends Animal { bark(): string }
function addCat(animals: Animal[]): void {
animals.push({ name: "Tom" });
}
const dogs: Dog[] = [{ name: "Rex", bark: () => "Woof" }];
addCat(dogs);
for (const dog of dogs) {
console.log(dog.name, dog.bark());
}
$ npx tsc --noEmit --module nodenext array-variance.ts
$ npx tsx array-variance.ts 2>&1 | head -6
Rex Woof
/root/ts/advanced-types/array-variance.ts:12
console.log(dog.name, dog.bark());
^
TypeError: dog.bark is not a function
A clean type check, then a crash: dogs now contains a cat. If addCat only needed to read, typing its parameter readonly Animal[] would make push a compile error inside it. For your own generic types, in and out annotations declare the intended variance and make the compiler verify it:
interface Getter<out T> {
get(): T;
}
interface Setter<in T> {
set(value: T): void;
}
interface Broken<out T> {
set: (value: T) => void;
}
interface AlsoBroken<out T> {
set(value: T): void;
}
$ npx tsc --noEmit --module nodenext annotations.ts
annotations.ts(9,18): error TS2636: Type 'Broken<sub-T>' is not assignable to type 'Broken<super-T>' as implied by variance annotation.
Types of property 'set' are incompatible.
Type '(value: sub-T) => void' is not assignable to type '(value: super-T) => void'.
Types of parameters 'value' and 'value' are incompatible.
Type 'super-T' is not assignable to type 'sub-T'.
Broken claims to be covariant but consumes T in a parameter, so the annotation is rejected. AlsoBroken is exactly as wrong and gets no error: it uses method syntax, and method parameters are checked bivariantly, so the compiler accepts either direction. The same trade-off shows up in the interfaces lesson — write function-typed properties when you want variance checked.
The satisfies Operator
A type annotation checks a value and replaces its inferred type. satisfies checks it and keeps the inferred type.
type RGB = [number, number, number];
type ColorMap = Record<string, string | RGB>;
const annotated: ColorMap = { red: [255, 0, 0], green: "#00ff00" };
const checked = { red: [255, 0, 0], green: "#00ff00" } satisfies ColorMap;
console.log(checked.green.toUpperCase(), checked.red.map((n) => n / 255));
console.log(annotated.green.toUpperCase());
console.log(checked.blue);
const invalid = { red: [255, 0] } satisfies ColorMap;
$ npx tsc --noEmit --module nodenext satisfies.ts
satisfies.ts(8,29): error TS2339: Property 'toUpperCase' does not exist on type 'string | RGB'.
Property 'toUpperCase' does not exist on type 'RGB'.
satisfies.ts(9,21): error TS2339: Property 'blue' does not exist on type '{ red: [number, number, number]; green: string; }'.
satisfies.ts(11,19): error TS2322: Type '[number, number]' is not assignable to type 'string | RGB'.
Type '[number, number]' is not assignable to type 'RGB'.
Source has 2 element(s) but target requires 3.
Through the annotation, annotated.green is only string | RGB, so calling toUpperCase needs a check. Through satisfies, checked.green is known to be a string and checked.red a tuple — and the object’s keys are known exactly, so the misspelled blue is caught too. The value is still validated: a two-element colour fails. Remove the three bad lines:
$ npx tsx satisfies.ts
#00FF00 [ 1, 0, 0 ]
Variadic Tuple Types
...T inside a tuple type spreads another tuple, so a wrapper can add parameters without forgetting the original ones.
function withLabel<Args extends unknown[], R>(fn: (...args: Args) => R) {
return (label: string, ...args: Args): R => {
const result = fn(...args);
console.log(`[${label}]`, ...args, "->", result);
return result;
};
}
const add = (a: number, b: number) => a + b;
const greet = (name: string, excited = false) => `Hi ${name}${excited ? "!" : "."}`;
const loggedAdd = withLabel(add);
const loggedGreet = withLabel(greet);
loggedAdd("sum", 1, 2);
loggedGreet("greet", "Ada");
loggedGreet("greet", "Ada", true);
loggedAdd("sum", 1, "2");
$ npx tsc --noEmit --module nodenext variadic.ts
variadic.ts(18,21): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
Args captured add’s parameter list as [a: number, b: number] and greet’s as [name: string, excited?: boolean], optional element included. Remove the bad call:
$ npx tsx variadic.ts
[sum] 1 2 -> 3
[greet] Ada -> Hi Ada.
[greet] Ada true -> Hi Ada!
Key Remapping and String Intrinsics
Uppercase, Lowercase, Capitalize and Uncapitalize are built into the compiler. Combined with recursive template literal types and as in a mapped type, they can type a snake_case to camelCase conversion.
type SnakeToCamel<S extends string> =
S extends `${infer Head}_${infer Tail}` ? `${Head}${Capitalize<SnakeToCamel<Tail>>}` : S;
type CamelKeys<T> = { [K in keyof T & string as SnakeToCamel<K>]: T[K] };
function camelize<T extends object>(row: T): CamelKeys<T> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row)) {
out[key.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())] = value;
}
return out as CamelKeys<T>;
}
const row = { user_id: 7, first_name: "Ada", created_at: "2026-09-30" };
const user = camelize(row);
console.log(user, user.firstName.toUpperCase());
console.log(user.first_name);
$ npx tsc --noEmit --module nodenext camel.ts
camel.ts(17,18): error TS2551: Property 'first_name' does not exist on type 'CamelKeys<{ user_id: number; first_name: string; created_at: string; }>'. Did you mean 'firstName'?
The type-level SnakeToCamel and the runtime regex have to agree — the as cast is where you promise they do. With the bad line removed:
$ npx tsx camel.ts
{ userId: 7, firstName: 'Ada', createdAt: '2026-09-30' } ADA
A Worked Example: A Typed Query Builder
Mapping each table to its columns lets the builder reject unknown columns, and a template literal type checks where clauses.
type Columns = {
users: "id" | "name" | "email";
posts: "id" | "title" | "authorId";
};
type Op = "=" | ">" | "<" | "!=";
class Query<T extends keyof Columns> {
#cols: string[] = ["*"];
#where: string[] = [];
constructor(private table: T) {}
select(...cols: Columns[T][]): this {
this.#cols = cols;
return this;
}
where(condition: `${Columns[T]} ${Op} ?`): this {
this.#where.push(condition);
return this;
}
build(): string {
const where = this.#where.length ? ` WHERE ${this.#where.join(" AND ")}` : "";
return `SELECT ${this.#cols.join(", ")} FROM ${this.table}${where}`;
}
}
console.log(new Query("users").select("id", "email").where("name = ?").build());
console.log(new Query("posts").where("authorId > ?").where("id != ?").build());
new Query("posts").select("email");
new Query("users").where("name LIKE ?");
new Query("orders");
$ npx tsc --noEmit --module nodenext query.ts
query.ts(28,27): error TS2345: Argument of type '"email"' is not assignable to parameter of type '"id" | "title" | "authorId"'.
query.ts(29,26): error TS2345: Argument of type '"name LIKE ?"' is not assignable to parameter of type '"name = ?" | "id = ?" | "email = ?" | "name > ?" | "id > ?" | "email > ?" | "name < ?" | "id < ?" | "email < ?" | "name != ?" | "id != ?" | "email != ?"'.
query.ts(30,11): error TS2345: Argument of type '"orders"' is not assignable to parameter of type 'keyof Columns'.
T is inferred from the table name, and Columns[T] then narrows select and where to that table’s columns. Remove the three bad lines:
$ npx tsx query.ts
SELECT id, email FROM users WHERE name = ?
SELECT * FROM posts WHERE authorId > ? AND id != ?
The template literal only checks the shape of the condition — it’s still a string with a ? placeholder, and binding the values safely is the database driver’s job.
Practice
1. Make addCat in array-variance.ts safe by typing its parameter as read-only. What does tsc now report?
Solution
interface Animal { name: string }
interface Dog extends Animal { bark(): string }
function addCat(animals: readonly Animal[]): void {
animals.push({ name: "Tom" });
}
function names(animals: readonly Animal[]): string {
return animals.map((a) => a.name).join(", ");
}
const dogs: Dog[] = [{ name: "Rex", bark: () => "Woof" }];
console.log(names(dogs));
addCat(dogs);
$ npx tsc --noEmit --module nodenext practice-1.ts
practice-1.ts(5,11): error TS2339: Property 'push' does not exist on type 'readonly Animal[]'.
The mutation is now rejected inside the function, while read-only uses such as names still accept a Dog[].
2. Use satisfies to type a routes object as Record<string, { path: string; auth: boolean }> while keeping the exact keys, then access a route that doesn’t exist.
Solution
type Route = { path: string; auth: boolean };
const routes = {
home: { path: "/", auth: false },
profile: { path: "/profile", auth: true },
} satisfies Record<string, Route>;
const protectedPaths = Object.values(routes).filter((r) => r.auth).map((r) => r.path);
console.log(protectedPaths, routes.home.path);
console.log(routes.settings);
$ npx tsc --noEmit --module nodenext practice-2.ts
practice-2.ts(10,20): error TS2339: Property 'settings' does not exist on type '{ home: { path: string; auth: false; }; profile: { path: string; auth: true; }; }'.
Annotating routes: Record<string, Route> would make routes.settings compile — any string key is allowed — and it would be undefined at runtime.
3. Write type CamelToSnake<S extends string> and check it on "userFirstName".
Solution
type CamelToSnake<S extends string> =
S extends `${infer C}${infer Rest}`
? `${C extends Uppercase<C> ? (C extends Lowercase<C> ? C : `_${Lowercase<C>}`) : C}${CamelToSnake<Rest>}`
: S;
const ok: CamelToSnake<"userFirstName"> = "user_first_name";
const probe: CamelToSnake<"userFirstName"> = "userFirstName";
console.log(ok, probe);
$ npx tsc --noEmit --module nodenext practice-3.ts
practice-3.ts(7,7): error TS2322: Type '"userFirstName"' is not assignable to type '"user_first_name"'.
It walks one character at a time. A character that is unchanged by Uppercase but changed by Lowercase is an upper-case letter, so it gets _ and is lowered; digits and _ are unchanged by both and pass through.