Skip to main content
TypeScript · lesson 11 of 21
TypeScript advanced Lesson 11 of 21

Advanced 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.

Frequently Asked Questions

What are template literal types good for?
Template literal types let you construct string types programmatically. They are useful for typed event names, CSS property names, API route paths, and any string that follows a predictable pattern.
What is type variance in TypeScript?
Variance describes how subtype relationships carry through generic types. Covariance means if A extends B, then Container<A> extends Container<B>. Contravariance is the reverse. TypeScript infers variance but you can annotate it explicitly with in and out.
What does the satisfies operator do?
satisfies validates that a value matches a type without widening the value's inferred type. It gives you validation without losing the precise literal types that make autocomplete and narrowing work.
Are TypeScript arrays type-safe under variance?
Not completely. Mutable arrays are treated as covariant, so a Dog[] can be passed where an Animal[] is expected, and the function can then push a Cat into it without a compile error. Accepting ReadonlyArray<Animal> in functions that only read closes that hole.
Do complex type-level computations slow down the compiler?
They can. Deeply recursive conditional types and template literal types that expand into large unions cost check time, and TypeScript stops with an error when a union would exceed 100,000 members or recursion gets too deep. Keep type-level code shallow and measure with tsc --extendedDiagnostics.