Advanced Types in TypeScript
Explore template literal types, recursive types, variance, and the satisfies operator for precise, expressive TypeScript type definitions.
Template Literal Types
Template literal types bring JavaScript’s template literal syntax to the type level, letting you construct new string types by combining existing ones. They’re particularly valuable when string structure carries semantic meaning — event names, CSS properties, API routes — because they let TypeScript validate string patterns rather than just accepting any string.
type EventName = "click" | "focus" | "blur" | "change";
// Capitalize each event name and prefix with "on" — producing a cross product
type Handler = `on${Capitalize<EventName>}`;
// "onClick" | "onFocus" | "onBlur" | "onChange"
type Direction = "top" | "right" | "bottom" | "left";
type Padding = `padding${Capitalize<Direction>}`;
// "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft"
Combining multiple union types creates a cross product of all combinations:
type Color = "red" | "green" | "blue";
type Shade = "light" | "dark";
type ColorShade = `${Shade}-${Color}`;
// "light-red" | "light-green" | "light-blue" | "dark-red" | "dark-green" | "dark-blue"
A practical use — typed CSS-in-TS where invalid units are caught at compile time:
type CSSUnit = "px" | "em" | "rem" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
interface StyleRule {
width?: CSSValue;
height?: CSSValue;
fontSize?: CSSValue;
margin?: CSSValue;
}
const style: StyleRule = {
width: "100%", // fine
height: "50vh", // fine
fontSize: "16px", // fine
margin: "auto", // Error: "auto" is not a valid CSSValue
};
Using infer inside a template literal type lets you extract named segments from a path string — the foundation of typed routing libraries:
// Recursively extracts :param names from a URL path string
type ExtractRouteParams<S extends string> =
S extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractRouteParams<`/${Rest}`>
: S extends `${string}:${infer Param}`
? Param
: never;
type Params = ExtractRouteParams<"/users/:userId/posts/:postId">;
// "userId" | "postId"
Recursive Types
Types can reference themselves to model recursive data structures precisely. Without recursive types you’d fall back to any or object for nested data — losing all safety. TypeScript handles self-referential types correctly as long as there’s a non-recursive base case.
// Models any valid JSON structure — primitives, arrays, or nested objects
type JSONPrimitive = string | number | boolean | null;
type JSONValue = JSONPrimitive | JSONValue[] | { [key: string]: JSONValue };
// Deeply nested structure — all valid
const data: JSONValue = {
users: [
{ name: "Alice", scores: [95, 87], metadata: null },
{ name: "Bob", active: true },
],
};
A recursive mapped type — DeepPartial makes every field at every nesting level optional. This is useful for deeply nested configuration or patch payloads:
// Recursively applies Partial to every nested object
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;
};
}
type PartialConfig = DeepPartial<AppConfig>;
// Every field at every level is now optional — useful for config merging
Variance
Variance describes how subtype relationships transfer through generic types — whether Container<Dog> can be used where Container<Animal> is expected. Getting this right matters when building libraries and prevents subtle type-safety holes.
Covariance — a generic is covariant when it only produces values of type T (read-only). If Dog extends Animal, then ReadonlyArray<Dog> is assignable to ReadonlyArray<Animal> because every dog is an animal:
function makeNoise(animals: ReadonlyArray<Animal>): void {
animals.forEach((a) => a.speak());
}
const dogs: Dog[] = [new Dog("Rex")];
makeNoise(dogs); // fine — covariant, Dog[] is a subtype of ReadonlyArray<Animal>
Contravariance — a generic is contravariant when it only consumes values of type T (write-only). Function parameters are contravariant: a handler that accepts any Animal is safe to use where a handler that accepts only Dog is expected — it can handle everything a dog handler would receive, and more:
type AnimalHandler = (animal: Animal) => void;
type DogHandler = (dog: Dog) => void;
let animalHandler: AnimalHandler = (a) => a.speak();
let dogHandler: DogHandler = animalHandler; // fine — contravariant
// An AnimalHandler is more general — it can safely handle any Dog
TypeScript 4.7 introduced explicit variance annotations for generic type parameters, which document intent and let TypeScript catch violations:
type Provider<out T> = () => T; // covariant — only produces T
type Consumer<in T> = (t: T) => void; // contravariant — only consumes T
The satisfies Operator
satisfies (introduced in TypeScript 4.9) validates that a value conforms to a type without changing the value’s inferred type. The problem it solves: annotating a variable with a type gives you validation but widens the inferred type, losing specific information you might need. satisfies gives you the validation without the widening.
type ColorMap = Record<string, string | [number, number, number]>;
// With a type annotation — palette is typed as ColorMap, losing the specific types
const palette: ColorMap = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
};
palette.red.toUpperCase(); // Error — TypeScript only knows it's string | number[]
// With satisfies — validated against ColorMap, but inferred types are preserved
const palette = {
red: [255, 0, 0],
green: "#00ff00",
blue: [0, 0, 255],
} satisfies ColorMap;
palette.red; // [number, number, number] — TypeScript knows it's a tuple
palette.green; // string
palette.green.toUpperCase(); // fine — TypeScript knows green is specifically a string
Another use — validating a config object while preserving literal types for autocomplete and narrowing:
interface Route {
path: string;
component: string;
auth: boolean;
}
const routes = {
home: { path: "/", component: "Home", auth: false },
profile: { path: "/profile", component: "Profile", auth: true },
settings: { path: "/settings", component: "Settings", auth: true },
} satisfies Record<string, Route>;
routes.home.path; // "/" — the literal type, not just string
routes.profile.auth; // true — the literal type, not just boolean
routes.unknown; // Error — key doesn't exist in the object
Opaque / Branded Types
TypeScript uses structural typing — two types with the same shape are interchangeable. This means a UserId and a PostId that are both number can be mixed up without any error. Branded types add a phantom property to create nominal-like behavior, making distinct IDs or validated strings incompatible even though their underlying types are the same.
// The __brand property exists only at the type level — it's never actually set at runtime
type UserId = number & { readonly __brand: "UserId" };
type PostId = number & { readonly __brand: "PostId" };
// Constructor functions cast to the branded type after any necessary validation
function createUserId(id: number): UserId {
return id as UserId;
}
function createPostId(id: number): PostId {
return id as PostId;
}
function getUser(id: UserId): User { return {} as User; }
const userId = createUserId(1);
const postId = createPostId(42);
getUser(userId); // fine
getUser(postId); // Error — PostId is not assignable to UserId
getUser(42); // Error — plain number is not assignable to UserId
Variadic Tuple Types
TypeScript 4.0 added variadic tuple types — the ability to spread one tuple type into another. This lets you build type-safe higher-order functions that prepend, append, or compose argument lists without losing their individual types.
// Concatenate two tuple types into one
type Concat<T extends unknown[], U extends unknown[]> = [...T, ...U];
type A = Concat<[string, number], [boolean]>;
// [string, number, boolean]
// A function wrapper that prepends a label argument while preserving the original signature
function addLogging<T extends unknown[], R>(
fn: (...args: T) => R
): (label: string, ...args: T) => R {
return (label, ...args) => {
console.log(`[${label}]`, args);
return fn(...args);
};
}
const add = (a: number, b: number) => a + b;
const loggedAdd = addLogging(add);
loggedAdd("sum", 1, 2); // fine — TypeScript knows args are (string, number, number)
loggedAdd("sum", 1, "wrong"); // Error — second numeric arg must be number
Intrinsic String Manipulation Types
TypeScript provides four built-in types for transforming string literal types. These are implemented in the compiler itself rather than as library code, so they work on arbitrary string types including type parameters.
type U = Uppercase<"hello">; // "HELLO"
type L = Lowercase<"WORLD">; // "world"
type C = Capitalize<"hello">; // "Hello"
type UC = Uncapitalize<"Hello">; // "hello"
A practical use — converting snake_case API field names to camelCase TypeScript property names:
// Recursively converts snake_case to camelCase at the type level
type SnakeToCamel<S extends string> =
S extends `${infer Head}_${infer Tail}`
? `${Head}${Capitalize<SnakeToCamel<Tail>>}`
: S;
type Camel = SnakeToCamel<"user_first_name">; // "userFirstName"
type Camel2 = SnakeToCamel<"created_at">; // "createdAt"
Practical Example: A Typed Query Builder
This example pulls together template literal types, generic constraints, and mapped types to build a query builder where invalid table names, column names, and where-clause operators are all caught at compile time — not at runtime.
type Table = "users" | "posts" | "comments";
// Each table has its own set of valid columns
type ColumnMap = {
users: "id" | "name" | "email" | "createdAt";
posts: "id" | "title" | "content" | "authorId";
comments: "id" | "body" | "postId" | "authorId";
};
interface QueryBuilder<T extends Table> {
// select only accepts columns that exist on the chosen table
select<C extends ColumnMap[T]>(...columns: C[]): this;
// where only accepts valid column names and comparison operators
where(condition: `${ColumnMap[T]} ${"=" | ">" | "<" | "!="} ?`): this;
limit(n: number): this;
build(): string;
}
// The type system validates column names and where clauses against the specific table schema
// — selecting "password" from "posts" or filtering by a non-existent column is a compile error